Files
no-whatever/src/components/MatchResult.tsx
T
kurihada 3335f7f872 refactor: 移除 Service Worker 离线缓存 + 统一注册引导文案
- 删除 sw.js、ServiceWorkerRegistrar、offline 页面
- 保留 manifest 和 PWA 图标(添加到主屏幕仍可用)
- 注册引导文案统一为"10 秒注册,无需手机号"
2026-02-27 10:38:33 +08:00

514 lines
17 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,
ChevronDown,
Swords,
RefreshCw,
Share2,
Zap,
Heart,
UserPlus,
} from "lucide-react";
import {
Restaurant,
MatchType,
RunnerUp,
SceneType,
UserProfile,
} from "@/types";
import { fireCelebration, playChime } from "@/lib/celebrate";
import { isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
import RestaurantImage from "@/components/RestaurantImage";
import AuthModal from "@/components/AuthModal";
import Button from "@/components/Button";
import NoMatchResult from "@/components/NoMatchResult";
import RunnerUpCard from "@/components/RunnerUpCard";
import { buildNavUrl } from "@/lib/navigation";
import { useToast } from "@/hooks/useToast";
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;
}
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 [showShareCard, setShowShareCard] = useState(false);
const toast = useToast();
const celebratedRef = useRef(false);
const historySavedRef = useRef(false);
const isSolo = userCount <= 1;
const isUnanimous = matchType === "unanimous";
const [favorited, setFavorited] = useState(false);
const [favLoading, setFavLoading] = useState(false);
const [registered, setRegistered] = useState(() => isRegistered());
const [showAuth, setShowAuth] = useState(false);
useEffect(() => {
if (isUnanimous && !celebratedRef.current) {
const timer = setTimeout(() => {
celebratedRef.current = true;
fireCelebration();
playChime();
}, 500);
return () => clearTimeout(timer);
}
}, [isUnanimous]);
useEffect(() => {
if (historySavedRef.current) return;
if (!registered) 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(() => {});
}, [registered, userId, roomId, restaurant, matchType, userCount]);
const handleOpenShareCard = useCallback(() => {
setShowShareCard(true);
}, []);
const handleAuth = useCallback(
(profile: UserProfile) => {
setRegistered(true);
setShowAuth(false);
toast.show(`欢迎,${profile.username}!记录已保存`);
},
[toast],
);
const handleFavorite = useCallback(async () => {
if (!registered || favorited || favLoading) return;
setFavLoading(true);
try {
const res = await fetch("/api/user/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, restaurant }),
});
if (res.ok) {
setFavorited(true);
toast.show("已收藏");
}
} catch {
/* ignore */
}
setFavLoading(false);
}, [registered, userId, restaurant, favorited, favLoading, toast]);
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 pb-24 pt-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-center text-4xl font-black text-heading"
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.35 }}
>
{isSolo ? "帮你选好了" : "就去这了"}
</motion.h1>
<motion.p
className={`mt-1 text-center 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 }}
>
{isSolo
? "你的首选,别犹豫了"
: isUnanimous
? "大家一拍即合!"
: `${matchLikes}/${userCount} 人想去这家`}
</motion.p>
{isUnanimous && !isSolo && (
<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="relative 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,
}}
>
{registered && (
<motion.button
onClick={handleFavorite}
disabled={favLoading}
className="absolute right-3 top-3 z-10 rounded-full bg-black/40 p-2 backdrop-blur-sm transition-colors hover:bg-black/60 disabled:opacity-50"
whileTap={{ scale: 0.85 }}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
delay: 0.7,
type: "spring",
stiffness: 300,
damping: 15,
}}
>
<Heart
size={18}
className={
favorited ? "fill-red-500 text-red-500" : "text-white"
}
/>
</motion.button>
)}
{restaurant.images?.[0] && (
<RestaurantImage
src={restaurant.images[0]}
alt={restaurant.name}
className="h-44 w-full object-cover"
/>
)}
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<h2 className="text-lg font-bold leading-tight text-heading">
{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-secondary ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.95 }}
>
<Phone size={15} />
</motion.a>
)}
<Button
onClick={handleOpenShareCard}
variant="secondary"
shape="pill"
icon={<Share2 size={15} />}
className="px-8 py-3"
>
</Button>
</motion.div>
{/* Registration nudge */}
{!registered && (
<motion.div
className="mt-5 w-full rounded-2xl bg-surface/80 p-4 ring-1 ring-border/50 backdrop-blur-sm"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.75 }}
>
<p className="text-sm font-medium text-secondary">
</p>
<p className="mt-1 text-xs text-muted">
10
</p>
<Button
onClick={() => setShowAuth(true)}
fullWidth
icon={<UserPlus size={15} />}
className="mt-3"
>
</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>
{canNarrow && (
<div className="mt-4 flex flex-col items-center gap-2">
<p className="text-xs text-muted">
{runnerUpRestaurants.length}
</p>
<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-secondary 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>
</div>
)}
</motion.div>
)}
</div>
{/* Floating bottom bar */}
<motion.div
className="fixed inset-x-0 bottom-0 z-10 border-t border-border/50 bg-background/80 backdrop-blur-xl"
initial={{ y: 60, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.9, type: "spring", stiffness: 200, damping: 20 }}
>
<div className="mx-auto flex max-w-sm items-center gap-3 px-6 pb-6 pt-3">
<span className="shrink-0 text-xs text-muted"></span>
<motion.button
onClick={onReset}
disabled={resetting}
className="flex flex-1 items-center justify-center gap-1.5 rounded-full bg-elevated py-2.5 text-xs font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle disabled:opacity-50"
whileTap={{ scale: 0.95 }}
>
<RotateCcw size={12} className={resetting ? "animate-spin" : ""} />
{resetting ? "重置中..." : "再来一轮"}
</motion.button>
<motion.button
onClick={() => router.push("/")}
className="flex flex-1 items-center justify-center gap-1.5 rounded-full bg-elevated py-2.5 text-xs font-bold text-secondary ring-1 ring-border transition-colors hover:bg-subtle"
whileTap={{ scale: 0.95 }}
>
<RefreshCw size={12} />
</motion.button>
</div>
</motion.div>
<AuthModal
open={showAuth}
onClose={() => setShowAuth(false)}
onAuth={handleAuth}
defaultTab="register"
/>
<ShareCardModal
open={showShareCard}
onClose={() => setShowShareCard(false)}
data={{
type: "restaurant",
restaurant,
matchType,
matchLikes,
userCount,
scene,
}}
/>
</motion.div>
);
}