"use client"; import { useState, useCallback, useRef, useEffect } from "react"; import { AnimatePresence, motion } from "framer-motion"; import SwipeableCard from "./SwipeableCard"; import ActionButtons from "./ActionButtons"; import MatchResult from "./MatchResult"; import SwipeGuide from "./SwipeGuide"; import { Restaurant, SwipeDirection, MatchType, RunnerUp } 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]; } function UserProgressBar({ userId, swipeCounts, localIndex, total, }: { userId: string; swipeCounts: Record; localIndex: number; total: number; }) { const others = Object.entries(swipeCounts).filter(([id]) => id !== userId); if (others.length === 0) return null; return (
{getAvatar(userId).emoji} 你 {localIndex}/{total} {others.map(([id, count]) => { const finished = count >= total; const avatar = getAvatar(id); return ( {avatar.emoji} {count}/{total} {finished && } ); })}
); } function WaitingProgress({ userId, swipeCounts, total, }: { userId: string; swipeCounts: Record; total: number; }) { const entries = Object.entries(swipeCounts); if (entries.length <= 1) return null; const others = entries.filter(([id]) => id !== userId); const finishedCount = others.filter(([, c]) => c >= total).length; return (
{getAvatar(userId).emoji} 你 {total}/{total}
{others.map(([id, count]) => { const finished = count >= total; const pct = Math.min((count / total) * 100, 100); const avatar = getAvatar(id); return (
{avatar.emoji} {count}/{total} {finished && }
{!finished && (
)}
); })}

{finishedCount}/{others.length} 人已完成

); } interface SwipeDeckProps { restaurants: Restaurant[]; roomId: string; userId: string; initialIndex: number; matchedRestaurantId: string | null; matchType: MatchType; matchLikes: number; runnerUps: RunnerUp[]; likeCounts: Record; swipeCounts: Record; userCount: number; onReset: () => Promise; onNarrow: (restaurantIds: string[]) => Promise; } export default function SwipeDeck({ restaurants, roomId, userId, initialIndex, matchedRestaurantId, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, userCount, onReset, onNarrow, }: SwipeDeckProps) { const [currentIndex, setCurrentIndex] = useState(initialIndex); const [showMatch, setShowMatch] = useState(false); const [localMatchId, setLocalMatchId] = useState(null); const [resetting, setResetting] = useState(false); const [bubble, setBubble] = useState(""); const [guideVisible, setGuideVisible] = useState(initialIndex === 0); const [swipeHistory, setSwipeHistory] = useState( () => restaurants.slice(0, initialIndex).map((r) => r.id), ); const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null); const swipingRef = useRef(false); const prevLikeCounts = useRef>({}); const resolvedMatchId = matchedRestaurantId ?? localMatchId; useEffect(() => { if (resolvedMatchId != null && !showMatch) { setShowMatch(true); } }, [resolvedMatchId, showMatch]); useEffect(() => { const currentCard = restaurants[currentIndex]; if (!currentCard || resolvedMatchId) return; const prev = prevLikeCounts.current; const rid = currentCard.id; const oldCount = prev[rid] ?? 0; const newCount = likeCounts[rid] ?? 0; if (newCount > oldCount && oldCount > 0) { setBubble("有人也想去这家!"); const timer = setTimeout(() => setBubble(""), 2000); return () => clearTimeout(timer); } }, [likeCounts, currentIndex, restaurants, resolvedMatchId]); useEffect(() => { prevLikeCounts.current = { ...likeCounts }; }, [likeCounts]); const registerSwipe = useCallback( (fn: (direction: SwipeDirection) => void) => { swipeFnRef.current = fn; }, [], ); const sendSwipe = async (restaurantId: string, action: "like" | "nope") => { try { const res = await fetch(`/api/room/${roomId}/swipe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, restaurantId, action }), }); const data = await res.json(); if (data.match != null) { setLocalMatchId(data.match); } } catch { // Polling will catch match state } }; const handleSwipe = useCallback( (direction: SwipeDirection) => { const current = restaurants[currentIndex]; if (!current) return; swipingRef.current = false; if (guideVisible) setGuideVisible(false); const action = direction === "right" ? "like" : "nope"; sendSwipe(current.id, action); setSwipeHistory((h) => [...h, current.id]); const nextIndex = currentIndex + 1; setCurrentIndex(nextIndex); swipeFnRef.current = null; }, // eslint-disable-next-line react-hooks/exhaustive-deps [currentIndex, restaurants, roomId, userId], ); const handleButtonAction = useCallback( (direction: SwipeDirection) => { if (swipeFnRef.current && !swipingRef.current) { swipingRef.current = true; swipeFnRef.current(direction); } }, [], ); const handleUndo = useCallback(async () => { if (swipeHistory.length === 0 || currentIndex === 0) return; const lastRid = swipeHistory[swipeHistory.length - 1]; try { await fetch(`/api/room/${roomId}/undo`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, restaurantId: lastRid }), }); } catch { // Best-effort } setSwipeHistory((h) => h.slice(0, -1)); setCurrentIndex((i) => i - 1); setLocalMatchId(null); swipeFnRef.current = null; }, [swipeHistory, currentIndex, roomId, userId]); const clearLocalState = useCallback(() => { setCurrentIndex(0); setShowMatch(false); setLocalMatchId(null); setSwipeHistory([]); prevLikeCounts.current = {}; }, []); const handleReset = useCallback(async () => { setResetting(true); try { await onReset(); clearLocalState(); } finally { setResetting(false); } }, [onReset, clearLocalState]); const handleNarrow = useCallback(async (restaurantIds: string[]) => { setResetting(true); try { await onNarrow(restaurantIds); clearLocalState(); } finally { setResetting(false); } }, [onNarrow, clearLocalState]); const allSwiped = currentIndex >= restaurants.length; const isDone = allSwiped || resolvedMatchId != null; const matchRestaurant = resolvedMatchId ? restaurants.find((r) => r.id === resolvedMatchId) ?? null : null; const showWaiting = allSwiped && !resolvedMatchId; return ( <> {!allSwiped && !resolvedMatchId && (
{currentIndex}/{restaurants.length}
)}
{currentIndex === 0 && !resolvedMatchId && guideVisible && ( setGuideVisible(false)} /> )} {!resolvedMatchId && ( {restaurants.map((restaurant, index) => { if (index < currentIndex || index > currentIndex + 1) return null; const isTop = index === currentIndex; return ( ); })} )} {showWaiting && (

等待其他人完成选择

)} {bubble && ( {bubble} )}
{showMatch && matchRestaurant && ( )} ); }