feat: 增加滑动参与感 - 进度条、实时气泡、热度标签
- 卡片上方显示滑动进度条和计数 (3/15)
- 轮询检测到当前卡片新增 like 时弹出"有人也想去这家!"气泡
- 卡片图片角落显示"🔥N 人想去"热度标签
- 后端 GET /api/room/[id] 新增 likeCounts 字段
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
{restaurant.category && (
|
<div className="absolute bottom-3 left-4 flex items-center gap-1.5">
|
||||||
<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">
|
{restaurant.category && (
|
||||||
{restaurant.category}
|
<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">
|
||||||
</span>
|
{restaurant.category}
|
||||||
)}
|
</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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user