Files
no-whatever/src/components/RestaurantCard.tsx
T
kurihada 37eb7f07d7 ui: 滑卡互动增强 — like 徽章动画、分类标签可读性、进度数字区分
- RestaurantCard: like 徽章实时弹跳动画,首次出现滑入效果
- RestaurantCard: 分类标签改为白底黑字,图片上始终清晰
- SwipeDeck: 进度数字加背景色块包裹,与用户名视觉分离
2026-02-26 16:32:00 +08:00

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>
);
}