diff --git a/README.md b/README.md index 25322ad..14bc3a6 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,6 @@ src/ │ ├── 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 ``` diff --git a/package-lock.json b/package-lock.json index 19ad09e..ec8835e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@prisma/client": "^6.19.2", + "bcryptjs": "^3.0.3", "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", "lucide-react": "^0.575.0", @@ -21,6 +22,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", @@ -1618,6 +1620,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/canvas-confetti": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", @@ -2548,6 +2557,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/package.json b/package.json index 482f7c3..8db0243 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@prisma/client": "^6.19.2", + "bcryptjs": "^3.0.3", "canvas-confetti": "^1.9.4", "framer-motion": "^12.34.3", "lucide-react": "^0.575.0", @@ -22,6 +23,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8f0ede4..07ad26e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,3 +13,35 @@ model Room { createdAt DateTime @default(now()) expiresAt DateTime } + +model User { + id String @id @default(cuid()) + username String @unique + passwordHash String + avatar String @default("🐱") + email String? @unique + preferences String @default("{}") + createdAt DateTime @default(now()) + decisions Decision[] + favorites Favorite[] +} + +model Decision { + id String @id @default(cuid()) + userId String + roomId String + restaurantName String + restaurantData String + matchType String + participants Int + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) +} + +model Favorite { + id String @id @default(cuid()) + userId String + restaurantData String + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..8d72ef7 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; + +export async function POST(req: NextRequest) { + const { username, password } = await req.json(); + + if (!username || !password) { + return NextResponse.json({ error: "请输入用户名和密码" }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { username: username.trim() } }); + if (!user) { + return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 }); + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 }); + } + + return NextResponse.json({ + id: user.id, + username: user.username, + avatar: user.avatar, + }); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..3f5cc28 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; + +export async function POST(req: NextRequest) { + const { username, password, avatar } = await req.json(); + + if (!username || !password) { + return NextResponse.json({ error: "用户名和密码为必填项" }, { status: 400 }); + } + + const trimmedUsername = username.trim(); + if (trimmedUsername.length < 2 || trimmedUsername.length > 16) { + return NextResponse.json({ error: "用户名需要 2-16 个字符" }, { status: 400 }); + } + + if (password.length < 6) { + return NextResponse.json({ error: "密码至少 6 个字符" }, { status: 400 }); + } + + const existing = await prisma.user.findUnique({ where: { username: trimmedUsername } }); + if (existing) { + return NextResponse.json({ error: "用户名已被注册" }, { status: 409 }); + } + + const passwordHash = await bcrypt.hash(password, 10); + + const user = await prisma.user.create({ + data: { + username: trimmedUsername, + passwordHash, + avatar: avatar || "🐱", + }, + }); + + return NextResponse.json({ + id: user.id, + username: user.username, + avatar: user.avatar, + }); +} diff --git a/src/app/api/user/favorite/route.ts b/src/app/api/user/favorite/route.ts new file mode 100644 index 0000000..e17a8de --- /dev/null +++ b/src/app/api/user/favorite/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: NextRequest) { + const userId = req.nextUrl.searchParams.get("userId"); + if (!userId) { + return NextResponse.json([]); + } + + const favorites = await prisma.favorite.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + + return NextResponse.json( + favorites.map((f) => ({ + id: f.id, + restaurantData: JSON.parse(f.restaurantData), + createdAt: f.createdAt.toISOString(), + })), + ); +} + +export async function POST(req: NextRequest) { + const { userId, restaurant } = await req.json(); + + if (!userId || !restaurant) { + return NextResponse.json({ error: "缺少必要字段" }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return NextResponse.json({ error: "请先设置个人资料" }, { status: 404 }); + } + + const existing = await prisma.favorite.findFirst({ + where: { + userId, + restaurantData: { contains: `"id":"${restaurant.id}"` }, + }, + }); + + if (existing) { + return NextResponse.json({ id: existing.id, alreadyExists: true }); + } + + const fav = await prisma.favorite.create({ + data: { + userId, + restaurantData: JSON.stringify(restaurant), + }, + }); + + return NextResponse.json({ id: fav.id }); +} + +export async function DELETE(req: NextRequest) { + const { userId, favoriteId } = await req.json(); + + if (!userId || !favoriteId) { + return NextResponse.json({ error: "缺少必要字段" }, { status: 400 }); + } + + const fav = await prisma.favorite.findUnique({ where: { id: favoriteId } }); + if (!fav || fav.userId !== userId) { + return NextResponse.json({ error: "收藏不存在" }, { status: 404 }); + } + + await prisma.favorite.delete({ where: { id: favoriteId } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/user/history/route.ts b/src/app/api/user/history/route.ts new file mode 100644 index 0000000..b5503df --- /dev/null +++ b/src/app/api/user/history/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +const MAX_HISTORY = 50; + +export async function GET(req: NextRequest) { + const userId = req.nextUrl.searchParams.get("userId"); + if (!userId) { + return NextResponse.json([]); + } + + const decisions = await prisma.decision.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: MAX_HISTORY, + }); + + return NextResponse.json( + decisions.map((d) => ({ + id: d.id, + roomId: d.roomId, + restaurantName: d.restaurantName, + restaurantData: JSON.parse(d.restaurantData), + matchType: d.matchType, + participants: d.participants, + createdAt: d.createdAt.toISOString(), + })), + ); +} + +export async function POST(req: NextRequest) { + const { userId, roomId, restaurant, matchType, participants } = + await req.json(); + + if (!userId || !roomId || !restaurant || !matchType) { + return NextResponse.json({ error: "缺少必要字段" }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return NextResponse.json({ error: "用户未注册" }, { status: 404 }); + } + + const existing = await prisma.decision.findFirst({ + where: { userId, roomId }, + }); + if (existing) { + return NextResponse.json({ id: existing.id, alreadyExists: true }); + } + + const decision = await prisma.decision.create({ + data: { + userId, + roomId, + restaurantName: restaurant.name, + restaurantData: JSON.stringify(restaurant), + matchType, + participants: participants ?? 1, + }, + }); + + const count = await prisma.decision.count({ where: { userId } }); + if (count > MAX_HISTORY) { + const oldest = await prisma.decision.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + take: count - MAX_HISTORY, + select: { id: true }, + }); + await prisma.decision.deleteMany({ + where: { id: { in: oldest.map((d) => d.id) } }, + }); + } + + return NextResponse.json({ id: decision.id }); +} diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts new file mode 100644 index 0000000..60a8725 --- /dev/null +++ b/src/app/api/user/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; + +export async function GET(req: NextRequest) { + const userId = req.nextUrl.searchParams.get("id"); + if (!userId) { + return NextResponse.json(null); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return NextResponse.json(null); + } + + return NextResponse.json({ + id: user.id, + username: user.username, + avatar: user.avatar, + email: user.email, + preferences: JSON.parse(user.preferences), + createdAt: user.createdAt.toISOString(), + }); +} + +export async function PUT(req: NextRequest) { + const body = await req.json(); + const { userId } = body; + + if (!userId) { + return NextResponse.json({ error: "缺少用户 ID" }, { status: 400 }); + } + + const existing = await prisma.user.findUnique({ where: { id: userId } }); + if (!existing) { + return NextResponse.json({ error: "用户不存在" }, { status: 404 }); + } + + const updateData: Record = {}; + + if (body.username !== undefined) { + const trimmed = body.username.trim(); + if (trimmed.length < 2 || trimmed.length > 16) { + return NextResponse.json({ error: "用户名需要 2-16 个字符" }, { status: 400 }); + } + if (trimmed !== existing.username) { + const taken = await prisma.user.findUnique({ where: { username: trimmed } }); + if (taken) { + return NextResponse.json({ error: "用户名已被占用" }, { status: 409 }); + } + } + updateData.username = trimmed; + } + + if (body.newPassword !== undefined) { + if (!body.currentPassword) { + return NextResponse.json({ error: "请输入当前密码" }, { status: 400 }); + } + const valid = await bcrypt.compare(body.currentPassword, existing.passwordHash); + if (!valid) { + return NextResponse.json({ error: "当前密码错误" }, { status: 403 }); + } + if (body.newPassword.length < 6) { + return NextResponse.json({ error: "新密码至少 6 个字符" }, { status: 400 }); + } + updateData.passwordHash = await bcrypt.hash(body.newPassword, 10); + } + + if (body.avatar !== undefined) { + updateData.avatar = body.avatar; + } + + if (body.email !== undefined) { + if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { + return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 }); + } + updateData.email = body.email || null; + } + + if (body.preferences !== undefined) { + updateData.preferences = JSON.stringify(body.preferences); + } + + const user = await prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + + return NextResponse.json({ + id: user.id, + username: user.username, + avatar: user.avatar, + email: user.email, + preferences: JSON.parse(user.preferences), + }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8d519eb..a050c5b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,8 +3,11 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; -import { Plus, LogIn, Utensils, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame } from "lucide-react"; -import { getUserId } from "@/lib/userId"; +import { Plus, LogIn, Loader2, MapPin, Navigation, X, Users, Heart, Sparkles, ChevronRight, Flame, User } from "lucide-react"; +import { getUserId, getCachedProfile, getCachedPreferences } from "@/lib/userId"; +import { getAvatarBg } from "@/lib/avatars"; +import AuthModal from "@/components/AuthModal"; +import type { UserProfile } from "@/types"; interface LocationSuggestion { id: string; @@ -70,6 +73,19 @@ export default function LandingPage() { const suggestRef = useRef(null); const debounceRef = useRef>(null); + const [profile, setProfile] = useState(null); + const [authModalOpen, setAuthModalOpen] = useState(false); + + useEffect(() => { + const cached = getCachedProfile(); + if (cached) setProfile(cached); + + const prefs = getCachedPreferences(); + if (prefs.cuisine) setCuisine(prefs.cuisine); + if (prefs.priceRange) setPriceRange(prefs.priceRange); + if (prefs.radius) setRadius(prefs.radius); + }, []); + const fetchSuggestions = useCallback(async (query: string) => { if (query.length < 1) { setSuggestions([]); @@ -191,7 +207,28 @@ export default function LandingPage() { }; return ( -
+
+ {/* Profile / Auth button */} +
+ {profile ? ( + + ) : ( + + )} +
+ )} + + setAuthModalOpen(false)} + onAuth={(p) => setProfile(p)} + />
); } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..12f278e --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,686 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { motion, AnimatePresence } from "framer-motion"; +import { + ArrowLeft, + Mail, + Clock, + Star, + MapPin, + Trash2, + Loader2, + ChevronDown, + LogOut, + Lock, + Edit3, + Check, + X, + Eye, + EyeOff, +} from "lucide-react"; +import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId"; +import { getAvatarBg, AVATARS } from "@/lib/avatars"; +import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types"; + +export default function ProfilePage() { + const router = useRouter(); + const [userId, setUserId] = useState(""); + const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences }) | null>(null); + const [loading, setLoading] = useState(true); + + const [history, setHistory] = useState([]); + const [favorites, setFavorites] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [favLoading, setFavLoading] = useState(false); + + const [editingUsername, setEditingUsername] = useState(false); + const [newUsername, setNewUsername] = useState(""); + const [usernameSaving, setUsernameSaving] = useState(false); + const [usernameMsg, setUsernameMsg] = useState(""); + + const [editingPassword, setEditingPassword] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [passwordSaving, setPasswordSaving] = useState(false); + const [passwordMsg, setPasswordMsg] = useState(""); + + const [editingAvatar, setEditingAvatar] = useState(false); + + const [email, setEmail] = useState(""); + const [emailSaving, setEmailSaving] = useState(false); + const [emailMsg, setEmailMsg] = useState(""); + + const [showHistory, setShowHistory] = useState(true); + const [showFavorites, setShowFavorites] = useState(true); + const [toast, setToast] = useState(""); + + const showToast = useCallback((msg: string) => { + setToast(msg); + setTimeout(() => setToast(""), 2200); + }, []); + + useEffect(() => { + const cached = getCachedProfile(); + if (!cached) { + router.push("/"); + return; + } + + const id = getUserId(); + setUserId(id); + + fetch(`/api/user?id=${id}`) + .then((r) => r.json()) + .then((data) => { + if (data) { + setProfile(data); + setEmail(data.email ?? ""); + setCachedProfile({ id: data.id, username: data.username, avatar: data.avatar }); + if (data.preferences) setCachedPreferences(data.preferences); + } else { + router.push("/"); + } + }) + .catch(() => { + setProfile({ ...cached }); + }) + .finally(() => setLoading(false)); + }, [router]); + + useEffect(() => { + if (!userId) return; + + setHistoryLoading(true); + fetch(`/api/user/history?userId=${userId}`) + .then((r) => r.json()) + .then(setHistory) + .catch(() => {}) + .finally(() => setHistoryLoading(false)); + + setFavLoading(true); + fetch(`/api/user/favorite?userId=${userId}`) + .then((r) => r.json()) + .then(setFavorites) + .catch(() => {}) + .finally(() => setFavLoading(false)); + }, [userId]); + + const handleSaveUsername = async () => { + const trimmed = newUsername.trim(); + if (trimmed.length < 2 || trimmed.length > 16) { + setUsernameMsg("用户名需要 2-16 个字符"); + return; + } + + setUsernameSaving(true); + setUsernameMsg(""); + try { + const res = await fetch("/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, username: trimmed }), + }); + const data = await res.json(); + if (res.ok) { + setProfile((prev) => prev ? { ...prev, username: trimmed } : prev); + setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar }); + setEditingUsername(false); + showToast("用户名已更新"); + } else { + setUsernameMsg(data.error ?? "更新失败"); + } + } catch { + setUsernameMsg("网络错误"); + } finally { + setUsernameSaving(false); + } + }; + + const handleSavePassword = async () => { + if (!currentPassword) { + setPasswordMsg("请输入当前密码"); + return; + } + if (newPassword.length < 6) { + setPasswordMsg("新密码至少 6 个字符"); + return; + } + if (newPassword !== confirmPassword) { + setPasswordMsg("两次密码不一致"); + return; + } + + setPasswordSaving(true); + setPasswordMsg(""); + try { + const res = await fetch("/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, currentPassword, newPassword }), + }); + const data = await res.json(); + if (res.ok) { + setEditingPassword(false); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + showToast("密码已更新"); + } else { + setPasswordMsg(data.error ?? "更新失败"); + } + } catch { + setPasswordMsg("网络错误"); + } finally { + setPasswordSaving(false); + } + }; + + const handleSaveAvatar = async (emoji: string) => { + try { + const res = await fetch("/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, avatar: emoji }), + }); + if (res.ok) { + setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev); + setCachedProfile({ id: userId, username: profile!.username, avatar: emoji }); + setEditingAvatar(false); + showToast("头像已更新"); + } + } catch { + showToast("更新失败"); + } + }; + + const handleSaveEmail = async () => { + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setEmailMsg("邮箱格式不正确"); + return; + } + + setEmailSaving(true); + setEmailMsg(""); + try { + const res = await fetch("/api/user", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, email: email || null }), + }); + if (res.ok) { + setEmailMsg(email ? "邮箱已绑定" : "邮箱已解绑"); + } else { + const data = await res.json().catch(() => ({})); + setEmailMsg(data.error ?? "保存失败"); + } + } catch { + setEmailMsg("网络错误"); + } finally { + setEmailSaving(false); + } + }; + + const handleRemoveFavorite = async (favId: string) => { + try { + await fetch("/api/user/favorite", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, favoriteId: favId }), + }); + setFavorites((f) => f.filter((x) => x.id !== favId)); + showToast("已取消收藏"); + } catch { + showToast("操作失败"); + } + }; + + const handleLogout = () => { + logout(); + router.push("/"); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!profile) return null; + + const amapNavUrl = (r: Restaurant) => + r.location + ? `https://uri.amap.com/marker?position=${r.location}&name=${encodeURIComponent(r.name)}&callnative=1` + : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(r.name)}`; + + return ( +
+ + +
+ {/* Profile card */} + +
+ +
+ {editingUsername ? ( +
+ { + setNewUsername(e.target.value.slice(0, 16)); + setUsernameMsg(""); + }} + maxLength={16} + autoFocus + className="h-8 flex-1 rounded-lg border border-zinc-200 px-2 text-sm text-zinc-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> + + +
+ ) : ( +
+

{profile.username}

+ +
+ )} + {usernameMsg &&

{usernameMsg}

} +
+
+ + {/* Avatar picker */} + + {editingAvatar && ( + +
+ {AVATARS.map((a) => ( + + ))} +
+
+ )} +
+
+ + {/* Change password */} + + + + + {editingPassword && ( + +
+
+

当前密码

+
+ { setCurrentPassword(e.target.value); setPasswordMsg(""); }} + className="h-9 w-full rounded-lg border border-zinc-200 px-3 pr-9 text-sm text-zinc-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> + +
+
+
+

新密码

+ { setNewPassword(e.target.value); setPasswordMsg(""); }} + placeholder="至少 6 个字符" + className="mt-1 h-9 w-full rounded-lg border border-zinc-200 px-3 text-sm text-zinc-800 outline-none placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> +
+
+

