feat: 接入全站图片资源 + 修复卡片滑动与房间轮询问题
图片资源接入: - OG/Twitter 社交分享元数据 (og-image.png) - 错误页插画替换图标 (error-robot.png) - EmptyState 组件支持 image prop,空状态页面接入插画 - 餐厅图片 fallback 改用 restaurant-fallback.png - 极速救场/周末契约页面添加 hero 装饰图 - 分享卡片添加背景图层 (share-bg-*.png),通过 base64 预加载 - 更新 App 图标 (apple-touch-icon, icon-192/512) Bug 修复: - SwipeDeck: swipe action 从 "nope" 改为 "pass",匹配 API 预期 - SwipeDeck: 用 ref 读取 currentIndex 避免竞态重置(本地滑动后 被服务端旧 swipeCounts 立即清零) - SwipeDeck: 卡片 key 加入 isTop 标识,强制 remount 解决 framer-motion drag 手势在 isTop 切换时不重新初始化的问题 - SwipeableCard: initial 统一为背景位置,确保晋升为顶部卡片时 有一致的放大动画 - useRoomPolling: roomId 为空时跳过 SWR 和 EventSource - room page: joinRoom 前 guard roomId,消除退房时 404 - layout: 添加 metadataBase 消除 Next.js OG 图片警告
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 393 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 975 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -193,6 +193,7 @@ export default function AchievementsPage() {
|
|||||||
) : decisions.length === 0 ? (
|
) : decisions.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
|
image="/empty-no-record.png"
|
||||||
title="还没有决策记录"
|
title="还没有决策记录"
|
||||||
subtitle="使用极速救场后会在这里记录"
|
subtitle="使用极速救场后会在这里记录"
|
||||||
color="amber"
|
color="amber"
|
||||||
@@ -254,6 +255,7 @@ export default function AchievementsPage() {
|
|||||||
) : contracts.length === 0 ? (
|
) : contracts.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
|
image="/empty-no-room.png"
|
||||||
title="还没有契约记录"
|
title="还没有契约记录"
|
||||||
subtitle="完成或过期的契约会在这里显示"
|
subtitle="完成或过期的契约会在这里显示"
|
||||||
color="purple"
|
color="purple"
|
||||||
|
|||||||
@@ -167,11 +167,20 @@ export default function BlindboxLobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.img
|
||||||
|
src="/blindbox-hero.png"
|
||||||
|
alt="周末契约"
|
||||||
|
className="mt-3 h-28 w-auto"
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.05 }}
|
||||||
|
/>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-muted"
|
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-muted"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.5, delay: 0.05 }}
|
transition={{ duration: 0.5, delay: 0.08 }}
|
||||||
>
|
>
|
||||||
平日蓄水,周末开奖。把所有"想做但一直没做"的事,交给命运来决定。
|
平日蓄水,周末开奖。把所有"想做但一直没做"的事,交给命运来决定。
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { AlertTriangle, RotateCcw, Home } from "lucide-react";
|
import { RotateCcw, Home } from "lucide-react";
|
||||||
import Button from "@/components/Button";
|
import Button from "@/components/Button";
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
@@ -23,14 +23,13 @@ export default function Error({
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.img
|
||||||
className="relative flex h-20 w-20 items-center justify-center"
|
src="/error-robot.png"
|
||||||
|
alt="错误"
|
||||||
|
className="h-28 w-28"
|
||||||
animate={{ y: [0, -4, 0] }}
|
animate={{ y: [0, -4, 0] }}
|
||||||
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
|
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
>
|
/>
|
||||||
<div className="absolute inset-0 rounded-2xl bg-rose-500/15 blur-lg" />
|
|
||||||
<AlertTriangle size={36} className="relative text-rose-400/80" strokeWidth={1.5} />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<h1 className="mt-6 text-xl font-bold text-heading">出了点问题</h1>
|
<h1 className="mt-6 text-xl font-bold text-heading">出了点问题</h1>
|
||||||
<p className="mt-2 max-w-xs text-sm text-muted">
|
<p className="mt-2 max-w-xs text-sm text-muted">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function GlobalError({
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif", background: "#0a0a0a", color: "#e5e5e5" }}>
|
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif", background: "#0a0a0a", color: "#e5e5e5" }}>
|
||||||
<div style={{ display: "flex", minHeight: "100dvh", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "1.5rem" }}>
|
<div style={{ display: "flex", minHeight: "100dvh", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: "1.5rem" }}>
|
||||||
<div style={{ fontSize: "3rem" }}>⚠️</div>
|
<img src="/error-robot.png" alt="错误" style={{ width: 120, height: 120 }} />
|
||||||
<h1 style={{ marginTop: "1.5rem", fontSize: "1.25rem", fontWeight: 700 }}>应用崩溃了</h1>
|
<h1 style={{ marginTop: "1.5rem", fontSize: "1.25rem", fontWeight: 700 }}>应用崩溃了</h1>
|
||||||
<p style={{ marginTop: "0.5rem", fontSize: "0.875rem", color: "#a3a3a3", textAlign: "center" }}>
|
<p style={{ marginTop: "0.5rem", fontSize: "0.875rem", color: "#a3a3a3", textAlign: "center" }}>
|
||||||
发生了严重错误,请尝试刷新页面
|
发生了严重错误,请尝试刷新页面
|
||||||
|
|||||||
@@ -12,9 +12,21 @@ const geistSans = Geist({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || "https://nowhatever.app"),
|
||||||
title: "NoWhatever — 别说随便",
|
title: "NoWhatever — 别说随便",
|
||||||
description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
|
description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
|
||||||
referrer: "no-referrer",
|
referrer: "no-referrer",
|
||||||
|
openGraph: {
|
||||||
|
title: "NoWhatever — 别说随便",
|
||||||
|
description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
|
||||||
|
images: [{ url: "/og-image.png", width: 1200, height: 630 }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "NoWhatever — 别说随便",
|
||||||
|
description: "像 Tinder 一样滑卡片,和朋友一起决定去哪吃!",
|
||||||
|
images: ["/og-image.png"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
@@ -214,11 +214,20 @@ export default function PanicPage() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.img
|
||||||
|
src="/panic-hero.png"
|
||||||
|
alt="极速救场"
|
||||||
|
className="mt-3 h-28 w-auto"
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.05 }}
|
||||||
|
/>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-muted"
|
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-muted"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.5, delay: 0.05 }}
|
transition={{ duration: 0.5, delay: 0.08 }}
|
||||||
>
|
>
|
||||||
{sceneConfig.subtitle}
|
{sceneConfig.subtitle}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function RoomPage() {
|
|||||||
} = useRoomPolling(roomId);
|
} = useRoomPolling(roomId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!roomId) return;
|
||||||
const id = getUserId();
|
const id = getUserId();
|
||||||
setUserId(id);
|
setUserId(id);
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ export interface PlanShareData {
|
|||||||
export default function BlindboxPlanShareCard({
|
export default function BlindboxPlanShareCard({
|
||||||
data,
|
data,
|
||||||
cardRef,
|
cardRef,
|
||||||
|
bgDataUrl,
|
||||||
}: {
|
}: {
|
||||||
data: PlanShareData;
|
data: PlanShareData;
|
||||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
bgDataUrl?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { days, roomName } = data;
|
const { days, roomName } = data;
|
||||||
const shareUrl =
|
const shareUrl =
|
||||||
@@ -38,6 +40,22 @@ export default function BlindboxPlanShareCard({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Background image */}
|
||||||
|
{bgDataUrl && (
|
||||||
|
<img
|
||||||
|
src={bgDataUrl}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
opacity: 0.12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Decorative glows */}
|
{/* Decorative glows */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ export interface BlindboxShareData {
|
|||||||
export default function BlindboxShareCard({
|
export default function BlindboxShareCard({
|
||||||
data,
|
data,
|
||||||
cardRef,
|
cardRef,
|
||||||
|
bgDataUrl,
|
||||||
}: {
|
}: {
|
||||||
data: BlindboxShareData;
|
data: BlindboxShareData;
|
||||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
bgDataUrl?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { idea, submitter, drawer, roomName } = data;
|
const { idea, submitter, drawer, roomName } = data;
|
||||||
const shareUrl =
|
const shareUrl =
|
||||||
@@ -39,6 +41,22 @@ export default function BlindboxShareCard({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Background image */}
|
||||||
|
{bgDataUrl && (
|
||||||
|
<img
|
||||||
|
src={bgDataUrl}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
opacity: 0.12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Decorative glows */}
|
{/* Decorative glows */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
image?: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
ctaLabel?: string;
|
ctaLabel?: string;
|
||||||
@@ -14,6 +15,7 @@ interface EmptyStateProps {
|
|||||||
|
|
||||||
export default function EmptyState({
|
export default function EmptyState({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
|
image,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
ctaLabel,
|
ctaLabel,
|
||||||
@@ -30,14 +32,24 @@ export default function EmptyState({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center py-6">
|
<div className="flex flex-col items-center py-6">
|
||||||
<motion.div
|
{image ? (
|
||||||
className="relative flex h-14 w-14 items-center justify-center"
|
<motion.img
|
||||||
animate={{ y: [0, -3, 0] }}
|
src={image}
|
||||||
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
|
alt={title}
|
||||||
>
|
className="h-20 w-20"
|
||||||
<div className={`absolute inset-0 rounded-xl ${c.glow} blur-md`} />
|
animate={{ y: [0, -3, 0] }}
|
||||||
<Icon size={24} className={`relative ${c.icon}`} strokeWidth={1.5} />
|
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
</motion.div>
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
className="relative flex h-14 w-14 items-center justify-center"
|
||||||
|
animate={{ y: [0, -3, 0] }}
|
||||||
|
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<div className={`absolute inset-0 rounded-xl ${c.glow} blur-md`} />
|
||||||
|
<Icon size={24} className={`relative ${c.icon}`} strokeWidth={1.5} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="mt-3 text-sm font-semibold text-secondary">{title}</p>
|
<p className="mt-3 text-sm font-semibold text-secondary">{title}</p>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export default function ProfileFavoritesCard({
|
|||||||
) : favorites.length === 0 ? (
|
) : favorites.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Heart}
|
icon={Heart}
|
||||||
|
image="/empty-no-record.png"
|
||||||
title="还没有收藏的餐厅"
|
title="还没有收藏的餐厅"
|
||||||
subtitle="在匹配结果中收藏喜欢的店"
|
subtitle="在匹配结果中收藏喜欢的店"
|
||||||
ctaLabel="去创建第一个房间"
|
ctaLabel="去创建第一个房间"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export default function ProfileHistoryCard({
|
|||||||
) : history.length === 0 ? (
|
) : history.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
|
image="/empty-no-record.png"
|
||||||
title="还没有决策记录"
|
title="还没有决策记录"
|
||||||
subtitle="创建房间开始一起选餐厅"
|
subtitle="创建房间开始一起选餐厅"
|
||||||
ctaLabel="去创建第一个房间"
|
ctaLabel="去创建第一个房间"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { UtensilsCrossed } from "lucide-react";
|
|
||||||
|
|
||||||
interface RestaurantImageProps {
|
interface RestaurantImageProps {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -30,7 +29,12 @@ export default function RestaurantImage({
|
|||||||
className={`flex items-center justify-center bg-elevated ${className}`}
|
className={`flex items-center justify-center bg-elevated ${className}`}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<UtensilsCrossed className="h-1/3 w-1/3 max-h-8 max-w-8 text-muted/40" strokeWidth={1.5} />
|
<img
|
||||||
|
src="/restaurant-fallback.png"
|
||||||
|
alt={alt}
|
||||||
|
className="h-1/2 w-1/2 max-h-20 max-w-20 object-contain opacity-60"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ export default function RestaurantShareCard({
|
|||||||
data,
|
data,
|
||||||
cardRef,
|
cardRef,
|
||||||
imageDataUrl,
|
imageDataUrl,
|
||||||
|
bgDataUrl,
|
||||||
}: {
|
}: {
|
||||||
data: RestaurantShareData;
|
data: RestaurantShareData;
|
||||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||||
imageDataUrl: string | null;
|
imageDataUrl: string | null;
|
||||||
|
bgDataUrl?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { restaurant, matchType, matchLikes, userCount, scene } = data;
|
const { restaurant, matchType, matchLikes, userCount, scene } = data;
|
||||||
const isUnanimous = matchType === "unanimous";
|
const isUnanimous = matchType === "unanimous";
|
||||||
@@ -53,6 +55,22 @@ export default function RestaurantShareCard({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Background image */}
|
||||||
|
{bgDataUrl && (
|
||||||
|
<img
|
||||||
|
src={bgDataUrl}
|
||||||
|
alt=""
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
opacity: 0.12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Decorative glows */}
|
{/* Decorative glows */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -41,20 +41,41 @@ export default function ShareCardModal({
|
|||||||
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
|
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
|
||||||
const [imageLoading, setImageLoading] = useState(false);
|
const [imageLoading, setImageLoading] = useState(false);
|
||||||
|
|
||||||
|
const [bgDataUrl, setBgDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const imageSrc = data.type === "restaurant" ? data.restaurant.images?.[0] : undefined;
|
const imageSrc = data.type === "restaurant" ? data.restaurant.images?.[0] : undefined;
|
||||||
|
const bgSrc =
|
||||||
|
data.type === "restaurant"
|
||||||
|
? "/share-bg-panic.png"
|
||||||
|
: data.type === "blindbox" || data.type === "plan"
|
||||||
|
? "/share-bg-blindbox.png"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setImageDataUrl(null);
|
setImageDataUrl(null);
|
||||||
|
setBgDataUrl(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!imageSrc) return;
|
|
||||||
|
|
||||||
setImageLoading(true);
|
const promises: Promise<void>[] = [];
|
||||||
loadImageAsDataUrl(imageSrc)
|
|
||||||
.then(setImageDataUrl)
|
if (imageSrc) {
|
||||||
.finally(() => setImageLoading(false));
|
promises.push(
|
||||||
}, [open, imageSrc]);
|
loadImageAsDataUrl(imageSrc).then(setImageDataUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bgSrc) {
|
||||||
|
promises.push(
|
||||||
|
loadImageAsDataUrl(bgSrc).then(setBgDataUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promises.length > 0) {
|
||||||
|
setImageLoading(true);
|
||||||
|
Promise.all(promises).finally(() => setImageLoading(false));
|
||||||
|
}
|
||||||
|
}, [open, imageSrc, bgSrc]);
|
||||||
|
|
||||||
const handleGenerate = useCallback(async (): Promise<string | null> => {
|
const handleGenerate = useCallback(async (): Promise<string | null> => {
|
||||||
if (!cardRef.current) return null;
|
if (!cardRef.current) return null;
|
||||||
@@ -153,11 +174,12 @@ export default function ShareCardModal({
|
|||||||
data={data}
|
data={data}
|
||||||
cardRef={cardRef}
|
cardRef={cardRef}
|
||||||
imageDataUrl={imageDataUrl}
|
imageDataUrl={imageDataUrl}
|
||||||
|
bgDataUrl={bgDataUrl}
|
||||||
/>
|
/>
|
||||||
) : data.type === "plan" ? (
|
) : data.type === "plan" ? (
|
||||||
<BlindboxPlanShareCard data={data} cardRef={cardRef} />
|
<BlindboxPlanShareCard data={data} cardRef={cardRef} bgDataUrl={bgDataUrl} />
|
||||||
) : (
|
) : (
|
||||||
<BlindboxShareCard data={data} cardRef={cardRef} />
|
<BlindboxShareCard data={data} cardRef={cardRef} bgDataUrl={bgDataUrl} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default function SwipeDeck({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendSwipe = async (restaurantId: string, action: "like" | "nope") => {
|
const sendSwipe = async (restaurantId: string, action: "like" | "pass") => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/room/${roomId}/swipe`, {
|
const res = await fetch(`/api/room/${roomId}/swipe`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -240,7 +240,7 @@ export default function SwipeDeck({
|
|||||||
swipingRef.current = false;
|
swipingRef.current = false;
|
||||||
if (guideVisible) setGuideVisible(false);
|
if (guideVisible) setGuideVisible(false);
|
||||||
|
|
||||||
const action = direction === "right" ? "like" : "nope";
|
const action = direction === "right" ? "like" : "pass";
|
||||||
sendSwipe(current.id, action);
|
sendSwipe(current.id, action);
|
||||||
|
|
||||||
setSwipeHistory((h) => [...h, current.id]);
|
setSwipeHistory((h) => [...h, current.id]);
|
||||||
@@ -291,12 +291,16 @@ export default function SwipeDeck({
|
|||||||
prevLikeCounts.current = {};
|
prevLikeCounts.current = {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const currentIndexRef = useRef(currentIndex);
|
||||||
|
currentIndexRef.current = currentIndex;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const serverIndex = swipeCounts[userId] ?? 0;
|
const serverIndex = swipeCounts[userId] ?? 0;
|
||||||
if (serverIndex === 0 && currentIndex > 0 && !resetting) {
|
if (serverIndex === 0 && currentIndexRef.current > 0 && !resetting) {
|
||||||
clearLocalState();
|
clearLocalState();
|
||||||
}
|
}
|
||||||
}, [swipeCounts, userId, currentIndex, resetting, clearLocalState]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [swipeCounts, userId, resetting, clearLocalState]);
|
||||||
|
|
||||||
const handleReset = useCallback(async () => {
|
const handleReset = useCallback(async () => {
|
||||||
setResetting(true);
|
setResetting(true);
|
||||||
@@ -354,7 +358,7 @@ export default function SwipeDeck({
|
|||||||
const isTop = index === currentIndex;
|
const isTop = index === currentIndex;
|
||||||
return (
|
return (
|
||||||
<SwipeableCard
|
<SwipeableCard
|
||||||
key={restaurant.id}
|
key={`${restaurant.id}-${isTop ? "top" : "bg"}`}
|
||||||
restaurant={restaurant}
|
restaurant={restaurant}
|
||||||
isTop={isTop}
|
isTop={isTop}
|
||||||
onSwipe={handleSwipe}
|
onSwipe={handleSwipe}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function SwipeableCard({
|
|||||||
dragElastic={0.9}
|
dragElastic={0.9}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
whileDrag={{ cursor: "grabbing" }}
|
whileDrag={{ cursor: "grabbing" }}
|
||||||
initial={isTop ? { scale: 1 } : { scale: 0.95, y: 16 }}
|
initial={{ scale: 0.95, y: 16 }}
|
||||||
animate={isTop ? { scale: 1, y: 0 } : { scale: 0.95, y: 16 }}
|
animate={isTop ? { scale: 1, y: 0 } : { scale: 0.95, y: 16 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ async function fetcher(url: string) {
|
|||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRoomPolling(roomId: string) {
|
export function useRoomPolling(roomId: string | undefined) {
|
||||||
const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
|
const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
|
||||||
`/api/room/${roomId}`,
|
roomId ? `/api/room/${roomId}` : null,
|
||||||
fetcher,
|
fetcher,
|
||||||
{
|
{
|
||||||
revalidateOnFocus: true,
|
revalidateOnFocus: true,
|
||||||
@@ -27,6 +27,7 @@ export function useRoomPolling(roomId: string) {
|
|||||||
const notFound = error?.message === "NOT_FOUND";
|
const notFound = error?.message === "NOT_FOUND";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!roomId) return;
|
||||||
const es = new EventSource(`/api/room/${roomId}/events`);
|
const es = new EventSource(`/api/room/${roomId}/events`);
|
||||||
|
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
|
|||||||