d4c6da57a1
将 useToast 从独立 state hook 改为 Context-based,在 layout 中 挂载 ToastProvider 全局渲染 Toast。QrInviteModal、RoomManageModal、 ShareCardModal 不再需要 onToast prop,直接 useToast() 调用即可。 父组件 TopNav、MatchResult、profile、blindbox 移除了本地 Toast 渲染和 onToast 传递逻辑。
716 lines
27 KiB
TypeScript
716 lines
27 KiB
TypeScript
"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,
|
||
Zap,
|
||
ClipboardList,
|
||
Heart,
|
||
} from "lucide-react";
|
||
import EmptyState from "@/components/EmptyState";
|
||
import { useToast } from "@/hooks/useToast";
|
||
import RestaurantImage from "@/components/RestaurantImage";
|
||
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
||
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
||
import type { UserProfile, UserPreferences, DecisionRecord, FavoriteRecord, Restaurant } from "@/types";
|
||
|
||
function firstImage(r: Restaurant): string {
|
||
if (r.images?.length > 0) return r.images[0];
|
||
const legacy = (r as unknown as Record<string, unknown>).image;
|
||
return typeof legacy === "string" ? legacy : "";
|
||
}
|
||
|
||
export default function ProfilePage() {
|
||
const router = useRouter();
|
||
const [userId, setUserId] = useState("");
|
||
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | 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 = useToast();
|
||
|
||
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);
|
||
toast.show("用户名已更新");
|
||
} 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("");
|
||
toast.show("密码已更新");
|
||
} 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);
|
||
toast.show("头像已更新");
|
||
}
|
||
} catch {
|
||
toast.show("更新失败");
|
||
}
|
||
};
|
||
|
||
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));
|
||
toast.show("已取消收藏");
|
||
} catch {
|
||
toast.show("操作失败");
|
||
}
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
logout();
|
||
router.push("/");
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
|
||
<nav className="sticky top-0 z-10 flex h-14 items-center gap-3 bg-background/80 px-4 backdrop-blur-sm">
|
||
<div className="h-8 w-8" />
|
||
<h1 className="flex-1 text-base font-bold text-heading">个人中心</h1>
|
||
</nav>
|
||
<div className="mx-auto max-w-sm px-5">
|
||
<ProfileCardSkeleton />
|
||
<div className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border">
|
||
<div className="flex flex-col gap-2">
|
||
<RecordItemSkeleton />
|
||
<RecordItemSkeleton />
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border">
|
||
<div className="flex flex-col gap-2">
|
||
<RecordItemSkeleton />
|
||
<RecordItemSkeleton />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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="h-dvh bg-background pb-16 overflow-y-auto scrollbar-none">
|
||
<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-muted transition-colors active:bg-elevated"
|
||
>
|
||
<ArrowLeft size={20} />
|
||
</button>
|
||
<h1 className="flex-1 text-base font-bold text-heading">个人中心</h1>
|
||
</nav>
|
||
|
||
<div className="mx-auto max-w-sm px-5">
|
||
{/* Profile card */}
|
||
<motion.div
|
||
className="rounded-2xl bg-surface p-4 ring-1 ring-border"
|
||
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-surface text-muted shadow-sm ring-1 ring-border">
|
||
<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-none bg-elevated px-2 text-sm text-foreground outline-none ring-1 ring-border focus:ring-2 focus:ring-accent/50"
|
||
/>
|
||
<button
|
||
onClick={handleSaveUsername}
|
||
disabled={usernameSaving}
|
||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent 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-elevated text-muted"
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-lg font-bold text-heading">{profile.username}</h2>
|
||
<button
|
||
onClick={() => { setEditingUsername(true); setNewUsername(profile.username); }}
|
||
className="text-muted transition-colors active:text-secondary"
|
||
>
|
||
<Edit3 size={13} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{usernameMsg && <p className="mt-1 text-xs text-rose-400">{usernameMsg}</p>}
|
||
{(profile.decisionCount ?? 0) > 0 && (
|
||
<p className="mt-1 flex items-center gap-1 text-xs text-muted">
|
||
<Zap size={11} className="text-amber-400" />
|
||
已拯救 {profile.decisionCount} 次选择困难症
|
||
</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-accent ring-offset-1 ring-offset-surface`
|
||
: "bg-elevated hover:bg-subtle"
|
||
}`}
|
||
>
|
||
{a.emoji}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
|
||
{/* Change password */}
|
||
<motion.div
|
||
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
|
||
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-muted" />
|
||
<h3 className="text-sm font-semibold text-secondary">修改密码</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-muted">当前密码</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-none bg-elevated px-3 pr-9 text-sm text-heading outline-none ring-1 ring-border focus:ring-2 focus:ring-accent/50"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPassword(!showPassword)}
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted"
|
||
>
|
||
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted">新密码</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-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted">确认新密码</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-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
||
/>
|
||
</div>
|
||
|
||
{passwordMsg && (
|
||
<p className={`text-xs ${passwordMsg.includes("错误") || passwordMsg.includes("失败") || passwordMsg.includes("不一致") || passwordMsg.includes("至少") ? "text-rose-400" : "text-accent"}`}>
|
||
{passwordMsg}
|
||
</p>
|
||
)}
|
||
|
||
<button
|
||
onClick={handleSavePassword}
|
||
disabled={passwordSaving}
|
||
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-accent text-xs font-semibold text-white transition-colors hover:bg-accent-hover 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-surface p-4 ring-1 ring-border"
|
||
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-muted" />
|
||
<h3 className="text-sm font-semibold text-secondary">绑定邮箱</h3>
|
||
<span className="text-[10px] text-dim">(可选)</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-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
||
/>
|
||
<button
|
||
onClick={handleSaveEmail}
|
||
disabled={emailSaving}
|
||
className="flex h-9 items-center gap-1 rounded-lg bg-accent px-3 text-xs font-semibold text-white transition-colors hover:bg-accent-hover 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-400" : "text-accent"}`}>
|
||
{emailMsg}
|
||
</p>
|
||
)}
|
||
</motion.div>
|
||
|
||
{/* Decision History */}
|
||
<motion.div
|
||
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
|
||
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-muted" />
|
||
<h3 className="text-sm font-semibold text-secondary">
|
||
决策记录 {history.length > 0 && `(${history.length})`}
|
||
</h3>
|
||
</div>
|
||
<motion.span
|
||
animate={{ rotate: showHistory ? 180 : 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
className="text-muted"
|
||
>
|
||
<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="mt-3 flex flex-col gap-2">
|
||
<RecordItemSkeleton />
|
||
<RecordItemSkeleton />
|
||
</div>
|
||
) : history.length === 0 ? (
|
||
<EmptyState
|
||
icon={ClipboardList}
|
||
title="还没有决策记录"
|
||
subtitle="创建房间开始一起选餐厅"
|
||
ctaLabel="去创建第一个房间"
|
||
onCta={() => router.push("/blindbox")}
|
||
color="purple"
|
||
/>
|
||
) : (
|
||
<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-elevated p-2.5 transition-colors active:bg-subtle"
|
||
>
|
||
{firstImage(d.restaurantData) && (
|
||
<RestaurantImage
|
||
src={firstImage(d.restaurantData)}
|
||
alt={d.restaurantName}
|
||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||
/>
|
||
)}
|
||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||
<p className="truncate text-sm font-semibold text-heading">{d.restaurantName}</p>
|
||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
|
||
<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-surface p-4 ring-1 ring-border"
|
||
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-muted" />
|
||
<h3 className="text-sm font-semibold text-secondary">
|
||
收藏餐厅 {favorites.length > 0 && `(${favorites.length})`}
|
||
</h3>
|
||
</div>
|
||
<motion.span
|
||
animate={{ rotate: showFavorites ? 180 : 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
className="text-muted"
|
||
>
|
||
<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="mt-3 flex flex-col gap-2">
|
||
<RecordItemSkeleton />
|
||
<RecordItemSkeleton />
|
||
</div>
|
||
) : favorites.length === 0 ? (
|
||
<EmptyState
|
||
icon={Heart}
|
||
title="还没有收藏的餐厅"
|
||
subtitle="在匹配结果中收藏喜欢的店"
|
||
ctaLabel="去创建第一个房间"
|
||
onCta={() => router.push("/blindbox")}
|
||
color="amber"
|
||
/>
|
||
) : (
|
||
<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-elevated p-2.5"
|
||
>
|
||
{firstImage(r) && (
|
||
<RestaurantImage
|
||
src={firstImage(r)}
|
||
alt={r.name}
|
||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||
/>
|
||
)}
|
||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||
<p className="truncate text-sm font-semibold text-heading">{r.name}</p>
|
||
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted">
|
||
<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-muted transition-colors active:bg-subtle active:text-rose-400"
|
||
>
|
||
<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 justify-center gap-2 rounded-xl bg-surface px-6 py-2.5 text-sm font-medium text-rose-400/80 ring-1 ring-border transition-colors hover:bg-elevated hover:text-rose-400"
|
||
>
|
||
<LogOut size={14} />
|
||
退出登录
|
||
</button>
|
||
</motion.div>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
}
|