Files
no-whatever/src/components/MatchResult.tsx
T
kurihada e10e3c8230 ui: 全站统一暗色主题设计系统
- globals.css 定义语义化 token (background/surface/elevated/border/muted/dim/accent)
- 所有页面和组件迁移至暗色 token,移除硬编码 bg-white/text-zinc-*/bg-gray-*
- RestaurantCard 和 MatchResult 适配暗色卡片风格
- 按钮颜色分层:系统CTA(accent)/模式强调(橙/紫)/危险(rose)/次级(surface)
- 修复 room 页深色文字在深背景不可见的可访问性问题
2026-02-26 11:27:18 +08:00

576 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useRouter } from "next/navigation";
import {
MapPin,
Star,
PartyPopper,
Navigation,
Phone,
Clock,
Trophy,
RotateCcw,
SearchX,
Home,
ChevronDown,
Swords,
RefreshCw,
Share2,
Zap,
} from "lucide-react";
import { Restaurant, MatchType, RunnerUp, SceneType } from "@/types";
import { fireCelebration, playChime } from "@/lib/celebrate";
import { isRegistered } from "@/lib/userId";
interface MatchResultProps {
restaurant: Restaurant;
matchType: MatchType;
matchLikes: number;
runnerUps: RunnerUp[];
allRestaurants: Restaurant[];
userCount: number;
roomId: string;
userId: string;
onReset: () => Promise<void>;
onNarrow: (restaurantIds: string[]) => Promise<void>;
resetting: boolean;
scene?: SceneType;
}
function buildNavUrl(restaurant: Restaurant): string {
if (restaurant.location) {
const [lng, lat] = restaurant.location.split(",");
return `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(restaurant.name)}&callnative=1`;
}
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`;
}
function NoMatchResult({
onReset,
resetting,
}: {
onReset: () => Promise<void>;
resetting: boolean;
}) {
const router = useRouter();
return (
<motion.div
className="fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto bg-background px-6 py-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
>
<SearchX size={56} className="text-muted" />
</motion.div>
<motion.h1
className="mt-4 text-3xl font-black text-white"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.35 }}
>
</motion.h1>
<motion.p
className="mt-2 max-w-[16rem] text-center text-sm leading-relaxed text-muted"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.45 }}
>
</motion.p>
<motion.div
className="mt-8 flex w-full max-w-xs flex-col gap-3"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.55 }}
>
<motion.button
onClick={onReset}
disabled={resetting}
className="flex items-center justify-center gap-2 rounded-full bg-accent px-8 py-3 text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover disabled:opacity-50"
whileTap={{ scale: 0.95 }}
>
<RotateCcw size={15} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"}
</motion.button>
<motion.button
onClick={() => router.push("/")}
className="flex items-center justify-center gap-2 rounded-full bg-surface px-8 py-3 text-sm font-bold text-muted ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.95 }}
>
<Home size={15} />
</motion.button>
</motion.div>
</motion.div>
);
}
function RunnerUpCard({
restaurant,
likes,
userCount,
}: {
restaurant: Restaurant;
likes: number;
userCount: number;
}) {
return (
<a
href={buildNavUrl(restaurant)}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-xl bg-surface/80 p-2.5 ring-1 ring-border/50 backdrop-blur-sm transition-colors hover:bg-elevated/80"
>
{restaurant.images?.[0] && (
<img
src={restaurant.images[0]}
alt={restaurant.name}
className="h-16 w-16 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-bold text-white">
{restaurant.name}
</p>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-400">
<span className="flex items-center gap-0.5">
<Star size={11} className="fill-yellow-300 text-yellow-300" />
{restaurant.rating}
</span>
<span>{restaurant.price}</span>
{restaurant.distance && (
<span className="flex items-center gap-0.5">
<MapPin size={11} />
{restaurant.distance}
</span>
)}
</div>
<p className="mt-0.5 text-[11px] font-medium text-amber-400">
{likes}/{userCount}
</p>
</div>
</a>
);
}
export default function MatchResult({
restaurant,
matchType,
matchLikes,
runnerUps,
allRestaurants,
userCount,
roomId,
userId,
onReset,
onNarrow,
resetting,
scene = "eat",
}: MatchResultProps) {
const router = useRouter();
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) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}, []);
useEffect(() => {
if (isUnanimous && !celebratedRef.current) {
const timer = setTimeout(() => {
celebratedRef.current = true;
fireCelebration();
playChime();
}, 500);
return () => clearTimeout(timer);
}
}, [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 verb = scene === "drink" ? "喝" : "吃";
const lines = [
isUnanimous
? `🎉 默契度 100%${userCount} 人全员一致选了同一家!`
: `🎉 我们用 NoWhatever 选好了去哪${verb}`,
``,
`📍 ${restaurant.name}`,
restaurant.rating ? `${restaurant.rating}` : "",
restaurant.price && restaurant.price !== "未知" ? `💰 人均${restaurant.price}` : "",
restaurant.address ? `📮 ${restaurant.address}` : "",
``,
isUnanimous ? `✨ 这就是心有灵犀吧~` : "",
].filter(Boolean);
const text = lines.join("\n");
const navUrl = buildNavUrl(restaurant);
const shareData = {
title: `我们选了${restaurant.name}`,
text,
url: navUrl,
};
try {
if (navigator.share && navigator.canShare?.(shareData)) {
await navigator.share(shareData);
return;
}
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
}
try {
await navigator.clipboard.writeText(`${text}\n\n${navUrl}`);
showToast("已复制,快去发给朋友吧!");
} catch {
showToast("复制失败,请手动复制");
}
}, [restaurant, showToast, isUnanimous, userCount, scene]);
if (matchType === "no_match") {
return <NoMatchResult onReset={onReset} resetting={resetting} />;
}
const runnerUpRestaurants = runnerUps
.map((ru) => {
const r = allRestaurants.find((rest) => rest.id === ru.id);
return r ? { restaurant: r, likes: ru.likes } : null;
})
.filter((x): x is { restaurant: Restaurant; likes: number } => x !== null);
const canNarrow = !isUnanimous && runnerUpRestaurants.length > 0;
const narrowIds = canNarrow
? [restaurant.id, ...runnerUps.map((ru) => ru.id)]
: [];
return (
<motion.div
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-background px-6 py-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
{/* Accent glow behind icon */}
<div
className={`pointer-events-none fixed left-1/2 top-0 h-72 w-72 -translate-x-1/2 -translate-y-1/3 rounded-full blur-3xl ${
isUnanimous ? "bg-emerald-500/20" : "bg-amber-500/20"
}`}
/>
<div className="relative flex w-full max-w-sm flex-1 flex-col items-center justify-center">
<motion.div
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 12, delay: 0.2 }}
>
{isUnanimous ? (
<PartyPopper size={56} className="text-emerald-400" />
) : (
<Trophy size={56} className="text-amber-400" />
)}
</motion.div>
<motion.h1
className="mt-3 text-4xl font-black text-white"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.35 }}
>
</motion.h1>
<motion.p
className={`mt-1 text-sm font-medium ${isUnanimous ? "text-emerald-400" : "text-amber-400"}`}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.45 }}
>
{isUnanimous
? "大家一拍即合!"
: `${matchLikes}/${userCount} 人想去这家`}
</motion.p>
{isUnanimous && (
<motion.div
className="mt-3 flex items-center gap-2 rounded-full bg-emerald-500/15 px-4 py-1.5 ring-1 ring-emerald-500/30"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 15,
delay: 0.55,
}}
>
<Zap size={14} className="fill-emerald-400 text-emerald-400" />
<span className="text-xs font-bold text-emerald-300">
100% · {userCount}
</span>
<Zap size={14} className="fill-emerald-400 text-emerald-400" />
</motion.div>
)}
{/* Result card */}
<motion.div
className="mt-6 w-full overflow-hidden rounded-2xl bg-surface shadow-2xl ring-1 ring-border"
initial={{ y: 60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }}
>
{restaurant.images?.[0] && (
<img
src={restaurant.images[0]}
alt={restaurant.name}
className="h-44 w-full object-cover"
referrerPolicy="no-referrer"
/>
)}
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<h2 className="text-lg font-bold leading-tight text-white">
{restaurant.name}
</h2>
{restaurant.category && (
<span className="shrink-0 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold text-emerald-400">
{restaurant.category}
</span>
)}
</div>
<div className="mt-2 flex items-center gap-3 text-sm text-muted">
<span className="flex items-center gap-1">
<Star size={13} className="fill-amber-400 text-amber-400" />
{restaurant.rating}
</span>
<span className="font-semibold text-emerald-400">
{restaurant.price}
</span>
{restaurant.distance && (
<span className="flex items-center gap-1">
<MapPin size={13} />
{restaurant.distance}
</span>
)}
</div>
{restaurant.address && (
<p className="mt-2 text-xs leading-relaxed text-muted">
{restaurant.address}
</p>
)}
{restaurant.openTime && (
<div className="mt-1.5 flex items-center gap-1 text-xs text-muted">
<Clock size={12} />
<span>{restaurant.openTime}</span>
</div>
)}
{restaurant.tag && (
<div className="mt-2 flex flex-wrap gap-1">
{restaurant.tag
.split(",")
.slice(0, 4)
.map((t) => (
<span
key={t}
className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400"
>
{t.trim()}
</span>
))}
</div>
)}
</div>
</motion.div>
{/* Action buttons */}
<motion.div
className="mt-5 flex w-full flex-col gap-2.5"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.65 }}
>
<motion.a
href={buildNavUrl(restaurant)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-full bg-accent px-8 py-3 text-sm font-bold text-white shadow-lg shadow-accent/20 transition-colors hover:bg-accent-hover"
whileTap={{ scale: 0.95 }}
>
<Navigation size={16} />
</motion.a>
{restaurant.tel && (
<motion.a
href={`tel:${restaurant.tel}`}
className="flex items-center justify-center gap-2 rounded-full bg-surface px-8 py-3 text-sm font-bold text-gray-300 ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.95 }}
>
<Phone size={15} />
</motion.a>
)}
<motion.button
onClick={handleShare}
className="flex items-center justify-center gap-2 rounded-full bg-surface px-8 py-3 text-sm font-bold text-gray-300 ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.95 }}
>
<Share2 size={15} />
</motion.button>
</motion.div>
{/* Runner ups */}
{!isUnanimous && runnerUpRestaurants.length > 0 && (
<motion.div
className="mt-5 w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.75 }}
>
<button
onClick={() => setShowRunnerUps((v) => !v)}
className="flex w-full items-center justify-center gap-1.5 py-2 text-xs font-semibold text-muted transition-colors hover:text-foreground"
>
{runnerUpRestaurants.length}
<motion.span
animate={{ rotate: showRunnerUps ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="inline-flex"
>
<ChevronDown size={14} />
</motion.span>
</button>
<AnimatePresence>
{showRunnerUps && (
<motion.div
className="flex flex-col gap-2"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
>
{runnerUpRestaurants.map(({ restaurant: r, likes }) => (
<RunnerUpCard
key={r.id}
restaurant={r}
likes={likes}
userCount={userCount}
/>
))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
{/* Bottom actions */}
<motion.div
className="mt-5 flex w-full flex-col items-center gap-2.5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
{canNarrow ? (
<>
<motion.button
onClick={() => onNarrow(narrowIds)}
disabled={resetting}
className="flex w-full items-center justify-center gap-2 rounded-full bg-elevated px-8 py-3 text-sm font-bold text-gray-300 ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50"
whileTap={{ scale: 0.95 }}
>
<Swords size={15} />
{resetting ? "加载中..." : `Top ${narrowIds.length} 决赛`}
</motion.button>
<motion.button
onClick={() => router.push("/")}
className={`flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${
isUnanimous ? "text-emerald-400" : "text-amber-400"
}`}
>
<RefreshCw size={13} />
</motion.button>
</>
) : (
<>
<motion.button
onClick={onReset}
disabled={resetting}
className="flex items-center justify-center gap-2 rounded-full bg-elevated px-8 py-3 text-sm font-bold text-gray-300 ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50"
whileTap={{ scale: 0.95 }}
>
<RotateCcw size={14} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"}
</motion.button>
<motion.button
onClick={() => router.push("/")}
className={`flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${
isUnanimous ? "text-emerald-400" : "text-amber-400"
}`}
>
<RefreshCw size={13} />
</motion.button>
</>
)}
</motion.div>
</div>
<AnimatePresence>
{toast && (
<motion.div
className="fixed left-1/2 top-10 z-60 -translate-x-1/2 rounded-xl bg-surface px-4 py-2.5 text-xs font-medium text-white shadow-lg ring-1 ring-border"
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>
</motion.div>
);
}