feat: 增加滑动参与感 - 进度条、实时气泡、热度标签

- 卡片上方显示滑动进度条和计数 (3/15)
- 轮询检测到当前卡片新增 like 时弹出"有人也想去这家!"气泡
- 卡片图片角落显示"🔥N 人想去"热度标签
- 后端 GET /api/room/[id] 新增 likeCounts 字段
This commit is contained in:
2026-02-24 17:36:04 +08:00
parent a72f7ed884
commit fb49e21eb2
7 changed files with 94 additions and 11 deletions
+8
View File
@@ -37,12 +37,20 @@ export async function GET(
matchLikes = best.likes; matchLikes = best.likes;
} }
const likeCounts: Record<string, number> = {};
for (const [rid, users] of Object.entries(data.likes)) {
if (users.length > 0) {
likeCounts[rid] = users.length;
}
}
return NextResponse.json({ return NextResponse.json({
roomId: id, roomId: id,
userCount: data.users.length, userCount: data.users.length,
match, match,
matchType, matchType,
matchLikes, matchLikes,
likeCounts,
restaurants: data.restaurants, restaurants: data.restaurants,
}); });
} catch (e) { } catch (e) {
+4 -2
View File
@@ -14,8 +14,9 @@ export default function RoomPage() {
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
const [joined, setJoined] = useState(false); const [joined, setJoined] = useState(false);
const { userCount, match, matchType, matchLikes, restaurants, mutate } = const {
useRoomPolling(roomId); userCount, match, matchType, matchLikes, likeCounts, restaurants, mutate,
} = useRoomPolling(roomId);
useEffect(() => { useEffect(() => {
const id = getUserId(); const id = getUserId();
@@ -54,6 +55,7 @@ export default function RoomPage() {
matchedRestaurantId={match} matchedRestaurantId={match}
matchType={matchType} matchType={matchType}
matchLikes={matchLikes} matchLikes={matchLikes}
likeCounts={likeCounts}
userCount={userCount} userCount={userCount}
onReset={handleReset} onReset={handleReset}
/> />
+12 -3
View File
@@ -1,18 +1,19 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback } from "react";
import { Star, MapPin, Clock, ExternalLink } from "lucide-react"; import { Star, MapPin, Clock, ExternalLink, Flame } from "lucide-react";
import { Restaurant } from "@/types"; import { Restaurant } from "@/types";
interface RestaurantCardProps { interface RestaurantCardProps {
restaurant: Restaurant; restaurant: Restaurant;
likeCount?: number;
} }
function stopAll(e: React.SyntheticEvent) { function stopAll(e: React.SyntheticEvent) {
e.stopPropagation(); e.stopPropagation();
} }
export default function RestaurantCard({ restaurant }: RestaurantCardProps) { export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
const openLink = useCallback( const openLink = useCallback(
(url: string) => (e: React.MouseEvent | React.TouchEvent) => { (url: string) => (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -37,11 +38,19 @@ export default function RestaurantCard({ restaurant }: RestaurantCardProps) {
/> />
<div className="absolute inset-0 bg-linear-to-t from-black/40 via-transparent to-transparent" /> <div className="absolute inset-0 bg-linear-to-t from-black/40 via-transparent to-transparent" />
<div className="absolute bottom-3 left-4 flex items-center gap-1.5">
{restaurant.category && ( {restaurant.category && (
<span className="absolute bottom-3 left-4 rounded-full bg-white/90 px-2.5 py-0.5 text-xs font-semibold text-zinc-700 shadow-sm backdrop-blur-sm"> <span className="rounded-full bg-white/90 px-2.5 py-0.5 text-xs font-semibold text-zinc-700 shadow-sm backdrop-blur-sm">
{restaurant.category} {restaurant.category}
</span> </span>
)} )}
{likeCount > 0 && (
<span className="flex items-center gap-0.5 rounded-full bg-rose-500/90 px-2 py-0.5 text-xs font-semibold text-white shadow-sm backdrop-blur-sm">
<Flame size={11} />
{likeCount}
</span>
)}
</div>
</div> </div>
<div className="flex flex-1 flex-col justify-center gap-2 px-5 py-3"> <div className="flex flex-1 flex-col justify-center gap-2 px-5 py-3">
+61 -1
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import { AnimatePresence } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import SwipeableCard from "./SwipeableCard"; import SwipeableCard from "./SwipeableCard";
import ActionButtons from "./ActionButtons"; import ActionButtons from "./ActionButtons";
import MatchResult from "./MatchResult"; import MatchResult from "./MatchResult";
import { Restaurant, SwipeDirection, MatchType } from "@/types"; import { Restaurant, SwipeDirection, MatchType } from "@/types";
import { Heart } from "lucide-react";
interface SwipeDeckProps { interface SwipeDeckProps {
restaurants: Restaurant[]; restaurants: Restaurant[];
@@ -14,6 +15,7 @@ interface SwipeDeckProps {
matchedRestaurantId: string | null; matchedRestaurantId: string | null;
matchType: MatchType; matchType: MatchType;
matchLikes: number; matchLikes: number;
likeCounts: Record<string, number>;
userCount: number; userCount: number;
onReset: () => Promise<void>; onReset: () => Promise<void>;
} }
@@ -25,6 +27,7 @@ export default function SwipeDeck({
matchedRestaurantId, matchedRestaurantId,
matchType, matchType,
matchLikes, matchLikes,
likeCounts,
userCount, userCount,
onReset, onReset,
}: SwipeDeckProps) { }: SwipeDeckProps) {
@@ -32,8 +35,10 @@ export default function SwipeDeck({
const [showMatch, setShowMatch] = useState(false); const [showMatch, setShowMatch] = useState(false);
const [localMatchId, setLocalMatchId] = useState<string | null>(null); const [localMatchId, setLocalMatchId] = useState<string | null>(null);
const [resetting, setResetting] = useState(false); const [resetting, setResetting] = useState(false);
const [bubble, setBubble] = useState("");
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null); const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
const swipingRef = useRef(false); const swipingRef = useRef(false);
const prevLikeCounts = useRef<Record<string, number>>({});
const resolvedMatchId = matchedRestaurantId ?? localMatchId; const resolvedMatchId = matchedRestaurantId ?? localMatchId;
@@ -43,6 +48,26 @@ export default function SwipeDeck({
} }
}, [resolvedMatchId, showMatch]); }, [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( const registerSwipe = useCallback(
(fn: (direction: SwipeDirection) => void) => { (fn: (direction: SwipeDirection) => void) => {
swipeFnRef.current = fn; swipeFnRef.current = fn;
@@ -101,6 +126,7 @@ export default function SwipeDeck({
setCurrentIndex(0); setCurrentIndex(0);
setShowMatch(false); setShowMatch(false);
setLocalMatchId(null); setLocalMatchId(null);
prevLikeCounts.current = {};
} finally { } finally {
setResetting(false); setResetting(false);
} }
@@ -117,6 +143,24 @@ export default function SwipeDeck({
return ( 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 flex flex-1 items-center justify-center px-4">
<div className="relative h-[70vh] w-full max-w-sm"> <div className="relative h-[70vh] w-full max-w-sm">
{!resolvedMatchId && ( {!resolvedMatchId && (
@@ -132,6 +176,7 @@ export default function SwipeDeck({
isTop={isTop} isTop={isTop}
onSwipe={handleSwipe} onSwipe={handleSwipe}
registerSwipe={isTop ? registerSwipe : undefined} 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> <p className="text-sm text-zinc-400">...</p>
</div> </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>
</div> </div>
+3 -1
View File
@@ -21,6 +21,7 @@ interface SwipeableCardProps {
isTop: boolean; isTop: boolean;
onSwipe: (direction: SwipeDirection) => void; onSwipe: (direction: SwipeDirection) => void;
registerSwipe?: (fn: (direction: SwipeDirection) => void) => void; registerSwipe?: (fn: (direction: SwipeDirection) => void) => void;
likeCount: number;
} }
function SwipeOverlay({ x }: { x: MotionValue<number> }) { function SwipeOverlay({ x }: { x: MotionValue<number> }) {
@@ -54,6 +55,7 @@ export default function SwipeableCard({
isTop, isTop,
onSwipe, onSwipe,
registerSwipe, registerSwipe,
likeCount,
}: SwipeableCardProps) { }: SwipeableCardProps) {
const x = useMotionValue(0); const x = useMotionValue(0);
const rotate = useTransform(x, [-300, 300], [-ROTATION_RANGE, ROTATION_RANGE]); const rotate = useTransform(x, [-300, 300], [-ROTATION_RANGE, ROTATION_RANGE]);
@@ -108,7 +110,7 @@ export default function SwipeableCard({
transition={{ type: "spring", stiffness: 300, damping: 25 }} transition={{ type: "spring", stiffness: 300, damping: 25 }}
> >
<SwipeOverlay x={x} /> <SwipeOverlay x={x} />
<RestaurantCard restaurant={restaurant} /> <RestaurantCard restaurant={restaurant} likeCount={likeCount} />
</motion.div> </motion.div>
); );
} }
+1
View File
@@ -29,6 +29,7 @@ export function useRoomPolling(roomId: string) {
match: data?.match ?? null, match: data?.match ?? null,
matchType: data?.matchType ?? null, matchType: data?.matchType ?? null,
matchLikes: data?.matchLikes ?? 0, matchLikes: data?.matchLikes ?? 0,
likeCounts: data?.likeCounts ?? {},
restaurants: data?.restaurants ?? [], restaurants: data?.restaurants ?? [],
isLoading, isLoading,
error, error,
+1
View File
@@ -23,5 +23,6 @@ export interface RoomStatus {
match: string | null; match: string | null;
matchType: MatchType; matchType: MatchType;
matchLikes: number; matchLikes: number;
likeCounts: Record<string, number>;
restaurants: Restaurant[]; restaurants: Restaurant[];
} }