feat: 用户名密码登录注册系统
- 新增 /api/auth/register 和 /api/auth/login 接口,使用 bcryptjs 哈希密码 - User 模型改为 username + passwordHash,id 自动生成 cuid - 新增 AuthModal 组件(登录/注册双标签页),替换旧的 ProfileSetupModal - 重写 /profile 页面:支持修改用户名、密码、头像、绑定邮箱、退出登录 - /api/user PUT 支持密码修改(需验证当前密码)和用户名唯一性校验 - 游客模式保留,右上角显示"登录"按钮;登录后显示头像和用户名 - 全局 nickname -> username 重命名(types、SwipeDeck、RoomManageModal、buildRoomStatus) - 新增 logout() 清除登录态并重新生成游客 UUID
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
+46
-3
@@ -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<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(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 (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-12">
|
||||
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-12">
|
||||
{/* Profile / Auth button */}
|
||||
<div className="absolute right-4 top-4">
|
||||
{profile ? (
|
||||
<button
|
||||
onClick={() => router.push("/profile")}
|
||||
className={`flex h-9 items-center gap-1.5 rounded-full px-3 text-sm font-medium transition-colors active:opacity-80 ${getAvatarBg(profile.avatar)}`}
|
||||
>
|
||||
<span className="text-base leading-none">{profile.avatar}</span>
|
||||
<span className="max-w-[5rem] truncate text-xs font-semibold text-zinc-700">{profile.username}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAuthModalOpen(true)}
|
||||
className="flex h-9 items-center gap-1.5 rounded-full bg-zinc-100 px-3 text-xs font-medium text-zinc-500 transition-colors active:bg-zinc-200"
|
||||
>
|
||||
<User size={14} />
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="flex flex-col items-center"
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
@@ -466,6 +503,12 @@ export default function LandingPage() {
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<AuthModal
|
||||
open={authModalOpen}
|
||||
onClose={() => setAuthModalOpen(false)}
|
||||
onAuth={(p) => setProfile(p)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<DecisionRecord[]>([]);
|
||||
const [favorites, setFavorites] = useState<FavoriteRecord[]>([]);
|
||||
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 (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background">
|
||||
<Loader2 size={24} className="animate-spin text-zinc-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-dvh bg-background pb-16">
|
||||
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-zinc-500 transition-colors active:bg-zinc-100"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<h1 className="text-base font-bold text-zinc-900">个人中心</h1>
|
||||
</nav>
|
||||
|
||||
<div className="mx-auto max-w-sm px-5">
|
||||
{/* Profile card */}
|
||||
<motion.div
|
||||
className="rounded-2xl bg-white p-4 shadow-sm"
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setEditingAvatar(!editingAvatar)}
|
||||
className={`relative flex h-14 w-14 items-center justify-center rounded-2xl text-2xl transition-transform active:scale-95 ${getAvatarBg(profile.avatar)}`}
|
||||
>
|
||||
{profile.avatar}
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-white text-zinc-400 shadow-sm">
|
||||
<Edit3 size={10} />
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
{editingUsername ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveUsername}
|
||||
disabled={usernameSaving}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500 text-white disabled:opacity-50"
|
||||
>
|
||||
{usernameSaving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingUsername(false); setUsernameMsg(""); }}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-100 text-zinc-500"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold text-zinc-900">{profile.username}</h2>
|
||||
<button
|
||||
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
|
||||
className="text-zinc-400 transition-colors active:text-zinc-600"
|
||||
>
|
||||
<Edit3 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{usernameMsg && <p className="mt-1 text-xs text-rose-500">{usernameMsg}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar picker */}
|
||||
<AnimatePresence>
|
||||
{editingAvatar && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 grid grid-cols-6 gap-2">
|
||||
{AVATARS.map((a) => (
|
||||
<button
|
||||
key={a.emoji}
|
||||
onClick={() => handleSaveAvatar(a.emoji)}
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
||||
profile.avatar === a.emoji
|
||||
? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
|
||||
: "bg-zinc-50 hover:bg-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{a.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Change password */}
|
||||
<motion.div
|
||||
className="mt-4 rounded-2xl bg-white p-4 shadow-sm"
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
|
||||
className="flex w-full items-center gap-2"
|
||||
>
|
||||
<Lock size={15} className="text-zinc-400" />
|
||||
<h3 className="text-sm font-semibold text-zinc-700">修改密码</h3>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{editingPassword && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">当前密码</p>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400"
|
||||
>
|
||||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">新密码</p>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-400">确认新密码</p>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{passwordMsg && (
|
||||
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-500" : "text-emerald-500"}`}>
|
||||
{passwordMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSavePassword}
|
||||
disabled={passwordSaving}
|
||||
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-emerald-500 text-xs font-semibold text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{passwordSaving ? <Loader2 size={14} className="animate-spin" /> : "保存新密码"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Email binding */}
|
||||
<motion.div
|
||||
className="mt-4 rounded-2xl bg-white p-4 shadow-sm"
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail size={15} className="text-zinc-400" />
|
||||
<h3 className="text-sm font-semibold text-zinc-700">绑定邮箱</h3>
|
||||
<span className="text-[10px] text-zinc-400">(可选)</span>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveEmail}
|
||||
disabled={emailSaving}
|
||||
className="flex h-9 items-center gap-1 rounded-lg bg-emerald-500 px-3 text-xs font-semibold text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{emailSaving ? <Loader2 size={13} className="animate-spin" /> : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
{emailMsg && (
|
||||
<p className={`mt-2 text-xs ${emailMsg.includes("失败") || emailMsg.includes("不正确") ? "text-rose-500" : "text-emerald-500"}`}>
|
||||
{emailMsg}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Decision History */}
|
||||
<motion.div
|
||||
className="mt-4 rounded-2xl bg-white p-4 shadow-sm"
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowHistory((v) => !v)}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={15} className="text-zinc-400" />
|
||||
<h3 className="text-sm font-semibold text-zinc-700">
|
||||
决策记录 {history.length > 0 && `(${history.length})`}
|
||||
</h3>
|
||||
</div>
|
||||
<motion.span
|
||||
animate={{ rotate: showHistory ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-zinc-400"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</motion.span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showHistory && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{historyLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 size={18} className="animate-spin text-zinc-300" />
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-zinc-400">
|
||||
还没有决策记录
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{history.map((d) => (
|
||||
<a
|
||||
key={d.id}
|
||||
href={amapNavUrl(d.restaurantData)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex gap-3 rounded-xl bg-zinc-50 p-2.5 transition-colors active:bg-zinc-100"
|
||||
>
|
||||
{d.restaurantData.image && (
|
||||
<img
|
||||
src={d.restaurantData.image}
|
||||
alt={d.restaurantName}
|
||||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<p className="truncate text-sm font-semibold text-zinc-800">{d.restaurantName}</p>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-zinc-400">
|
||||
<span>{d.matchType === "unanimous" ? "全员一致" : "最佳匹配"}</span>
|
||||
<span>{d.participants} 人参与</span>
|
||||
<span>{new Date(d.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Favorites */}
|
||||
<motion.div
|
||||
className="mt-4 rounded-2xl bg-white p-4 shadow-sm"
|
||||
initial={{ y: 10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowFavorites((v) => !v)}
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star size={15} className="text-zinc-400" />
|
||||
<h3 className="text-sm font-semibold text-zinc-700">
|
||||
收藏餐厅 {favorites.length > 0 && `(${favorites.length})`}
|
||||
</h3>
|
||||
</div>
|
||||
<motion.span
|
||||
animate={{ rotate: showFavorites ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="text-zinc-400"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</motion.span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showFavorites && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{favLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 size={18} className="animate-spin text-zinc-300" />
|
||||
</div>
|
||||
) : favorites.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-zinc-400">
|
||||
还没有收藏的餐厅
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{favorites.map((f) => {
|
||||
const r = f.restaurantData;
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex gap-3 rounded-xl bg-zinc-50 p-2.5"
|
||||
>
|
||||
{r.image && (
|
||||
<img
|
||||
src={r.image}
|
||||
alt={r.name}
|
||||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
<p className="truncate text-sm font-semibold text-zinc-800">{r.name}</p>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-zinc-400">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star size={10} className="fill-amber-400 text-amber-400" />
|
||||
{r.rating}
|
||||
</span>
|
||||
<span>{r.price}</span>
|
||||
{r.distance && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MapPin size={10} />
|
||||
{r.distance}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveFavorite(f.id)}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center self-center rounded-full text-zinc-400 transition-colors active:bg-zinc-200 active:text-rose-500"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Logout */}
|
||||
<motion.div
|
||||
className="mt-6 flex justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-zinc-400 transition-colors hover:text-rose-500"
|
||||
>
|
||||
<LogOut size={13} />
|
||||
退出登录
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
className="fixed left-1/2 top-10 z-60 -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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
<SwipeDeck
|
||||
restaurants={restaurants}
|
||||
@@ -141,6 +142,7 @@ export default function RoomPage() {
|
||||
likeCounts={likeCounts}
|
||||
swipeCounts={swipeCounts}
|
||||
userCount={userCount}
|
||||
userProfiles={userProfiles}
|
||||
onReset={handleReset}
|
||||
onNarrow={handleNarrow}
|
||||
/>
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [tab, setTab] = useState<Tab>("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [avatar, setAvatar] = useState<string>(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 (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={backdropRef}
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<motion.div
|
||||
className="relative w-full max-w-sm rounded-t-3xl bg-white px-5 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", damping: 28, stiffness: 350 }}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-zinc-900">欢迎</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 rounded-xl bg-zinc-100 p-1">
|
||||
{(["login", "register"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => switchTab(t)}
|
||||
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
|
||||
tab === t ? "text-zinc-900" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{tab === t && (
|
||||
<motion.div
|
||||
layoutId="auth-tab"
|
||||
className="absolute inset-0 rounded-lg bg-white shadow-sm"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">
|
||||
{t === "login" ? "登录" : "注册"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="mt-5">
|
||||
<p className="text-xs font-medium text-zinc-500">用户名</p>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-zinc-500">密码</p>
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 transition-colors active:text-zinc-600"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm password (register only) */}
|
||||
{tab === "register" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-zinc-500">确认密码</p>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar picker (register only) */}
|
||||
{tab === "register" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-zinc-500">
|
||||
选择头像
|
||||
<span className="ml-1 text-zinc-300">(可选)</span>
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-6 gap-2">
|
||||
{AVATARS.map((a) => (
|
||||
<button
|
||||
key={a.emoji}
|
||||
onClick={() => setAvatar(a.emoji)}
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
||||
avatar === a.emoji
|
||||
? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
|
||||
: "bg-zinc-50 hover:bg-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{a.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="mt-3 text-center text-xs font-medium text-rose-500"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="mt-5 flex h-11 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 ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{tab === "login" ? "登录中..." : "注册中..."}
|
||||
</>
|
||||
) : tab === "login" ? (
|
||||
"登录"
|
||||
) : (
|
||||
"注册"
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
onNarrow: (restaurantIds: string[]) => Promise<void>;
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
</h2>
|
||||
<div className="mt-0.5 flex shrink-0 gap-1.5">
|
||||
{isRegistered() && (
|
||||
<button
|
||||
onClick={handleFavorite}
|
||||
onPointerDown={stopAll}
|
||||
onTouchStart={stopAll}
|
||||
className={`flex items-center justify-center rounded-full p-1 transition-colors ${
|
||||
favorited
|
||||
? "bg-amber-100 text-amber-500"
|
||||
: "bg-zinc-50 text-zinc-400 active:bg-amber-50 active:text-amber-500"
|
||||
}`}
|
||||
>
|
||||
<Bookmark size={13} className={favorited ? "fill-amber-400" : ""} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={openLink(amapUrl)}
|
||||
onPointerDown={stopAll}
|
||||
|
||||
@@ -11,29 +11,8 @@ import {
|
||||
Crown,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
const AVATARS = [
|
||||
{ emoji: "🐱", bg: "bg-amber-100" },
|
||||
{ emoji: "🐶", bg: "bg-orange-100" },
|
||||
{ emoji: "🦊", bg: "bg-red-100" },
|
||||
{ emoji: "🐰", bg: "bg-pink-100" },
|
||||
{ emoji: "🐼", bg: "bg-zinc-100" },
|
||||
{ emoji: "🐨", bg: "bg-sky-100" },
|
||||
{ emoji: "🦁", bg: "bg-yellow-100" },
|
||||
{ emoji: "🐸", bg: "bg-lime-100" },
|
||||
{ emoji: "🐵", bg: "bg-stone-100" },
|
||||
{ emoji: "🐷", bg: "bg-rose-100" },
|
||||
{ emoji: "🐙", bg: "bg-purple-100" },
|
||||
{ emoji: "🦄", bg: "bg-violet-100" },
|
||||
] as const;
|
||||
|
||||
function getAvatar(uid: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < uid.length; i++) {
|
||||
hash = (hash * 31 + uid.charCodeAt(i)) | 0;
|
||||
}
|
||||
return AVATARS[((hash % AVATARS.length) + AVATARS.length) % AVATARS.length];
|
||||
}
|
||||
import { UserProfile } from "@/types";
|
||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
||||
|
||||
interface RoomManageModalProps {
|
||||
open: boolean;
|
||||
@@ -44,6 +23,7 @@ interface RoomManageModalProps {
|
||||
locked: boolean;
|
||||
swipeCounts: Record<string, number>;
|
||||
totalCards: number;
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
onToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
@@ -56,6 +36,7 @@ export default function RoomManageModal({
|
||||
locked,
|
||||
swipeCounts,
|
||||
totalCards,
|
||||
userProfiles,
|
||||
onToast,
|
||||
}: RoomManageModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
@@ -172,7 +153,10 @@ export default function RoomManageModal({
|
||||
</h3>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{users.map((uid) => {
|
||||
const avatar = getAvatar(uid);
|
||||
const profile = userProfiles[uid];
|
||||
const emoji = profile?.avatar ?? getAvatar(uid).emoji;
|
||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(uid).bg;
|
||||
const displayName = profile?.username ?? uid.slice(0, 8);
|
||||
const isCreator = uid === userId;
|
||||
const swiped = swipeCounts[uid] ?? 0;
|
||||
const finished = swiped >= totalCards;
|
||||
@@ -183,9 +167,9 @@ export default function RoomManageModal({
|
||||
className="flex items-center gap-2.5 rounded-xl bg-zinc-50 px-3 py-2.5"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${avatar.bg}`}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${bg}`}
|
||||
>
|
||||
{avatar.emoji}
|
||||
{emoji}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -196,7 +180,7 @@ export default function RoomManageModal({
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-zinc-500">
|
||||
{uid.slice(0, 8)}
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
|
||||
@@ -6,65 +6,53 @@ import SwipeableCard from "./SwipeableCard";
|
||||
import ActionButtons from "./ActionButtons";
|
||||
import MatchResult from "./MatchResult";
|
||||
import SwipeGuide from "./SwipeGuide";
|
||||
import { Restaurant, SwipeDirection, MatchType, RunnerUp } from "@/types";
|
||||
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile } from "@/types";
|
||||
import { Heart, Undo2, Check } from "lucide-react";
|
||||
|
||||
const AVATARS = [
|
||||
{ emoji: "🐱", bg: "bg-amber-100" },
|
||||
{ emoji: "🐶", bg: "bg-orange-100" },
|
||||
{ emoji: "🦊", bg: "bg-red-100" },
|
||||
{ emoji: "🐰", bg: "bg-pink-100" },
|
||||
{ emoji: "🐼", bg: "bg-zinc-100" },
|
||||
{ emoji: "🐨", bg: "bg-sky-100" },
|
||||
{ emoji: "🦁", bg: "bg-yellow-100" },
|
||||
{ emoji: "🐸", bg: "bg-lime-100" },
|
||||
{ emoji: "🐵", bg: "bg-stone-100" },
|
||||
{ emoji: "🐷", bg: "bg-rose-100" },
|
||||
{ emoji: "🐙", bg: "bg-purple-100" },
|
||||
{ emoji: "🦄", bg: "bg-violet-100" },
|
||||
] as const;
|
||||
|
||||
function getAvatar(uid: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < uid.length; i++) {
|
||||
hash = (hash * 31 + uid.charCodeAt(i)) | 0;
|
||||
}
|
||||
return AVATARS[((hash % AVATARS.length) + AVATARS.length) % AVATARS.length];
|
||||
}
|
||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
||||
|
||||
function UserProgressBar({
|
||||
userId,
|
||||
swipeCounts,
|
||||
localIndex,
|
||||
total,
|
||||
userProfiles,
|
||||
}: {
|
||||
userId: string;
|
||||
swipeCounts: Record<string, number>;
|
||||
localIndex: number;
|
||||
total: number;
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
}) {
|
||||
const others = Object.entries(swipeCounts).filter(([id]) => id !== userId);
|
||||
if (others.length === 0) return null;
|
||||
|
||||
const myProfile = userProfiles[userId];
|
||||
const myAvatar = myProfile?.avatar ?? getAvatar(userId).emoji;
|
||||
const myAvatarBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="flex items-center gap-1 text-[11px] tabular-nums text-emerald-500">
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-emerald-100 text-[10px] leading-none">
|
||||
{getAvatar(userId).emoji}
|
||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${myAvatarBg} text-[10px] leading-none`}>
|
||||
{myAvatar}
|
||||
</span>
|
||||
你 {localIndex}/{total}
|
||||
</span>
|
||||
{others.map(([id, count]) => {
|
||||
const finished = count >= total;
|
||||
const avatar = getAvatar(id);
|
||||
const profile = userProfiles[id];
|
||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
||||
const label = profile?.username ?? "";
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className={`flex items-center gap-1 text-[11px] tabular-nums ${finished ? "text-emerald-400" : "text-zinc-400"}`}
|
||||
>
|
||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${avatar.bg} text-[10px] leading-none`}>
|
||||
{avatar.emoji}
|
||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${bg} text-[10px] leading-none`}>
|
||||
{emoji}
|
||||
</span>
|
||||
{label && <span className="max-w-[3rem] truncate">{label}</span>}
|
||||
{count}/{total}
|
||||
{finished && <Check size={10} className="text-emerald-400" />}
|
||||
</span>
|
||||
@@ -78,10 +66,12 @@ function WaitingProgress({
|
||||
userId,
|
||||
swipeCounts,
|
||||
total,
|
||||
userProfiles,
|
||||
}: {
|
||||
userId: string;
|
||||
swipeCounts: Record<string, number>;
|
||||
total: number;
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
}) {
|
||||
const entries = Object.entries(swipeCounts);
|
||||
if (entries.length <= 1) return null;
|
||||
@@ -89,12 +79,16 @@ function WaitingProgress({
|
||||
const others = entries.filter(([id]) => id !== userId);
|
||||
const finishedCount = others.filter(([, c]) => c >= total).length;
|
||||
|
||||
const myProfile = userProfiles[userId];
|
||||
const myEmoji = myProfile?.avatar ?? getAvatar(userId).emoji;
|
||||
const myBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
|
||||
|
||||
return (
|
||||
<div className="flex w-60 flex-col gap-2.5 rounded-2xl bg-zinc-50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-sm leading-none">
|
||||
{getAvatar(userId).emoji}
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${myBg} text-sm leading-none`}>
|
||||
{myEmoji}
|
||||
</span>
|
||||
你 {total}/{total}
|
||||
</span>
|
||||
@@ -104,14 +98,18 @@ function WaitingProgress({
|
||||
{others.map(([id, count]) => {
|
||||
const finished = count >= total;
|
||||
const pct = Math.min((count / total) * 100, 100);
|
||||
const avatar = getAvatar(id);
|
||||
const profile = userProfiles[id];
|
||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
||||
const label = profile?.username ?? "";
|
||||
return (
|
||||
<div key={id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`flex items-center gap-1.5 text-xs font-medium ${finished ? "text-emerald-600" : "text-zinc-500"}`}>
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-100" : avatar.bg}`}>
|
||||
{avatar.emoji}
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-100" : bg}`}>
|
||||
{emoji}
|
||||
</span>
|
||||
{label && <span className="max-w-[3rem] truncate">{label}</span>}
|
||||
{count}/{total}
|
||||
</span>
|
||||
{finished && <Check size={14} className="text-emerald-400" />}
|
||||
@@ -148,6 +146,7 @@ interface SwipeDeckProps {
|
||||
likeCounts: Record<string, number>;
|
||||
swipeCounts: Record<string, number>;
|
||||
userCount: number;
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
onReset: () => Promise<void>;
|
||||
onNarrow: (restaurantIds: string[]) => Promise<void>;
|
||||
}
|
||||
@@ -164,6 +163,7 @@ export default function SwipeDeck({
|
||||
likeCounts,
|
||||
swipeCounts,
|
||||
userCount,
|
||||
userProfiles,
|
||||
onReset,
|
||||
onNarrow,
|
||||
}: SwipeDeckProps) {
|
||||
@@ -351,6 +351,7 @@ export default function SwipeDeck({
|
||||
swipeCounts={swipeCounts}
|
||||
localIndex={currentIndex}
|
||||
total={restaurants.length}
|
||||
userProfiles={userProfiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -388,6 +389,7 @@ export default function SwipeDeck({
|
||||
userId={userId}
|
||||
swipeCounts={swipeCounts}
|
||||
total={restaurants.length}
|
||||
userProfiles={userProfiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -419,6 +421,8 @@ export default function SwipeDeck({
|
||||
runnerUps={runnerUps}
|
||||
allRestaurants={restaurants}
|
||||
userCount={userCount}
|
||||
roomId={roomId}
|
||||
userId={userId}
|
||||
onReset={handleReset}
|
||||
onNarrow={handleNarrow}
|
||||
resetting={resetting}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Users, QrCode, LogOut, Crown, Lock } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import QrInviteModal from "./QrInviteModal";
|
||||
import RoomManageModal from "./RoomManageModal";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
interface TopNavProps {
|
||||
roomId: string;
|
||||
@@ -16,6 +17,7 @@ interface TopNavProps {
|
||||
locked?: boolean;
|
||||
swipeCounts?: Record<string, number>;
|
||||
totalCards?: number;
|
||||
userProfiles?: Record<string, UserProfile>;
|
||||
}
|
||||
|
||||
export default function TopNav({
|
||||
@@ -28,6 +30,7 @@ export default function TopNav({
|
||||
locked = false,
|
||||
swipeCounts = {},
|
||||
totalCards = 0,
|
||||
userProfiles = {},
|
||||
}: TopNavProps) {
|
||||
const [toast, setToast] = useState("");
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
@@ -119,6 +122,7 @@ export default function TopNav({
|
||||
locked={locked}
|
||||
swipeCounts={swipeCounts}
|
||||
totalCards={totalCards}
|
||||
userProfiles={userProfiles}
|
||||
onToast={showToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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: "",
|
||||
},
|
||||
];
|
||||
@@ -74,6 +74,7 @@ export function useRoomPolling(roomId: string) {
|
||||
creatorId: data?.creatorId ?? "",
|
||||
locked: data?.locked ?? false,
|
||||
users: data?.users ?? [],
|
||||
userProfiles: data?.userProfiles ?? {},
|
||||
notFound,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const AVATARS = [
|
||||
{ emoji: "🐱", bg: "bg-amber-100" },
|
||||
{ emoji: "🐶", bg: "bg-orange-100" },
|
||||
{ emoji: "🦊", bg: "bg-red-100" },
|
||||
{ emoji: "🐰", bg: "bg-pink-100" },
|
||||
{ emoji: "🐼", bg: "bg-zinc-100" },
|
||||
{ emoji: "🐨", bg: "bg-sky-100" },
|
||||
{ emoji: "🦁", bg: "bg-yellow-100" },
|
||||
{ emoji: "🐸", bg: "bg-lime-100" },
|
||||
{ emoji: "🐵", bg: "bg-stone-100" },
|
||||
{ emoji: "🐷", bg: "bg-rose-100" },
|
||||
{ emoji: "🐙", bg: "bg-purple-100" },
|
||||
{ emoji: "🦄", bg: "bg-violet-100" },
|
||||
] as const;
|
||||
|
||||
export function getAvatar(uid: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < uid.length; i++) {
|
||||
hash = (hash * 31 + uid.charCodeAt(i)) | 0;
|
||||
}
|
||||
return AVATARS[((hash % AVATARS.length) + AVATARS.length) % AVATARS.length];
|
||||
}
|
||||
|
||||
export function getAvatarBg(emoji: string): string {
|
||||
const found = AVATARS.find((a) => a.emoji === emoji);
|
||||
return found?.bg ?? "bg-zinc-100";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getRoomData } from "./store";
|
||||
import type { RoomStatus, MatchType } from "@/types";
|
||||
import { prisma } from "./prisma";
|
||||
import type { RoomStatus, MatchType, UserProfile } from "@/types";
|
||||
|
||||
export async function buildRoomStatus(
|
||||
roomId: string,
|
||||
@@ -41,6 +42,17 @@ export async function buildRoomStatus(
|
||||
}
|
||||
}
|
||||
|
||||
const userProfiles: Record<string, UserProfile> = {};
|
||||
if (data.users.length > 0) {
|
||||
const dbUsers = await prisma.user.findMany({
|
||||
where: { id: { in: data.users } },
|
||||
select: { id: true, username: true, avatar: true },
|
||||
});
|
||||
for (const u of dbUsers) {
|
||||
userProfiles[u.id] = { id: u.id, username: u.username, avatar: u.avatar };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roomId,
|
||||
userCount: data.users.length,
|
||||
@@ -54,6 +66,7 @@ export async function buildRoomStatus(
|
||||
creatorId: data.creatorId,
|
||||
locked: data.locked,
|
||||
users: data.users,
|
||||
userProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { UserProfile, UserPreferences } from "@/types";
|
||||
|
||||
const STORAGE_KEY = "nowhatever_user_id";
|
||||
const PROFILE_KEY = "nowhatever_profile";
|
||||
|
||||
export function getUserId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
@@ -10,3 +13,51 @@ export function getUserId(): string {
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getCachedProfile(): UserProfile | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILE_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedProfile(profile: UserProfile | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (profile) {
|
||||
localStorage.setItem(PROFILE_KEY, JSON.stringify(profile));
|
||||
} else {
|
||||
localStorage.removeItem(PROFILE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function isRegistered(): boolean {
|
||||
return getCachedProfile() !== null;
|
||||
}
|
||||
|
||||
export function getCachedPreferences(): UserPreferences {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const profile = getCachedProfile();
|
||||
if (!profile) return {};
|
||||
const raw = localStorage.getItem("nowhatever_preferences");
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedPreferences(prefs: UserPreferences): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem("nowhatever_preferences", JSON.stringify(prefs));
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(PROFILE_KEY);
|
||||
localStorage.removeItem("nowhatever_preferences");
|
||||
const newId = crypto.randomUUID();
|
||||
localStorage.setItem(STORAGE_KEY, newId);
|
||||
}
|
||||
|
||||
@@ -35,4 +35,33 @@ export interface RoomStatus {
|
||||
creatorId: string;
|
||||
locked: boolean;
|
||||
users: string[];
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
cuisine?: string;
|
||||
priceRange?: string;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
export interface DecisionRecord {
|
||||
id: string;
|
||||
roomId: string;
|
||||
restaurantName: string;
|
||||
restaurantData: Restaurant;
|
||||
matchType: string;
|
||||
participants: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FavoriteRecord {
|
||||
id: string;
|
||||
restaurantData: Restaurant;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user