feat: best 匹配结果页展示 Top 3 候选排行,支持折叠查看备选餐厅
This commit is contained in:
@@ -26,18 +26,20 @@ export async function GET(
|
|||||||
let match = data.match;
|
let match = data.match;
|
||||||
let matchType: MatchType = null;
|
let matchType: MatchType = null;
|
||||||
let matchLikes = 0;
|
let matchLikes = 0;
|
||||||
|
let runnerUps: { id: string; likes: number }[] = [];
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
matchType = "unanimous";
|
matchType = "unanimous";
|
||||||
matchLikes = data.users.length;
|
matchLikes = data.users.length;
|
||||||
} else if (allFinished && data.restaurants.length > 0) {
|
} else if (allFinished && data.restaurants.length > 0) {
|
||||||
const best = findBestMatch(data.likes, data.restaurants);
|
const ranked = rankRestaurants(data.likes, data.restaurants);
|
||||||
if (best.likes > 0) {
|
if (ranked[0].likes > 0) {
|
||||||
match = best.id;
|
match = ranked[0].id;
|
||||||
matchType = "best";
|
matchType = "best";
|
||||||
matchLikes = best.likes;
|
matchLikes = ranked[0].likes;
|
||||||
|
runnerUps = ranked.slice(1, 3).filter((r) => r.likes > 0);
|
||||||
} else {
|
} else {
|
||||||
match = best.id;
|
match = ranked[0].id;
|
||||||
matchType = "no_match";
|
matchType = "no_match";
|
||||||
matchLikes = 0;
|
matchLikes = 0;
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,7 @@ export async function GET(
|
|||||||
match,
|
match,
|
||||||
matchType,
|
matchType,
|
||||||
matchLikes,
|
matchLikes,
|
||||||
|
runnerUps,
|
||||||
likeCounts,
|
likeCounts,
|
||||||
swipeCounts: data.swipeCounts,
|
swipeCounts: data.swipeCounts,
|
||||||
restaurants: data.restaurants,
|
restaurants: data.restaurants,
|
||||||
@@ -69,26 +72,12 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBestMatch(
|
function rankRestaurants(
|
||||||
likes: Record<string, string[]>,
|
likes: Record<string, string[]>,
|
||||||
restaurants: { id: string; rating: number }[],
|
restaurants: { id: string; rating: number }[],
|
||||||
): { id: string; likes: number } {
|
): { id: string; likes: number }[] {
|
||||||
let bestId = restaurants[0].id;
|
return restaurants
|
||||||
let bestLikes = 0;
|
.map((r) => ({ id: r.id, likes: likes[r.id]?.length ?? 0, rating: r.rating }))
|
||||||
let bestRating = restaurants[0].rating;
|
.sort((a, b) => b.likes - a.likes || b.rating - a.rating)
|
||||||
|
.map(({ id, likes: l }) => ({ id, likes: l }));
|
||||||
for (const r of restaurants) {
|
|
||||||
const count = likes[r.id]?.length ?? 0;
|
|
||||||
|
|
||||||
if (
|
|
||||||
count > bestLikes ||
|
|
||||||
(count === bestLikes && r.rating > bestRating)
|
|
||||||
) {
|
|
||||||
bestId = r.id;
|
|
||||||
bestLikes = count;
|
|
||||||
bestRating = r.rating;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { id: bestId, likes: bestLikes };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function RoomPage() {
|
|||||||
const [joined, setJoined] = useState(false);
|
const [joined, setJoined] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userCount, match, matchType, matchLikes, likeCounts, swipeCounts, restaurants, mutate,
|
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, restaurants, mutate,
|
||||||
} = useRoomPolling(roomId);
|
} = useRoomPolling(roomId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,6 +57,7 @@ export default function RoomPage() {
|
|||||||
matchedRestaurantId={match}
|
matchedRestaurantId={match}
|
||||||
matchType={matchType}
|
matchType={matchType}
|
||||||
matchLikes={matchLikes}
|
matchLikes={matchLikes}
|
||||||
|
runnerUps={runnerUps}
|
||||||
likeCounts={likeCounts}
|
likeCounts={likeCounts}
|
||||||
swipeCounts={swipeCounts}
|
swipeCounts={swipeCounts}
|
||||||
userCount={userCount}
|
userCount={userCount}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -13,13 +14,16 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
SearchX,
|
SearchX,
|
||||||
Home,
|
Home,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Restaurant, MatchType } from "@/types";
|
import { Restaurant, MatchType, RunnerUp } from "@/types";
|
||||||
|
|
||||||
interface MatchResultProps {
|
interface MatchResultProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
matchType: MatchType;
|
matchType: MatchType;
|
||||||
matchLikes: number;
|
matchLikes: number;
|
||||||
|
runnerUps: RunnerUp[];
|
||||||
|
allRestaurants: Restaurant[];
|
||||||
userCount: number;
|
userCount: number;
|
||||||
onReset: () => Promise<void>;
|
onReset: () => Promise<void>;
|
||||||
resetting: boolean;
|
resetting: boolean;
|
||||||
@@ -104,23 +108,81 @@ function NoMatchResult({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RunnerUpCard({
|
||||||
|
restaurant,
|
||||||
|
likes,
|
||||||
|
userCount,
|
||||||
|
}: {
|
||||||
|
restaurant: Restaurant;
|
||||||
|
likes: number;
|
||||||
|
userCount: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={buildNavUrl(restaurant)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex gap-3 rounded-xl bg-white/10 p-2.5 backdrop-blur-sm transition-colors hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={restaurant.image}
|
||||||
|
alt={restaurant.name}
|
||||||
|
className="h-16 w-16 shrink-0 rounded-lg object-cover"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||||
|
<p className="truncate text-sm font-bold text-white">
|
||||||
|
{restaurant.name}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-white/70">
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Star size={11} className="fill-yellow-300 text-yellow-300" />
|
||||||
|
{restaurant.rating}
|
||||||
|
</span>
|
||||||
|
<span>{restaurant.price}</span>
|
||||||
|
{restaurant.distance && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<MapPin size={11} />
|
||||||
|
{restaurant.distance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-[11px] font-medium text-amber-200">
|
||||||
|
{likes}/{userCount} 人想去
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MatchResult({
|
export default function MatchResult({
|
||||||
restaurant,
|
restaurant,
|
||||||
matchType,
|
matchType,
|
||||||
matchLikes,
|
matchLikes,
|
||||||
|
runnerUps,
|
||||||
|
allRestaurants,
|
||||||
userCount,
|
userCount,
|
||||||
onReset,
|
onReset,
|
||||||
resetting,
|
resetting,
|
||||||
}: MatchResultProps) {
|
}: MatchResultProps) {
|
||||||
|
const [showRunnerUps, setShowRunnerUps] = useState(false);
|
||||||
|
|
||||||
if (matchType === "no_match") {
|
if (matchType === "no_match") {
|
||||||
return <NoMatchResult onReset={onReset} resetting={resetting} />;
|
return <NoMatchResult onReset={onReset} resetting={resetting} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUnanimous = matchType === "unanimous";
|
const isUnanimous = matchType === "unanimous";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto px-6 py-10 ${
|
className={`fixed inset-0 z-50 flex flex-col items-center overflow-y-auto px-6 py-10 ${
|
||||||
isUnanimous
|
isUnanimous
|
||||||
? "bg-linear-to-b from-emerald-500 to-teal-600"
|
? "bg-linear-to-b from-emerald-500 to-teal-600"
|
||||||
: "bg-linear-to-b from-amber-500 to-orange-500"
|
: "bg-linear-to-b from-amber-500 to-orange-500"
|
||||||
@@ -129,6 +191,7 @@ export default function MatchResult({
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.4 }}
|
transition={{ duration: 0.4 }}
|
||||||
>
|
>
|
||||||
|
<div className="flex w-full max-w-sm flex-1 flex-col items-center justify-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0, rotate: -20 }}
|
initial={{ scale: 0, rotate: -20 }}
|
||||||
animate={{ scale: 1, rotate: 0 }}
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
@@ -162,7 +225,7 @@ export default function MatchResult({
|
|||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-6 w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl"
|
className="mt-6 w-full overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||||
initial={{ y: 60, opacity: 0 }}
|
initial={{ y: 60, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }}
|
transition={{ type: "spring", stiffness: 180, damping: 18, delay: 0.5 }}
|
||||||
@@ -233,7 +296,7 @@ export default function MatchResult({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-5 flex w-full max-w-sm flex-col gap-2.5"
|
className="mt-5 flex w-full flex-col gap-2.5"
|
||||||
initial={{ y: 30, opacity: 0 }}
|
initial={{ y: 30, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ delay: 0.65 }}
|
transition={{ delay: 0.65 }}
|
||||||
@@ -263,6 +326,49 @@ export default function MatchResult({
|
|||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{!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-white/80 transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
其他候选({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>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
className={`mt-4 flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${
|
className={`mt-4 flex items-center gap-1.5 text-sm font-medium underline underline-offset-2 hover:text-white ${
|
||||||
isUnanimous ? "text-emerald-200" : "text-amber-200"
|
isUnanimous ? "text-emerald-200" : "text-amber-200"
|
||||||
@@ -276,6 +382,7 @@ export default function MatchResult({
|
|||||||
<RotateCcw size={13} className={resetting ? "animate-spin" : ""} />
|
<RotateCcw size={13} className={resetting ? "animate-spin" : ""} />
|
||||||
{resetting ? "重置中..." : "再来一轮"}
|
{resetting ? "重置中..." : "再来一轮"}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import SwipeableCard from "./SwipeableCard";
|
|||||||
import ActionButtons from "./ActionButtons";
|
import ActionButtons from "./ActionButtons";
|
||||||
import MatchResult from "./MatchResult";
|
import MatchResult from "./MatchResult";
|
||||||
import SwipeGuide from "./SwipeGuide";
|
import SwipeGuide from "./SwipeGuide";
|
||||||
import { Restaurant, SwipeDirection, MatchType } from "@/types";
|
import { Restaurant, SwipeDirection, MatchType, RunnerUp } from "@/types";
|
||||||
import { Heart, Undo2, Check } from "lucide-react";
|
import { Heart, Undo2, Check } from "lucide-react";
|
||||||
|
|
||||||
const AVATARS = [
|
const AVATARS = [
|
||||||
@@ -144,6 +144,7 @@ interface SwipeDeckProps {
|
|||||||
matchedRestaurantId: string | null;
|
matchedRestaurantId: string | null;
|
||||||
matchType: MatchType;
|
matchType: MatchType;
|
||||||
matchLikes: number;
|
matchLikes: number;
|
||||||
|
runnerUps: RunnerUp[];
|
||||||
likeCounts: Record<string, number>;
|
likeCounts: Record<string, number>;
|
||||||
swipeCounts: Record<string, number>;
|
swipeCounts: Record<string, number>;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
@@ -158,6 +159,7 @@ export default function SwipeDeck({
|
|||||||
matchedRestaurantId,
|
matchedRestaurantId,
|
||||||
matchType,
|
matchType,
|
||||||
matchLikes,
|
matchLikes,
|
||||||
|
runnerUps,
|
||||||
likeCounts,
|
likeCounts,
|
||||||
swipeCounts,
|
swipeCounts,
|
||||||
userCount,
|
userCount,
|
||||||
@@ -398,6 +400,8 @@ export default function SwipeDeck({
|
|||||||
restaurant={matchRestaurant}
|
restaurant={matchRestaurant}
|
||||||
matchType={matchType ?? "unanimous"}
|
matchType={matchType ?? "unanimous"}
|
||||||
matchLikes={matchLikes}
|
matchLikes={matchLikes}
|
||||||
|
runnerUps={runnerUps}
|
||||||
|
allRestaurants={restaurants}
|
||||||
userCount={userCount}
|
userCount={userCount}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
resetting={resetting}
|
resetting={resetting}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
runnerUps: data?.runnerUps ?? [],
|
||||||
likeCounts: data?.likeCounts ?? {},
|
likeCounts: data?.likeCounts ?? {},
|
||||||
swipeCounts: data?.swipeCounts ?? {},
|
swipeCounts: data?.swipeCounts ?? {},
|
||||||
restaurants: data?.restaurants ?? [],
|
restaurants: data?.restaurants ?? [],
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ export type SwipeDirection = "left" | "right";
|
|||||||
|
|
||||||
export type MatchType = "unanimous" | "best" | "no_match" | null;
|
export type MatchType = "unanimous" | "best" | "no_match" | null;
|
||||||
|
|
||||||
|
export interface RunnerUp {
|
||||||
|
id: string;
|
||||||
|
likes: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomStatus {
|
export interface RoomStatus {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
match: string | null;
|
match: string | null;
|
||||||
matchType: MatchType;
|
matchType: MatchType;
|
||||||
matchLikes: number;
|
matchLikes: number;
|
||||||
|
runnerUps: RunnerUp[];
|
||||||
likeCounts: Record<string, number>;
|
likeCounts: Record<string, number>;
|
||||||
swipeCounts: Record<string, number>;
|
swipeCounts: Record<string, number>;
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
|
|||||||
Reference in New Issue
Block a user