feat: 实现 NoWhatever 别说随便餐厅决策 Web App
- Framer Motion 卡片滑动 UI,带物理阻尼动画 - 多人房间系统,4位房间号 + SWR 实时轮询 - 高德地图 POI v5 API 搜索附近餐厅 - Web Share API 一键邀请,剪贴板降级方案 - SQLite/Prisma 持久化存储 - 移动端优先响应式设计 (Tailwind CSS)
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# sqlite
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,93 +1,40 @@
|
||||
# no-whatever
|
||||
# NoWhatever — 别说随便
|
||||
|
||||
像 Tinder 一样滑卡片,和朋友一起决定去哪吃!解决聚餐时"随便都行"的纠结痛点,无需下载 App,用完即走。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
## Getting started
|
||||
- **Next.js** (App Router) + **React** + **TypeScript**
|
||||
- **Tailwind CSS** — Utility-first styling
|
||||
- **Framer Motion** — Physics-based swipe & drag animations
|
||||
- **Lucide React** — Icon library
|
||||
|
||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||
## Getting Started
|
||||
|
||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
||||
|
||||
## Add your files
|
||||
|
||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
||||
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin https://git.kurihada.com/root/no-whatever.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Integrate with your tools
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser (best viewed on mobile viewport).
|
||||
|
||||
- [ ] [Set up project integrations](https://git.kurihada.com/root/no-whatever/-/settings/integrations)
|
||||
## Project Structure
|
||||
|
||||
## Collaborate with your team
|
||||
|
||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
||||
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
||||
|
||||
## Test and Deploy
|
||||
|
||||
Use the built-in continuous integration in GitLab.
|
||||
|
||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
|
||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
||||
|
||||
***
|
||||
|
||||
# Editing this README
|
||||
|
||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
||||
|
||||
## Suggestions for a good README
|
||||
|
||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
||||
|
||||
## Name
|
||||
Choose a self-explaining name for your project.
|
||||
|
||||
## Description
|
||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
||||
|
||||
## Badges
|
||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
||||
|
||||
## Visuals
|
||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
||||
|
||||
## Installation
|
||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
||||
|
||||
## Usage
|
||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
||||
|
||||
## Support
|
||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
|
||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
||||
|
||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
||||
|
||||
## Authors and acknowledgment
|
||||
Show your appreciation to those who have contributed to the project.
|
||||
|
||||
## License
|
||||
For open source projects, say how it is licensed.
|
||||
|
||||
## Project status
|
||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── globals.css # Global styles (mobile-first, no scroll)
|
||||
│ ├── layout.tsx # Root layout with viewport meta
|
||||
│ └── page.tsx # Main entry page
|
||||
├── components/
|
||||
│ ├── TopNav.tsx # Navigation bar with room info
|
||||
│ ├── RestaurantCard.tsx # Restaurant display card
|
||||
│ ├── SwipeableCard.tsx # Framer Motion drag/swipe logic
|
||||
│ ├── SwipeDeck.tsx # Card stack orchestrator
|
||||
│ ├── ActionButtons.tsx # Nope / Like action buttons
|
||||
│ └── MatchResult.tsx # Match celebration screen
|
||||
├── data/
|
||||
│ └── restaurants.ts # Mock restaurant data
|
||||
└── types/
|
||||
└── index.ts # TypeScript type definitions
|
||||
```
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+7071
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "no-whatever",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.2",
|
||||
"framer-motion": "^12.34.3",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
"prisma": "^6.19.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"swr": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Room" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"data" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -0,0 +1,14 @@
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./dev.db"
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
model Room {
|
||||
id String @id
|
||||
data String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRoomData, updateRoomData } from "@/lib/store";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const data = await getRoomData(id);
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const { userId } = await req.json();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!data.users.includes(userId)) {
|
||||
data.users.push(userId);
|
||||
await updateRoomData(id, data);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
roomId: id,
|
||||
userCount: data.users.length,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to join room:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "加入房间失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRoomData } from "@/lib/store";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const data = await getRoomData(id);
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
roomId: id,
|
||||
userCount: data.users.length,
|
||||
match: data.match,
|
||||
restaurants: data.restaurants,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to get room:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "获取房间信息失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRoomData, updateRoomData } from "@/lib/store";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const data = await getRoomData(id);
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json(
|
||||
{ error: "房间不存在或已过期" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const { userId, restaurantId, action } = await req.json();
|
||||
|
||||
if (!userId || restaurantId == null || !action) {
|
||||
return NextResponse.json(
|
||||
{ error: "userId, restaurantId, and action are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const rid = String(restaurantId);
|
||||
let dirty = false;
|
||||
|
||||
if (action === "like") {
|
||||
if (!data.likes[rid]) {
|
||||
data.likes[rid] = [];
|
||||
}
|
||||
if (!data.likes[rid].includes(userId)) {
|
||||
data.likes[rid].push(userId);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
if (
|
||||
data.users.length > 1 &&
|
||||
data.likes[rid].length === data.users.length
|
||||
) {
|
||||
data.match = rid;
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
await updateRoomData(id, data);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
match: data.match,
|
||||
likeCount: data.likes[rid]?.length ?? 0,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to process swipe:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "操作失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createRoom } from "@/lib/store";
|
||||
import { Restaurant } from "@/types";
|
||||
import { fallbackRestaurants } from "@/data/restaurants";
|
||||
|
||||
interface AmapPoiV5 {
|
||||
id: string;
|
||||
name: string;
|
||||
distance?: string;
|
||||
type?: string;
|
||||
address?: string;
|
||||
location?: string;
|
||||
business?: {
|
||||
rating?: string;
|
||||
cost?: string;
|
||||
opentime_today?: string;
|
||||
opentime_week?: string;
|
||||
tel?: string;
|
||||
tag?: string;
|
||||
};
|
||||
photos?: { url: string }[];
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE =
|
||||
"https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800&q=80";
|
||||
|
||||
function extractCategory(type?: string): string {
|
||||
if (!type) return "";
|
||||
const parts = type.split(";");
|
||||
return parts[parts.length - 1] || parts[1] || "";
|
||||
}
|
||||
|
||||
function cleanField(val: unknown): string {
|
||||
if (!val || val === "[]" || (Array.isArray(val) && val.length === 0))
|
||||
return "";
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function mapPoiToRestaurant(poi: AmapPoiV5): Restaurant {
|
||||
const ratingStr = poi.business?.rating;
|
||||
const rating =
|
||||
ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || 4.0 : 4.0;
|
||||
|
||||
const costStr = poi.business?.cost;
|
||||
const price =
|
||||
costStr && costStr !== "[]" && costStr !== "0" ? `¥${costStr}` : "未知";
|
||||
|
||||
const image =
|
||||
poi.photos && poi.photos.length > 0 && poi.photos[0].url
|
||||
? poi.photos[0].url
|
||||
: DEFAULT_IMAGE;
|
||||
|
||||
const openTime =
|
||||
cleanField(poi.business?.opentime_week) ||
|
||||
cleanField(poi.business?.opentime_today);
|
||||
|
||||
return {
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
rating,
|
||||
price,
|
||||
distance: poi.distance ? `${poi.distance}m` : "",
|
||||
image,
|
||||
category: extractCategory(poi.type),
|
||||
address: cleanField(poi.address),
|
||||
openTime,
|
||||
tel: cleanField(poi.business?.tel),
|
||||
tag: cleanField(poi.business?.tag),
|
||||
location: cleanField(poi.location),
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let restaurants: Restaurant[] = fallbackRestaurants;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { lat, lng } = body;
|
||||
|
||||
if (lat && lng) {
|
||||
const apiKey = process.env.AMAP_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("AMAP_API_KEY not configured");
|
||||
} else {
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${lng},${lat}`);
|
||||
url.searchParams.set("radius", "3000");
|
||||
url.searchParams.set("types", "050000");
|
||||
url.searchParams.set("show_fields", "business,photos");
|
||||
url.searchParams.set("page_size", "15");
|
||||
url.searchParams.set("sortrule", "weight");
|
||||
|
||||
const amapRes = await fetch(url.toString());
|
||||
const amapData = await amapRes.json();
|
||||
|
||||
if (amapData.status === "1" && amapData.pois?.length > 0) {
|
||||
restaurants = amapData.pois.map(mapPoiToRestaurant);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Amap API error, using fallback data:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
const roomId = await createRoom(restaurants);
|
||||
return NextResponse.json({ roomId, restaurants });
|
||||
} catch (e) {
|
||||
console.error("Failed to create room:", e);
|
||||
return NextResponse.json(
|
||||
{ error: "创建房间失败,请重试" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #f8f9fa;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: none;
|
||||
touch-action: pan-y;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "NoWhatever — 别说随便",
|
||||
description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
|
||||
referrer: "no-referrer",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`${geistSans.variable} font-sans antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, LogIn, Utensils, Loader2 } from "lucide-react";
|
||||
import { getUserId } from "@/lib/userId";
|
||||
|
||||
const SHANGHAI_COORDS = { lat: 31.2222, lng: 121.4764 };
|
||||
|
||||
function getLocation(): Promise<{ lat: number; lng: number }> {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return Promise.resolve(SHANGHAI_COORDS);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(SHANGHAI_COORDS);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) =>
|
||||
resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
() => resolve(SHANGHAI_COORDS),
|
||||
{ timeout: 5000, enableHighAccuracy: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const [roomCode, setRoomCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingText, setLoadingText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const joinRoom = async (roomId: string) => {
|
||||
const userId = getUserId();
|
||||
const res = await fetch(`/api/room/${roomId}/join`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("房间不存在");
|
||||
return roomId;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setLoadingText("正在获取位置...");
|
||||
|
||||
try {
|
||||
const coords = await getLocation();
|
||||
|
||||
setLoadingText("正在搜索周边美食...");
|
||||
|
||||
const res = await fetch("/api/room/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(coords),
|
||||
});
|
||||
const { roomId } = await res.json();
|
||||
|
||||
setLoadingText("正在进入房间...");
|
||||
await joinRoom(roomId);
|
||||
router.push(`/room/${roomId}`);
|
||||
} catch {
|
||||
setError("创建失败,请重试");
|
||||
setLoading(false);
|
||||
setLoadingText("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (roomCode.length !== 4) {
|
||||
setError("请输入 4 位房间号");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await joinRoom(roomCode);
|
||||
router.push(`/room/${roomCode}`);
|
||||
} catch {
|
||||
setError("房间不存在,请检查房间号");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col items-center justify-center bg-background px-6">
|
||||
<motion.div
|
||||
className="flex flex-col items-center"
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-200">
|
||||
<Utensils size={28} className="text-white" />
|
||||
</div>
|
||||
|
||||
<h1 className="mt-5 text-3xl font-black tracking-tight text-zinc-900">
|
||||
NoWhatever
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm font-medium tracking-widest text-zinc-400">
|
||||
别说随便
|
||||
</p>
|
||||
<p className="mt-3 max-w-xs text-center text-sm leading-relaxed text-zinc-500">
|
||||
和朋友一起滑卡片,再也不用纠结吃什么
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-10 flex w-full max-w-xs flex-col gap-3"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.15 }}
|
||||
>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={loading}
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{loading && loadingText ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={18} strokeWidth={3} />
|
||||
创建新房间
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<div className="h-px flex-1 bg-zinc-200" />
|
||||
<span className="text-xs text-zinc-400">或加入已有房间</span>
|
||||
<div className="h-px flex-1 bg-zinc-200" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleJoin} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
placeholder="输入 4 位房间号"
|
||||
value={roomCode}
|
||||
onChange={(e) => {
|
||||
setRoomCode(e.target.value.replace(/\D/g, "").slice(0, 4));
|
||||
setError("");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="h-12 flex-1 rounded-xl border border-zinc-200 bg-white px-4 text-center text-lg font-semibold tracking-[0.3em] text-zinc-900 outline-none transition-colors placeholder:text-sm placeholder:tracking-normal placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || roomCode.length !== 4}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-900 text-white transition-colors hover:bg-zinc-700 disabled:opacity-30"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="text-center text-xs font-medium text-rose-500"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import TopNav from "@/components/TopNav";
|
||||
import SwipeDeck from "@/components/SwipeDeck";
|
||||
import { useRoomPolling } from "@/hooks/useRoomPolling";
|
||||
import { getUserId } from "@/lib/userId";
|
||||
|
||||
export default function RoomPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const roomId = params.id;
|
||||
|
||||
const [userId, setUserId] = useState("");
|
||||
const [joined, setJoined] = useState(false);
|
||||
|
||||
const { userCount, match, restaurants } = useRoomPolling(roomId);
|
||||
|
||||
useEffect(() => {
|
||||
const id = getUserId();
|
||||
setUserId(id);
|
||||
|
||||
fetch(`/api/room/${roomId}/join`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: id }),
|
||||
}).then(() => setJoined(true));
|
||||
}, [roomId]);
|
||||
|
||||
const ready = joined && userId && restaurants.length > 0;
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-background">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-emerald-500" />
|
||||
<p className="text-sm text-zinc-400">正在加载餐厅数据...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col bg-background">
|
||||
<TopNav roomId={roomId} userCount={userCount} />
|
||||
<SwipeDeck
|
||||
restaurants={restaurants}
|
||||
roomId={roomId}
|
||||
userId={userId}
|
||||
matchedRestaurantId={match}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { X, Heart } from "lucide-react";
|
||||
import { SwipeDirection } from "@/types";
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onAction: (direction: SwipeDirection) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default function ActionButtons({
|
||||
onAction,
|
||||
disabled,
|
||||
}: ActionButtonsProps) {
|
||||
return (
|
||||
<div className="relative z-10 flex items-center justify-center gap-8 pb-8 pt-4">
|
||||
<motion.button
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-lg shadow-rose-200/50 ring-1 ring-rose-100 disabled:opacity-40"
|
||||
whileTap={{ scale: 0.85 }}
|
||||
whileHover={{ scale: 1.08 }}
|
||||
onClick={() => onAction("left")}
|
||||
disabled={disabled}
|
||||
aria-label="Nope"
|
||||
>
|
||||
<X size={30} className="text-rose-500" strokeWidth={3} />
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-lg shadow-emerald-200/50 ring-1 ring-emerald-100 disabled:opacity-40"
|
||||
whileTap={{ scale: 0.85 }}
|
||||
whileHover={{ scale: 1.08 }}
|
||||
onClick={() => onAction("right")}
|
||||
disabled={disabled}
|
||||
aria-label="Like"
|
||||
>
|
||||
<Heart
|
||||
size={28}
|
||||
className="fill-emerald-500 text-emerald-500"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
MapPin,
|
||||
Star,
|
||||
PartyPopper,
|
||||
Navigation,
|
||||
Phone,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { Restaurant } from "@/types";
|
||||
|
||||
interface MatchResultProps {
|
||||
restaurant: Restaurant;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function buildNavUrl(restaurant: Restaurant): string {
|
||||
if (restaurant.location) {
|
||||
const [lng, lat] = restaurant.location.split(",");
|
||||
return `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(restaurant.name)}&callnative=1`;
|
||||
}
|
||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`;
|
||||
}
|
||||
|
||||
export default function MatchResult({ restaurant, onReset }: MatchResultProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto bg-linear-to-b from-emerald-500 to-teal-600 px-6 py-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -20 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
|
||||
>
|
||||
<PartyPopper size={56} className="text-yellow-300" />
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
className="mt-3 text-4xl font-black text-white"
|
||||
initial={{ y: 30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
>
|
||||
就去这了!
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
className="mt-1 text-sm font-medium text-emerald-100"
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
>
|
||||
Everyone agreed on this one
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mt-6 w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
initial={{ y: 60, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }}
|
||||
>
|
||||
<img
|
||||
src={restaurant.image}
|
||||
alt={restaurant.name}
|
||||
className="h-44 w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="text-lg font-bold leading-tight text-zinc-900">
|
||||
{restaurant.name}
|
||||
</h2>
|
||||
{restaurant.category && (
|
||||
<span className="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-600">
|
||||
{restaurant.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-3 text-sm text-zinc-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Star size={13} className="fill-amber-400 text-amber-400" />
|
||||
{restaurant.rating}
|
||||
</span>
|
||||
<span className="font-semibold text-emerald-600">
|
||||
{restaurant.price}
|
||||
</span>
|
||||
{restaurant.distance && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin size={13} />
|
||||
{restaurant.distance}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{restaurant.address && (
|
||||
<p className="mt-2 text-xs leading-relaxed text-zinc-400">
|
||||
{restaurant.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{restaurant.openTime && (
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs text-zinc-400">
|
||||
<Clock size={12} />
|
||||
<span>{restaurant.openTime}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restaurant.tag && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{restaurant.tag
|
||||
.split(",")
|
||||
.slice(0, 4)
|
||||
.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700"
|
||||
>
|
||||
{t.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-5 flex w-full max-w-sm flex-col gap-2.5"
|
||||
initial={{ y: 30, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.65 }}
|
||||
>
|
||||
<motion.a
|
||||
href={buildNavUrl(restaurant)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3 text-sm font-bold text-emerald-600 shadow-lg transition-colors hover:bg-emerald-50"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Navigation size={16} />
|
||||
导航过去
|
||||
</motion.a>
|
||||
|
||||
{restaurant.tel && (
|
||||
<motion.a
|
||||
href={`tel:${restaurant.tel}`}
|
||||
className="flex items-center justify-center gap-2 rounded-full bg-white/20 px-8 py-3 text-sm font-bold text-white backdrop-blur-sm transition-colors hover:bg-white/30"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Phone size={15} />
|
||||
打电话订位
|
||||
</motion.a>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
className="mt-4 text-sm font-medium text-emerald-200 underline underline-offset-2 hover:text-white"
|
||||
onClick={onReset}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
再来一轮
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Star, MapPin, Clock } from "lucide-react";
|
||||
import { Restaurant } from "@/types";
|
||||
|
||||
interface RestaurantCardProps {
|
||||
restaurant: Restaurant;
|
||||
}
|
||||
|
||||
export default function RestaurantCard({ restaurant }: RestaurantCardProps) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||
<div className="relative h-[58%] w-full shrink-0 overflow-hidden">
|
||||
<img
|
||||
src={restaurant.image}
|
||||
alt={restaurant.name}
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/40 via-transparent to-transparent" />
|
||||
|
||||
{restaurant.category && (
|
||||
<span className="absolute bottom-3 left-4 rounded-full bg-white/90 px-2.5 py-0.5 text-xs font-semibold text-zinc-700 shadow-sm backdrop-blur-sm">
|
||||
{restaurant.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-center gap-2 px-5 py-3">
|
||||
<h2 className="text-lg font-bold leading-tight text-zinc-900">
|
||||
{restaurant.name}
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star size={14} className="fill-amber-400 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-zinc-800">
|
||||
{restaurant.rating}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-semibold text-emerald-600">
|
||||
{restaurant.price}
|
||||
</span>
|
||||
|
||||
{restaurant.distance && (
|
||||
<div className="flex items-center gap-1 text-zinc-400">
|
||||
<MapPin size={13} />
|
||||
<span className="text-xs">{restaurant.distance}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{restaurant.address && (
|
||||
<p className="truncate text-xs leading-tight text-zinc-400">
|
||||
{restaurant.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{restaurant.openTime && (
|
||||
<div className="flex items-center gap-1 text-xs text-zinc-400">
|
||||
<Clock size={12} />
|
||||
<span>{restaurant.openTime}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restaurant.tag && (
|
||||
<div className="flex gap-1.5 overflow-hidden">
|
||||
{restaurant.tag
|
||||
.split(",")
|
||||
.slice(0, 3)
|
||||
.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700"
|
||||
>
|
||||
{t.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import SwipeableCard from "./SwipeableCard";
|
||||
import ActionButtons from "./ActionButtons";
|
||||
import MatchResult from "./MatchResult";
|
||||
import { Restaurant, SwipeDirection } from "@/types";
|
||||
|
||||
interface SwipeDeckProps {
|
||||
restaurants: Restaurant[];
|
||||
roomId: string;
|
||||
userId: string;
|
||||
matchedRestaurantId: string | null;
|
||||
}
|
||||
|
||||
export default function SwipeDeck({
|
||||
restaurants,
|
||||
roomId,
|
||||
userId,
|
||||
matchedRestaurantId,
|
||||
}: SwipeDeckProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showMatch, setShowMatch] = useState(false);
|
||||
const [localMatchId, setLocalMatchId] = useState<string | null>(null);
|
||||
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
|
||||
const swipingRef = useRef(false);
|
||||
|
||||
const resolvedMatchId = matchedRestaurantId ?? localMatchId;
|
||||
|
||||
useEffect(() => {
|
||||
if (matchedRestaurantId != null && !showMatch) {
|
||||
setShowMatch(true);
|
||||
}
|
||||
}, [matchedRestaurantId, showMatch]);
|
||||
|
||||
const registerSwipe = useCallback(
|
||||
(fn: (direction: SwipeDirection) => void) => {
|
||||
swipeFnRef.current = fn;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const sendSwipe = async (restaurantId: string, action: "like" | "nope") => {
|
||||
try {
|
||||
const res = await fetch(`/api/room/${roomId}/swipe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, restaurantId, action }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.match != null) {
|
||||
setLocalMatchId(data.match);
|
||||
}
|
||||
} catch {
|
||||
// Polling will catch match state
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipe = useCallback(
|
||||
(direction: SwipeDirection) => {
|
||||
const current = restaurants[currentIndex];
|
||||
if (!current) return;
|
||||
|
||||
swipingRef.current = false;
|
||||
|
||||
const action = direction === "right" ? "like" : "nope";
|
||||
sendSwipe(current.id, action);
|
||||
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
swipeFnRef.current = null;
|
||||
|
||||
if (nextIndex >= restaurants.length && !resolvedMatchId) {
|
||||
setTimeout(() => {
|
||||
if (!showMatch) setShowMatch(true);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[currentIndex, restaurants, roomId, userId, resolvedMatchId, showMatch],
|
||||
);
|
||||
|
||||
const handleButtonAction = useCallback(
|
||||
(direction: SwipeDirection) => {
|
||||
if (swipeFnRef.current && !swipingRef.current) {
|
||||
swipingRef.current = true;
|
||||
swipeFnRef.current(direction);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setCurrentIndex(0);
|
||||
setShowMatch(false);
|
||||
setLocalMatchId(null);
|
||||
}, []);
|
||||
|
||||
const isDone = currentIndex >= restaurants.length || resolvedMatchId != null;
|
||||
|
||||
const matchRestaurant = resolvedMatchId
|
||||
? restaurants.find((r) => r.id === resolvedMatchId) ?? restaurants[0]
|
||||
: restaurants[0];
|
||||
|
||||
const showWaiting =
|
||||
currentIndex >= restaurants.length && !resolvedMatchId && !showMatch;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex flex-1 items-center justify-center px-4">
|
||||
<div className="relative h-[70vh] w-full max-w-sm">
|
||||
{!resolvedMatchId && (
|
||||
<AnimatePresence>
|
||||
{restaurants.map((restaurant, index) => {
|
||||
if (index < currentIndex || index > currentIndex + 1)
|
||||
return null;
|
||||
const isTop = index === currentIndex;
|
||||
return (
|
||||
<SwipeableCard
|
||||
key={restaurant.id}
|
||||
restaurant={restaurant}
|
||||
isTop={isTop}
|
||||
onSwipe={handleSwipe}
|
||||
registerSwipe={isTop ? registerSwipe : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{showWaiting && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-300 border-t-emerald-500" />
|
||||
<p className="text-sm text-zinc-400">等待其他人完成选择...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActionButtons onAction={handleButtonAction} disabled={isDone} />
|
||||
|
||||
{showMatch && matchRestaurant && (
|
||||
<MatchResult restaurant={matchRestaurant} onReset={handleReset} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
animate,
|
||||
PanInfo,
|
||||
MotionValue,
|
||||
} from "framer-motion";
|
||||
import RestaurantCard from "./RestaurantCard";
|
||||
import { Restaurant, SwipeDirection } from "@/types";
|
||||
|
||||
const SWIPE_THRESHOLD = 120;
|
||||
const EXIT_X = 600;
|
||||
const ROTATION_RANGE = 18;
|
||||
|
||||
interface SwipeableCardProps {
|
||||
restaurant: Restaurant;
|
||||
isTop: boolean;
|
||||
onSwipe: (direction: SwipeDirection) => void;
|
||||
registerSwipe?: (fn: (direction: SwipeDirection) => void) => void;
|
||||
}
|
||||
|
||||
function SwipeOverlay({ x }: { x: MotionValue<number> }) {
|
||||
const likeOpacity = useTransform(x, [0, SWIPE_THRESHOLD], [0, 1]);
|
||||
const nopeOpacity = useTransform(x, [-SWIPE_THRESHOLD, 0], [1, 0]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-start justify-start rounded-2xl border-4 border-emerald-400 p-6"
|
||||
style={{ opacity: likeOpacity }}
|
||||
>
|
||||
<span className="rounded-lg border-3 border-emerald-400 px-3 py-1 text-2xl font-extrabold tracking-wide text-emerald-400">
|
||||
LIKE
|
||||
</span>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-start justify-end rounded-2xl border-4 border-rose-400 p-6"
|
||||
style={{ opacity: nopeOpacity }}
|
||||
>
|
||||
<span className="rounded-lg border-3 border-rose-400 px-3 py-1 text-2xl font-extrabold tracking-wide text-rose-400">
|
||||
NOPE
|
||||
</span>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SwipeableCard({
|
||||
restaurant,
|
||||
isTop,
|
||||
onSwipe,
|
||||
registerSwipe,
|
||||
}: SwipeableCardProps) {
|
||||
const x = useMotionValue(0);
|
||||
const rotate = useTransform(x, [-300, 300], [-ROTATION_RANGE, ROTATION_RANGE]);
|
||||
const opacity = useTransform(x, [-300, -100, 0, 100, 300], [0.5, 1, 1, 1, 0.5]);
|
||||
|
||||
const isSwiping = useRef(false);
|
||||
|
||||
const flyOut = (direction: SwipeDirection) => {
|
||||
if (isSwiping.current) return;
|
||||
isSwiping.current = true;
|
||||
const exitX = direction === "right" ? EXIT_X : -EXIT_X;
|
||||
animate(x, exitX, {
|
||||
type: "spring",
|
||||
stiffness: 600,
|
||||
damping: 40,
|
||||
onComplete: () => onSwipe(direction),
|
||||
});
|
||||
};
|
||||
|
||||
if (registerSwipe) {
|
||||
registerSwipe(flyOut);
|
||||
}
|
||||
|
||||
const handleDragEnd = (_: unknown, info: PanInfo) => {
|
||||
const offsetX = info.offset.x;
|
||||
if (offsetX > SWIPE_THRESHOLD) {
|
||||
flyOut("right");
|
||||
} else if (offsetX < -SWIPE_THRESHOLD) {
|
||||
flyOut("left");
|
||||
} else {
|
||||
animate(x, 0, { type: "spring", stiffness: 500, damping: 30 });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
x,
|
||||
rotate,
|
||||
opacity,
|
||||
zIndex: isTop ? 10 : 0,
|
||||
cursor: isTop ? "grab" : "default",
|
||||
}}
|
||||
drag={isTop ? "x" : false}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.9}
|
||||
onDragEnd={handleDragEnd}
|
||||
whileDrag={{ cursor: "grabbing" }}
|
||||
initial={isTop ? { scale: 1 } : { scale: 0.95, y: 16 }}
|
||||
animate={isTop ? { scale: 1, y: 0 } : { scale: 0.95, y: 16 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
<SwipeOverlay x={x} />
|
||||
<RestaurantCard restaurant={restaurant} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Users, Share2 } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface TopNavProps {
|
||||
roomId: string;
|
||||
userCount: number;
|
||||
}
|
||||
|
||||
export default function TopNav({ roomId, userCount }: TopNavProps) {
|
||||
const [toast, setToast] = useState("");
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(""), 2200);
|
||||
}, []);
|
||||
|
||||
const handleInvite = useCallback(async () => {
|
||||
const url = window.location.href;
|
||||
const shareData = {
|
||||
title: "别说随便啦,来滑卡片决定吃什么!",
|
||||
text: "我建好房间了,快点开链接一起选餐厅,滑中同一家就去吃!",
|
||||
url,
|
||||
};
|
||||
|
||||
try {
|
||||
if (navigator.share && navigator.canShare?.(shareData)) {
|
||||
await navigator.share(shareData);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === "AbortError") return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
showToast("邀请链接已复制,快去发给朋友吧!");
|
||||
} catch {
|
||||
showToast("复制失败,请手动复制链接");
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="relative z-10 flex h-14 items-center justify-between px-4">
|
||||
<div className="w-24">
|
||||
<button
|
||||
onClick={handleInvite}
|
||||
className="flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-600 transition-colors active:bg-emerald-100"
|
||||
>
|
||||
<Share2 size={13} />
|
||||
邀请饭搭子
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-center text-base font-bold tracking-tight text-zinc-900">
|
||||
<span className="block leading-tight">NoWhatever</span>
|
||||
<span className="block text-[10px] font-medium tracking-widest text-zinc-400">
|
||||
别说随便
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="flex w-24 items-center justify-end gap-1.5 text-xs text-zinc-500">
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-medium">
|
||||
{roomId}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Users size={13} />
|
||||
<span className="font-semibold text-emerald-500">{userCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
className="fixed left-1/2 top-16 z-50 -translate-x-1/2 rounded-xl bg-zinc-900 px-4 py-2.5 text-xs font-medium text-white shadow-lg"
|
||||
initial={{ opacity: 0, y: -12, x: "-50%" }}
|
||||
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
||||
exit={{ opacity: 0, y: -12, x: "-50%" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
{toast}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Restaurant } from "@/types";
|
||||
|
||||
export const fallbackRestaurants: Restaurant[] = [
|
||||
{
|
||||
id: "fallback-1",
|
||||
name: "天天海南鸡饭 Tian Tian",
|
||||
rating: 4.8,
|
||||
price: "¥15",
|
||||
distance: "800m",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1547592180-85f173990554?w=800&q=80",
|
||||
category: "东南亚菜",
|
||||
address: "Maxwell Food Centre #01-10",
|
||||
openTime: "10:00-19:30",
|
||||
tel: "",
|
||||
tag: "海南鸡饭",
|
||||
location: "",
|
||||
},
|
||||
{
|
||||
id: "fallback-2",
|
||||
name: "珍宝海鲜 Jumbo Seafood",
|
||||
rating: 4.5,
|
||||
price: "¥200",
|
||||
distance: "1.2km",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1615141982883-c7ad0e69fd62?w=800&q=80",
|
||||
category: "海鲜",
|
||||
address: "河畔驳船码头 #01-01/02",
|
||||
openTime: "11:30-23:00",
|
||||
tel: "",
|
||||
tag: "辣椒螃蟹,黑胡椒蟹",
|
||||
location: "",
|
||||
},
|
||||
{
|
||||
id: "fallback-3",
|
||||
name: "松发肉骨茶 Song Fa",
|
||||
rating: 4.7,
|
||||
price: "¥60",
|
||||
distance: "1.5km",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1476718406336-bb5a9690ee2a?w=800&q=80",
|
||||
category: "肉骨茶",
|
||||
address: "新桥路11号 #01-01",
|
||||
openTime: "09:00-21:00",
|
||||
tel: "",
|
||||
tag: "肉骨茶,卤味",
|
||||
location: "",
|
||||
},
|
||||
{
|
||||
id: "fallback-4",
|
||||
name: "老巴刹 Lau Pa Sat 沙爹",
|
||||
rating: 4.3,
|
||||
price: "¥25",
|
||||
distance: "2.1km",
|
||||
image:
|
||||
"https://images.unsplash.com/photo-1555939594-58d7cb561ad1?w=800&q=80",
|
||||
category: "小吃",
|
||||
address: "Boon Tat Street 18号",
|
||||
openTime: "全天",
|
||||
tel: "",
|
||||
tag: "沙爹,烤串",
|
||||
location: "",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { RoomStatus } from "@/types";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function useRoomPolling(roomId: string) {
|
||||
const { data, error, isLoading } = useSWR<RoomStatus>(
|
||||
`/api/room/${roomId}`,
|
||||
fetcher,
|
||||
{ refreshInterval: 1500, revalidateOnFocus: true },
|
||||
);
|
||||
|
||||
return {
|
||||
userCount: data?.userCount ?? 0,
|
||||
match: data?.match ?? null,
|
||||
restaurants: data?.restaurants ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { Restaurant } from "@/types";
|
||||
|
||||
export interface RoomData {
|
||||
users: string[];
|
||||
restaurants: Restaurant[];
|
||||
likes: Record<string, string[]>;
|
||||
match: string | null;
|
||||
}
|
||||
|
||||
function generateRoomId(): string {
|
||||
return String(Math.floor(1000 + Math.random() * 9000));
|
||||
}
|
||||
|
||||
export async function createRoom(restaurants: Restaurant[]): Promise<string> {
|
||||
const data: RoomData = {
|
||||
users: [],
|
||||
restaurants,
|
||||
likes: {},
|
||||
match: null,
|
||||
};
|
||||
|
||||
let roomId: string;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < 20) {
|
||||
roomId = generateRoomId();
|
||||
const existing = await prisma.room.findUnique({ where: { id: roomId } });
|
||||
if (!existing) {
|
||||
await prisma.room.create({
|
||||
data: { id: roomId, data: JSON.stringify(data) },
|
||||
});
|
||||
return roomId;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
roomId = generateRoomId() + String(Date.now()).slice(-2);
|
||||
await prisma.room.create({
|
||||
data: { id: roomId, data: JSON.stringify(data) },
|
||||
});
|
||||
return roomId;
|
||||
}
|
||||
|
||||
export async function getRoomData(
|
||||
roomId: string,
|
||||
): Promise<RoomData | null> {
|
||||
const room = await prisma.room.findUnique({ where: { id: roomId } });
|
||||
if (!room) return null;
|
||||
return JSON.parse(room.data) as RoomData;
|
||||
}
|
||||
|
||||
export async function updateRoomData(
|
||||
roomId: string,
|
||||
data: RoomData,
|
||||
): Promise<void> {
|
||||
await prisma.room.update({
|
||||
where: { id: roomId },
|
||||
data: { data: JSON.stringify(data) },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const STORAGE_KEY = "nowhatever_user_id";
|
||||
|
||||
export function getUserId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
|
||||
let id = localStorage.getItem(STORAGE_KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface Restaurant {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
price: string;
|
||||
distance: string;
|
||||
image: string;
|
||||
category: string;
|
||||
address: string;
|
||||
openTime: string;
|
||||
tel: string;
|
||||
tag: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export type SwipeDirection = "left" | "right";
|
||||
|
||||
export interface RoomStatus {
|
||||
roomId: string;
|
||||
userCount: number;
|
||||
match: string | null;
|
||||
restaurants: Restaurant[];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user