feat: 增加滑动参与感 - 进度条、实时气泡、热度标签
- 卡片上方显示滑动进度条和计数 (3/15)
- 轮询检测到当前卡片新增 like 时弹出"有人也想去这家!"气泡
- 卡片图片角落显示"🔥N 人想去"热度标签
- 后端 GET /api/room/[id] 新增 likeCounts 字段
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import SwipeableCard from "./SwipeableCard";
|
||||
import ActionButtons from "./ActionButtons";
|
||||
import MatchResult from "./MatchResult";
|
||||
import { Restaurant, SwipeDirection, MatchType } from "@/types";
|
||||
import { Heart } from "lucide-react";
|
||||
|
||||
interface SwipeDeckProps {
|
||||
restaurants: Restaurant[];
|
||||
@@ -14,6 +15,7 @@ interface SwipeDeckProps {
|
||||
matchedRestaurantId: string | null;
|
||||
matchType: MatchType;
|
||||
matchLikes: number;
|
||||
likeCounts: Record<string, number>;
|
||||
userCount: number;
|
||||
onReset: () => Promise<void>;
|
||||
}
|
||||
@@ -25,6 +27,7 @@ export default function SwipeDeck({
|
||||
matchedRestaurantId,
|
||||
matchType,
|
||||
matchLikes,
|
||||
likeCounts,
|
||||
userCount,
|
||||
onReset,
|
||||
}: SwipeDeckProps) {
|
||||
@@ -32,8 +35,10 @@ export default function SwipeDeck({
|
||||
const [showMatch, setShowMatch] = useState(false);
|
||||
const [localMatchId, setLocalMatchId] = useState<string | null>(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [bubble, setBubble] = useState("");
|
||||
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
|
||||
const swipingRef = useRef(false);
|
||||
const prevLikeCounts = useRef<Record<string, number>>({});
|
||||
|
||||
const resolvedMatchId = matchedRestaurantId ?? localMatchId;
|
||||
|
||||
@@ -43,6 +48,26 @@ export default function SwipeDeck({
|
||||
}
|
||||
}, [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;
|
||||
@@ -101,6 +126,7 @@ export default function SwipeDeck({
|
||||
setCurrentIndex(0);
|
||||
setShowMatch(false);
|
||||
setLocalMatchId(null);
|
||||
prevLikeCounts.current = {};
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
@@ -117,6 +143,24 @@ export default function SwipeDeck({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!allSwiped && !resolvedMatchId && (
|
||||
<div className="flex items-center justify-center gap-2 px-4 pb-1">
|
||||
<div className="h-1 flex-1 max-w-sm overflow-hidden rounded-full bg-zinc-100">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-emerald-400"
|
||||
initial={{ width: 0 }}
|
||||
animate={{
|
||||
width: `${((currentIndex) / restaurants.length) * 100}%`,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-zinc-400">
|
||||
{currentIndex}/{restaurants.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-1 items-center justify-center px-4">
|
||||
<div className="relative h-[70vh] w-full max-w-sm">
|
||||
{!resolvedMatchId && (
|
||||
@@ -132,6 +176,7 @@ export default function SwipeDeck({
|
||||
isTop={isTop}
|
||||
onSwipe={handleSwipe}
|
||||
registerSwipe={isTop ? registerSwipe : undefined}
|
||||
likeCount={likeCounts[restaurant.id] ?? 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -144,6 +189,21 @@ export default function SwipeDeck({
|
||||
<p className="text-sm text-zinc-400">等待其他人完成选择...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{bubble && (
|
||||
<motion.div
|
||||
className="absolute left-1/2 top-4 z-30 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-rose-500 px-3.5 py-1.5 text-xs font-semibold text-white shadow-lg"
|
||||
initial={{ opacity: 0, y: -16, x: "-50%" }}
|
||||
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
||||
exit={{ opacity: 0, y: -16, x: "-50%" }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<Heart size={12} className="fill-white" />
|
||||
{bubble}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user