确认新密码

+ { setConfirmPassword(e.target.value); setPasswordMsg(""); }} + placeholder="再次输入新密码" + className="mt-1 h-9 w-full rounded-lg border border-zinc-200 px-3 text-sm text-zinc-800 outline-none placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> +
+ + {passwordMsg && ( +

+ {passwordMsg} +

+ )} + + +
+
+ )} +
+
+ + {/* Email binding */} + +
+ +

绑定邮箱

+ (可选) +
+
+ { + setEmail(e.target.value); + setEmailMsg(""); + }} + className="h-9 flex-1 rounded-lg border border-zinc-200 bg-white px-3 text-sm text-zinc-700 outline-none placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> + +
+ {emailMsg && ( +

+ {emailMsg} +

+ )} +
+ + {/* Decision History */} + + + + + {showHistory && ( + + {historyLoading ? ( +
+ +
+ ) : history.length === 0 ? ( +

+ 还没有决策记录 +

+ ) : ( + + )} +
+ )} +
+
+ + {/* Favorites */} + + + + + {showFavorites && ( + + {favLoading ? ( +
+ +
+ ) : favorites.length === 0 ? ( +

+ 还没有收藏的餐厅 +

+ ) : ( +
+ {favorites.map((f) => { + const r = f.restaurantData; + return ( +
+ {r.image && ( + {r.name} + )} +
+

{r.name}

+
+ + + {r.rating} + + {r.price} + {r.distance && ( + + + {r.distance} + + )} +
+
+ +
+ ); + })} +
+ )} +
+ )} +
+
+ + {/* Logout */} + + + +
+ + + {toast && ( + + {toast} + + )} + +
+ ); +} diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx index c6a141f..ebab5b3 100644 --- a/src/app/room/[id]/page.tsx +++ b/src/app/room/[id]/page.tsx @@ -21,7 +21,7 @@ export default function RoomPage() { const { userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, - restaurants, notFound, mutate, creatorId, locked, users, + restaurants, notFound, mutate, creatorId, locked, users, userProfiles, } = useRoomPolling(roomId); useEffect(() => { @@ -128,6 +128,7 @@ export default function RoomPage() { locked={locked} swipeCounts={swipeCounts} totalCards={restaurants.length} + userProfiles={userProfiles} /> diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx new file mode 100644 index 0000000..1a233ba --- /dev/null +++ b/src/components/AuthModal.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useState, useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { X, Loader2, Eye, EyeOff } from "lucide-react"; +import { AVATARS } from "@/lib/avatars"; +import { setCachedProfile } from "@/lib/userId"; +import type { UserProfile } from "@/types"; + +type Tab = "login" | "register"; + +interface AuthModalProps { + open: boolean; + onClose: () => void; + onAuth: (profile: UserProfile) => void; +} + +export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) { + const backdropRef = useRef(null); + const [tab, setTab] = useState("login"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [avatar, setAvatar] = useState(AVATARS[0].emoji); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === backdropRef.current) onClose(); + }; + + const resetForm = () => { + setUsername(""); + setPassword(""); + setConfirmPassword(""); + setAvatar(AVATARS[0].emoji); + setShowPassword(false); + setError(""); + }; + + const switchTab = (t: Tab) => { + setTab(t); + resetForm(); + }; + + const handleSubmit = async () => { + const trimmedUsername = username.trim(); + if (!trimmedUsername) { + setError("请输入用户名"); + return; + } + + if (!password) { + setError("请输入密码"); + return; + } + + if (tab === "register") { + if (trimmedUsername.length < 2 || trimmedUsername.length > 16) { + setError("用户名需要 2-16 个字符"); + return; + } + if (password.length < 6) { + setError("密码至少 6 个字符"); + return; + } + if (password !== confirmPassword) { + setError("两次密码不一致"); + return; + } + } + + setLoading(true); + setError(""); + + try { + const endpoint = tab === "login" ? "/api/auth/login" : "/api/auth/register"; + const payload = + tab === "login" + ? { username: trimmedUsername, password } + : { username: trimmedUsername, password, avatar }; + + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? "操作失败"); + return; + } + + const profile: UserProfile = { + id: data.id, + username: data.username, + avatar: data.avatar, + }; + + localStorage.setItem("nowhatever_user_id", profile.id); + setCachedProfile(profile); + onAuth(profile); + onClose(); + resetForm(); + } catch { + setError("网络错误,请重试"); + } finally { + setLoading(false); + } + }; + + return ( + + {open && ( + + +
+ 欢迎 + +
+ + {/* Tabs */} +
+ {(["login", "register"] as const).map((t) => ( + + ))} +
+ + {/* Username */} +
+

用户名

+ { + setUsername(e.target.value.slice(0, 16)); + setError(""); + }} + placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"} + maxLength={16} + className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> +
+ + {/* Password */} +
+

密码

+
+ { + setPassword(e.target.value); + setError(""); + }} + placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"} + className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 pr-10 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> + +
+
+ + {/* Confirm password (register only) */} + {tab === "register" && ( +
+

确认密码

+ { + setConfirmPassword(e.target.value); + setError(""); + }} + placeholder="再次输入密码" + className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100" + /> +
+ )} + + {/* Avatar picker (register only) */} + {tab === "register" && ( +
+

+ 选择头像 + (可选) +

+
+ {AVATARS.map((a) => ( + + ))} +
+
+ )} + + {error && ( + + {error} + + )} + + +
+
+ )} +
+ ); +} diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index 3c837e1..5c6882f 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -22,6 +22,7 @@ import { } from "lucide-react"; import { Restaurant, MatchType, RunnerUp } from "@/types"; import { fireCelebration, playChime } from "@/lib/celebrate"; +import { isRegistered } from "@/lib/userId"; interface MatchResultProps { restaurant: Restaurant; @@ -30,6 +31,8 @@ interface MatchResultProps { runnerUps: RunnerUp[]; allRestaurants: Restaurant[]; userCount: number; + roomId: string; + userId: string; onReset: () => Promise; onNarrow: (restaurantIds: string[]) => Promise; resetting: boolean; @@ -168,6 +171,8 @@ export default function MatchResult({ runnerUps, allRestaurants, userCount, + roomId, + userId, onReset, onNarrow, resetting, @@ -176,6 +181,7 @@ export default function MatchResult({ const [showRunnerUps, setShowRunnerUps] = useState(false); const [toast, setToast] = useState(""); const celebratedRef = useRef(false); + const historySavedRef = useRef(false); const isUnanimous = matchType === "unanimous"; const showToast = useCallback((msg: string) => { @@ -194,6 +200,25 @@ export default function MatchResult({ } }, [isUnanimous]); + useEffect(() => { + if (historySavedRef.current) return; + if (!isRegistered()) return; + if (matchType === "no_match") return; + + historySavedRef.current = true; + fetch("/api/user/history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId, + roomId, + restaurant, + matchType, + participants: userCount, + }), + }).catch(() => {}); + }, [userId, roomId, restaurant, matchType, userCount]); + const handleShare = useCallback(async () => { const lines = [ isUnanimous diff --git a/src/components/RestaurantCard.tsx b/src/components/RestaurantCard.tsx index cab45bf..e65558c 100644 --- a/src/components/RestaurantCard.tsx +++ b/src/components/RestaurantCard.tsx @@ -1,8 +1,9 @@ "use client"; -import { useCallback } from "react"; -import { Star, MapPin, Clock, ExternalLink, Flame } from "lucide-react"; +import { useCallback, useState } from "react"; +import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark } from "lucide-react"; import { Restaurant } from "@/types"; +import { getUserId, isRegistered } from "@/lib/userId"; interface RestaurantCardProps { restaurant: Restaurant; @@ -14,6 +15,22 @@ function stopAll(e: React.SyntheticEvent) { } export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) { + const [favorited, setFavorited] = useState(false); + + const handleFavorite = useCallback(async (e: React.MouseEvent | React.TouchEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (favorited) return; + + try { + const res = await fetch("/api/user/favorite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: getUserId(), restaurant }), + }); + if (res.ok) setFavorited(true); + } catch {} + }, [restaurant, favorited]); const openLink = useCallback( (url: string) => (e: React.MouseEvent | React.TouchEvent) => { e.stopPropagation(); @@ -59,6 +76,20 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant {restaurant.name}
+ {isRegistered() && ( + + )}