37eb7f07d7
- RestaurantCard: like 徽章实时弹跳动画,首次出现滑入效果 - RestaurantCard: 分类标签改为白底黑字,图片上始终清晰 - SwipeDeck: 进度数字加背景色块包裹,与用户名视觉分离
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState, useEffect, useRef } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { Restaurant } from "@/types";
|
|
import { getUserId, isRegistered } from "@/lib/userId";
|
|
import RestaurantImage from "@/components/RestaurantImage";
|
|
|
|
interface RestaurantCardProps {
|
|
restaurant: Restaurant;
|
|
likeCount?: number;
|
|
}
|
|
|
|
function stopAll(e: React.SyntheticEvent) {
|
|
e.stopPropagation();
|
|
}
|
|
|
|
function ImageGallery({ images, name }: { images: string[]; name: string }) {
|
|
const [idx, setIdx] = useState(0);
|
|
const [fadingOut, setFadingOut] = useState<number | null>(null);
|
|
const [showHint, setShowHint] = useState(true);
|
|
const count = images.length;
|
|
const hasMultiple = count > 1;
|
|
|
|
useEffect(() => {
|
|
if (!hasMultiple || !showHint) return;
|
|
const t = setTimeout(() => setShowHint(false), 2500);
|
|
return () => clearTimeout(t);
|
|
}, [hasMultiple, showHint]);
|
|
|
|
const goTo = useCallback(
|
|
(newIdx: number) => {
|
|
if (newIdx === idx) return;
|
|
setFadingOut(idx);
|
|
setIdx(newIdx);
|
|
},
|
|
[idx],
|
|
);
|
|
|
|
const handleTap = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!hasMultiple) return;
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
setShowHint(false);
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const tapX = e.clientX - rect.left;
|
|
const isRight = tapX > rect.width / 2;
|
|
|
|
const newIdx = isRight
|
|
? Math.min(idx + 1, count - 1)
|
|
: Math.max(idx - 1, 0);
|
|
goTo(newIdx);
|
|
},
|
|
[hasMultiple, count, idx, goTo],
|
|
);
|
|
|
|
return (
|
|
<div className="relative h-full w-full" onClick={handleTap} onPointerDown={stopAll}>
|
|
<RestaurantImage
|
|
src={images[idx]}
|
|
alt={name}
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
draggable={false}
|
|
/>
|
|
|
|
{fadingOut !== null && (
|
|
<RestaurantImage
|
|
key={fadingOut}
|
|
src={images[fadingOut]}
|
|
alt=""
|
|
className="absolute inset-0 h-full w-full object-cover"
|
|
style={{ animation: "img-fade-out 280ms ease-out forwards" }}
|
|
onAnimationEnd={() => setFadingOut(null)}
|
|
draggable={false}
|
|
/>
|
|
)}
|
|
|
|
{hasMultiple && (
|
|
<>
|
|
<div className="absolute left-0 right-0 top-2 flex justify-center gap-1">
|
|
{images.map((_, i) => (
|
|
<span
|
|
key={i}
|
|
className={`h-[3px] rounded-full transition-all duration-200 ${
|
|
i === idx
|
|
? "w-4 bg-white"
|
|
: "w-1.5 bg-white/40"
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{idx > 0 && (
|
|
<div className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/25 p-1 backdrop-blur-sm">
|
|
<ChevronLeft size={20} className="text-white/90" />
|
|
</div>
|
|
)}
|
|
{idx < count - 1 && (
|
|
<div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/25 p-1 backdrop-blur-sm">
|
|
<ChevronRight size={20} className="text-white/90" />
|
|
</div>
|
|
)}
|
|
|
|
{showHint && idx === 0 && (
|
|
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 animate-pulse text-center">
|
|
<span className="rounded-full bg-black/40 px-3 py-1 text-[11px] font-medium text-white/90 backdrop-blur-sm">
|
|
点击图片左右切换 · 共 {count} 张
|
|
</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
|
|
const [favorited, setFavorited] = useState(false);
|
|
const [likeBounce, setLikeBounce] = useState(false);
|
|
const prevLikeRef = useRef(likeCount);
|
|
|
|
useEffect(() => {
|
|
if (likeCount > prevLikeRef.current) {
|
|
setLikeBounce(true);
|
|
const t = setTimeout(() => setLikeBounce(false), 600);
|
|
return () => clearTimeout(t);
|
|
}
|
|
prevLikeRef.current = likeCount;
|
|
}, [likeCount]);
|
|
|
|
const images = restaurant.images?.filter(Boolean);
|
|
const hasImage = images && images.length > 0;
|
|
|
|
const handleFavorite = useCallback(async (e: React.MouseEvent | React.TouchEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
if (favorited) return;
|
|
|
|
try {
|
|
const res = await fetch("/api/user/favorite", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ userId: getUserId(), restaurant }),
|
|
});
|
|
if (res.ok) setFavorited(true);
|
|
} catch {}
|
|
}, [restaurant, favorited]);
|
|
const openLink = useCallback(
|
|
(url: string) => (e: React.MouseEvent | React.TouchEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
},
|
|
[],
|
|
);
|
|
|
|
const amapUrl = `https://uri.amap.com/poidetail?poiid=${restaurant.id}`;
|
|
const dianpingUrl = `https://m.dianping.com/search/keyword/0/0_${encodeURIComponent(restaurant.name)}`;
|
|
|
|
return (
|
|
<div className="flex h-full w-full flex-col overflow-hidden rounded-2xl bg-surface shadow-xl ring-1 ring-border">
|
|
<div className="relative h-[58%] w-full shrink-0 overflow-hidden bg-elevated">
|
|
{hasImage && <ImageGallery images={images} name={restaurant.name} />}
|
|
<div className="pointer-events-none 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 && (
|
|
<span className="rounded-full bg-white/80 px-2.5 py-0.5 text-xs font-semibold text-gray-800 shadow-sm backdrop-blur-sm">
|
|
{restaurant.category}
|
|
</span>
|
|
)}
|
|
<AnimatePresence>
|
|
{likeCount > 0 && (
|
|
<motion.span
|
|
key="like-badge"
|
|
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"
|
|
initial={{ opacity: 0, scale: 0.5, x: -8 }}
|
|
animate={{
|
|
opacity: 1,
|
|
scale: likeBounce ? [1, 1.3, 1] : 1,
|
|
x: 0,
|
|
}}
|
|
exit={{ opacity: 0, scale: 0.5 }}
|
|
transition={likeBounce
|
|
? { scale: { duration: 0.4, ease: "easeInOut" }, default: { type: "spring", stiffness: 400, damping: 20 } }
|
|
: { type: "spring", stiffness: 400, damping: 20 }
|
|
}
|
|
>
|
|
<Flame size={11} className={likeBounce ? "animate-pulse" : ""} />
|
|
{likeCount} 人想去
|
|
</motion.span>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col justify-center gap-2 px-5 py-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h2 className="text-lg font-bold leading-tight text-heading">
|
|
{restaurant.name}
|
|
</h2>
|
|
<div className="mt-0.5 flex shrink-0 gap-1.5">
|
|
{isRegistered() && (
|
|
<button
|
|
onClick={handleFavorite}
|
|
onPointerDown={stopAll}
|
|
onTouchStart={stopAll}
|
|
className={`flex items-center justify-center rounded-full p-1 transition-colors ${
|
|
favorited
|
|
? "bg-amber-500/20 text-amber-400"
|
|
: "bg-elevated text-muted active:bg-amber-500/15 active:text-amber-400"
|
|
}`}
|
|
>
|
|
<Bookmark size={13} className={favorited ? "fill-amber-400" : ""} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={openLink(amapUrl)}
|
|
onPointerDown={stopAll}
|
|
onTouchStart={stopAll}
|
|
className="flex items-center gap-0.5 rounded-full bg-blue-500/15 px-2 py-0.5 text-[11px] font-medium text-blue-400 transition-colors active:bg-blue-500/25"
|
|
>
|
|
高德
|
|
<ExternalLink size={10} />
|
|
</button>
|
|
<button
|
|
onClick={openLink(dianpingUrl)}
|
|
onPointerDown={stopAll}
|
|
onTouchStart={stopAll}
|
|
className="flex items-center gap-0.5 rounded-full bg-orange-500/15 px-2 py-0.5 text-[11px] font-medium text-orange-400 transition-colors active:bg-orange-500/25"
|
|
>
|
|
点评
|
|
<ExternalLink size={10} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<div className="flex items-center gap-1">
|
|
<Star size={14} className="fill-amber-400 text-amber-400" />
|
|
<span className="text-sm font-semibold text-foreground">
|
|
{restaurant.rating}
|
|
</span>
|
|
</div>
|
|
|
|
<span className="text-sm font-semibold text-emerald-400">
|
|
{restaurant.price}
|
|
</span>
|
|
|
|
{restaurant.distance && (
|
|
<div className="flex items-center gap-1 text-muted">
|
|
<MapPin size={13} />
|
|
<span className="text-xs">{restaurant.distance}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{restaurant.address && (
|
|
<p className="truncate text-xs leading-tight text-muted">
|
|
{restaurant.address}
|
|
</p>
|
|
)}
|
|
|
|
{restaurant.openTime && (
|
|
<div className="flex items-center gap-1 text-xs text-muted">
|
|
<Clock size={12} />
|
|
<span>{restaurant.openTime}</span>
|
|
</div>
|
|
)}
|
|
|
|
{restaurant.tag && (
|
|
<div className="flex gap-1.5 overflow-hidden">
|
|
{restaurant.tag
|
|
.split(",")
|
|
.slice(0, 3)
|
|
.map((t) => (
|
|
<span
|
|
key={t}
|
|
className="shrink-0 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-400"
|
|
>
|
|
{t.trim()}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|