feat: 实现 NoWhatever 别说随便餐厅决策 Web App

- Framer Motion 卡片滑动 UI,带物理阻尼动画
- 多人房间系统,4位房间号 + SWR 实时轮询
- 高德地图 POI v5 API 搜索附近餐厅
- Web Share API 一键邀请,剪贴板降级方案
- SQLite/Prisma 持久化存储
- 移动端优先响应式设计 (Tailwind CSS)
This commit is contained in:
2026-02-24 16:49:43 +08:00
parent f5d921d585
commit d87d30ccc0
37 changed files with 8680 additions and 84 deletions
+45
View File
@@ -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
+31 -84
View File
@@ -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)! ```bash
npm install
## Add your files npm run dev
- [ ] [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
``` ```
## 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 ```
src/
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) ├── app/
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) │ ├── globals.css # Global styles (mobile-first, no scroll)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) │ ├── layout.tsx # Root layout with viewport meta
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) │ └── page.tsx # Main entry page
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) ├── components/
│ ├── TopNav.tsx # Navigation bar with room info
## Test and Deploy │ ├── RestaurantCard.tsx # Restaurant display card
│ ├── SwipeableCard.tsx # Framer Motion drag/swipe logic
Use the built-in continuous integration in GitLab. │ ├── SwipeDeck.tsx # Card stack orchestrator
│ ├── ActionButtons.tsx # Nope / Like action buttons
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) │ └── MatchResult.tsx # Match celebration screen
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) ├── data/
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) │ └── restaurants.ts # Mock restaurant data
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) └── types/
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) └── index.ts # TypeScript type definitions
```
***
# 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.
+18
View File
@@ -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;
+14
View File
@@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
export default nextConfig;
+7071
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+7
View File
@@ -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
);
+3
View File
@@ -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"
+14
View File
@@ -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())
}
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+41
View File
@@ -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 },
);
}
}
+33
View File
@@ -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 },
);
}
}
+65
View File
@@ -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 },
);
}
}
+116
View File
@@ -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

+28
View File
@@ -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;
}
+35
View File
@@ -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>
);
}
+182
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+172
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+148
View File
@@ -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} />
)}
</>
);
}
+114
View File
@@ -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>
);
}
+91
View File
@@ -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>
</>
);
}
+64
View File
@@ -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: "",
},
];
+22
View File
@@ -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,
};
}
+11
View File
@@ -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;
}
+61
View File
@@ -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) },
});
}
+12
View File
@@ -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;
}
+23
View File
@@ -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[];
}
+34
View File
@@ -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"]
}