feat: 添加分享结果卡片功能,形成用户增长闭环

- 新增 ShareCardModal 组件,支持餐厅匹配和盲盒契约两种分享卡片
- 卡片包含品牌标识、匹配结果、餐厅/想法详情、二维码
- 使用 html-to-image 生成高清 PNG,支持保存图片和 Web Share API 分享
- 餐厅图片通过 canvas 转 data URL 处理跨域
- 集成到 MatchResult(极速救场)和 BlindBox reveal(周末契约)
This commit is contained in:
2026-02-26 13:50:38 +08:00
parent 14b0aaece4
commit 08eb55ca41
5 changed files with 1018 additions and 49 deletions
+7
View File
@@ -12,6 +12,7 @@
"bcryptjs": "^3.0.3",
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.34.3",
"html-to-image": "^1.11.13",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"prisma": "^6.19.2",
@@ -4260,6 +4261,12 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+1
View File
@@ -13,6 +13,7 @@
"bcryptjs": "^3.0.3",
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.34.3",
"html-to-image": "^1.11.13",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"prisma": "^6.19.2",
+31 -1
View File
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import confetti from "canvas-confetti";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
import type { UserProfile } from "@/types";
interface RoomInfo {
@@ -57,6 +58,7 @@ export default function BlindboxRoomPage() {
const [submitFlash, setSubmitFlash] = useState(false);
const [error, setError] = useState("");
const [showInvite, setShowInvite] = useState(false);
const [showShareCard, setShowShareCard] = useState(false);
const [toast, setToast] = useState("");
const boxControls = useAnimation();
@@ -522,13 +524,23 @@ export default function BlindboxRoomPage() {
</div>
</div>
<div className="flex items-center gap-3">
<motion.button
onClick={() => { setPhase("pool"); setRevealedIdea(null); }}
onClick={() => setShowShareCard(true)}
className="flex h-10 items-center gap-2 rounded-full bg-purple-600 px-5 text-xs font-bold text-white shadow-lg shadow-purple-900/30 transition-colors hover:bg-purple-500"
whileTap={{ scale: 0.96 }}
>
<Share2 size={14} />
</motion.button>
<motion.button
onClick={() => { setPhase("pool"); setRevealedIdea(null); setShowShareCard(false); }}
className="flex h-10 items-center gap-2 rounded-full bg-surface px-5 text-xs font-semibold text-muted ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.96 }}
>
</motion.button>
</div>
</motion.div>
)}
</AnimatePresence>
@@ -590,6 +602,24 @@ export default function BlindboxRoomPage() {
</>
)}
{revealedIdea && room && (
<ShareCardModal
open={showShareCard}
onClose={() => setShowShareCard(false)}
data={{
type: "blindbox",
idea: revealedIdea.content,
submitter: revealedIdea.user ?? undefined,
drawer: revealedIdea.drawnBy ?? undefined,
roomName: room.name,
}}
onToast={(msg) => {
setToast(msg);
setTimeout(() => setToast(""), 2200);
}}
/>
)}
{/* Toast */}
<AnimatePresence>
{toast && (
+21 -42
View File
@@ -23,6 +23,7 @@ import {
import { Restaurant, MatchType, RunnerUp, SceneType } from "@/types";
import { fireCelebration, playChime } from "@/lib/celebrate";
import { isRegistered } from "@/lib/userId";
import ShareCardModal from "@/components/ShareCardModal";
interface MatchResultProps {
restaurant: Restaurant;
@@ -183,6 +184,7 @@ export default function MatchResult({
}: MatchResultProps) {
const router = useRouter();
const [showRunnerUps, setShowRunnerUps] = useState(false);
const [showShareCard, setShowShareCard] = useState(false);
const [toast, setToast] = useState("");
const celebratedRef = useRef(false);
const historySavedRef = useRef(false);
@@ -223,46 +225,9 @@ export default function MatchResult({
}).catch(() => {});
}, [userId, roomId, restaurant, matchType, userCount]);
const handleShare = useCallback(async () => {
const verb = scene === "drink" ? "喝" : "吃";
const lines = [
isUnanimous
? `🎉 默契度 100%${userCount} 人全员一致选了同一家!`
: `🎉 我们用 NoWhatever 选好了去哪${verb}`,
``,
`📍 ${restaurant.name}`,
restaurant.rating ? `${restaurant.rating}` : "",
restaurant.price && restaurant.price !== "未知" ? `💰 人均${restaurant.price}` : "",
restaurant.address ? `📮 ${restaurant.address}` : "",
``,
isUnanimous ? `✨ 这就是心有灵犀吧~` : "",
].filter(Boolean);
const text = lines.join("\n");
const navUrl = buildNavUrl(restaurant);
const shareData = {
title: `我们选了${restaurant.name}`,
text,
url: navUrl,
};
try {
if (navigator.share && navigator.canShare?.(shareData)) {
await navigator.share(shareData);
return;
}
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
}
try {
await navigator.clipboard.writeText(`${text}\n\n${navUrl}`);
showToast("已复制,快去发给朋友吧!");
} catch {
showToast("复制失败,请手动复制");
}
}, [restaurant, showToast, isUnanimous, userCount, scene]);
const handleOpenShareCard = useCallback(() => {
setShowShareCard(true);
}, []);
if (matchType === "no_match") {
return <NoMatchResult onReset={onReset} resetting={resetting} />;
@@ -451,12 +416,12 @@ export default function MatchResult({
)}
<motion.button
onClick={handleShare}
onClick={handleOpenShareCard}
className="flex items-center justify-center gap-2 rounded-full bg-surface px-8 py-3 text-sm font-bold text-gray-300 ring-1 ring-border transition-colors hover:bg-elevated"
whileTap={{ scale: 0.95 }}
>
<Share2 size={15} />
</motion.button>
</motion.div>
@@ -557,6 +522,20 @@ export default function MatchResult({
</motion.div>
</div>
<ShareCardModal
open={showShareCard}
onClose={() => setShowShareCard(false)}
data={{
type: "restaurant",
restaurant,
matchType,
matchLikes,
userCount,
scene,
}}
onToast={showToast}
/>
<AnimatePresence>
{toast && (
<motion.div
+952
View File
@@ -0,0 +1,952 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Download, Share2, Loader2, Star, MapPin, Zap } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { toPng } from "html-to-image";
import type { Restaurant, MatchType, SceneType } from "@/types";
type ShareCardData =
| {
type: "restaurant";
restaurant: Restaurant;
matchType: MatchType;
matchLikes: number;
userCount: number;
scene?: SceneType;
}
| {
type: "blindbox";
idea: string;
submitter?: { avatar: string; username: string };
drawer?: { avatar: string; username: string };
roomName: string;
};
interface ShareCardModalProps {
open: boolean;
onClose: () => void;
data: ShareCardData;
onToast?: (msg: string) => void;
}
async function loadImageAsDataUrl(src: string): Promise<string | null> {
try {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = src;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject();
});
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0);
return canvas.toDataURL("image/jpeg", 0.85);
} catch {
return null;
}
}
async function generateImage(el: HTMLElement): Promise<string> {
return toPng(el, {
pixelRatio: 2,
quality: 0.95,
cacheBust: true,
skipAutoScale: true,
filter: (node) => {
if (node instanceof HTMLElement && node.dataset.shareExclude === "true") {
return false;
}
return true;
},
});
}
function downloadDataUrl(dataUrl: string, filename: string) {
const link = document.createElement("a");
link.download = filename;
link.href = dataUrl;
link.click();
}
function dataUrlToFile(dataUrl: string, filename: string): File {
const arr = dataUrl.split(",");
const mime = arr[0].match(/:(.*?);/)?.[1] || "image/png";
const bstr = atob(arr[1]);
const u8 = new Uint8Array(bstr.length);
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i);
return new File([u8], filename, { type: mime });
}
function RestaurantShareCard({
data,
cardRef,
imageDataUrl,
}: {
data: Extract<ShareCardData, { type: "restaurant" }>;
cardRef: React.RefObject<HTMLDivElement | null>;
imageDataUrl: string | null;
}) {
const { restaurant, matchType, matchLikes, userCount, scene } = data;
const isUnanimous = matchType === "unanimous";
const verb = scene === "drink" ? "喝" : "吃";
const shareUrl =
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
const accentFrom = isUnanimous ? "#059669" : "#b45309";
const accentTo = isUnanimous ? "#34d399" : "#fbbf24";
const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d";
const accentBg = isUnanimous
? "rgba(16, 185, 129, 0.12)"
: "rgba(245, 158, 11, 0.12)";
return (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: `linear-gradient(160deg, ${accentFrom}, ${accentTo}40, ${accentFrom}30)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
borderRadius: 18.5,
background: "#08080a",
position: "relative",
overflow: "hidden",
}}
>
{/* Decorative glows */}
<div
style={{
position: "absolute",
top: -40,
right: -30,
width: 160,
height: 160,
borderRadius: "50%",
background: `radial-gradient(circle, ${accentFrom}25, transparent 70%)`,
}}
/>
<div
style={{
position: "absolute",
bottom: 60,
left: -40,
width: 120,
height: 120,
borderRadius: "50%",
background: `radial-gradient(circle, ${accentTo}12, transparent 70%)`,
}}
/>
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "relative",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 18 }}></span>
<div>
<div
style={{
fontSize: 13,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
}}
>
NoWhatever
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.35)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
便 · PANIC MODE
</div>
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`,
}}
/>
{/* Hero section */}
<div
style={{
padding: "24px 20px 20px",
textAlign: "center",
position: "relative",
}}
>
<div style={{ fontSize: 40, lineHeight: 1 }}>
{isUnanimous ? "🎉" : "🏆"}
</div>
<div
style={{
fontSize: 30,
fontWeight: 900,
color: "#ffffff",
marginTop: 12,
letterSpacing: "0.08em",
}}
>
{verb}
</div>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
marginTop: 12,
padding: "6px 16px",
borderRadius: 100,
background: accentBg,
border: `1px solid ${accentTo}20`,
}}
>
{isUnanimous && (
<Zap
size={12}
style={{ color: accentText, fill: accentText }}
/>
)}
<span
style={{
fontSize: 11,
fontWeight: 700,
color: accentText,
}}
>
{isUnanimous
? `默契度 100% · ${userCount}人全员一致`
: `${matchLikes}/${userCount} 人选了这家`}
</span>
{isUnanimous && (
<Zap
size={12}
style={{ color: accentText, fill: accentText }}
/>
)}
</div>
</div>
{/* Restaurant card */}
<div style={{ padding: "0 16px 16px" }}>
<div
style={{
borderRadius: 14,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.06)",
overflow: "hidden",
}}
>
{imageDataUrl && (
<img
src={imageDataUrl}
alt={restaurant.name}
style={{
width: "100%",
height: 150,
objectFit: "cover",
display: "block",
}}
/>
)}
<div style={{ padding: "14px 16px 16px" }}>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div
style={{
fontSize: 17,
fontWeight: 800,
color: "#ffffff",
lineHeight: 1.3,
}}
>
{restaurant.name}
</div>
{restaurant.category && (
<span
style={{
flexShrink: 0,
padding: "3px 8px",
borderRadius: 100,
background: accentBg,
fontSize: 10,
fontWeight: 600,
color: accentText,
whiteSpace: "nowrap",
}}
>
{restaurant.category}
</span>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 10,
fontSize: 12,
}}
>
{restaurant.rating > 0 && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 3,
color: "#e5e7eb",
fontWeight: 600,
}}
>
<Star
size={13}
style={{ color: "#fbbf24", fill: "#fbbf24" }}
/>
{restaurant.rating}
</span>
)}
{restaurant.price && restaurant.price !== "未知" && (
<span style={{ fontWeight: 700, color: accentText }}>
{restaurant.price}
</span>
)}
{restaurant.distance && (
<span
style={{
display: "flex",
alignItems: "center",
gap: 3,
color: "#9ca3af",
}}
>
<MapPin size={12} style={{ color: "#6b7280" }} />
{restaurant.distance}
</span>
)}
</div>
{restaurant.address && (
<div
style={{
marginTop: 8,
fontSize: 11,
color: "#6b7280",
lineHeight: 1.5,
}}
>
📍 {restaurant.address}
</div>
)}
{restaurant.tag && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
marginTop: 10,
}}
>
{restaurant.tag
.split(",")
.slice(0, 4)
.map((t) => (
<span
key={t}
style={{
padding: "2px 8px",
borderRadius: 4,
background: "rgba(255,255,255,0.05)",
fontSize: 10,
fontWeight: 500,
color: "#9ca3af",
}}
>
{t.trim()}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor="#08080a"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
}}
>
便
</div>
<div
style={{
fontSize: 10,
color: "rgba(255,255,255,0.25)",
marginTop: 3,
}}
>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
</div>
</div>
</div>
</div>
);
}
function BlindboxShareCard({
data,
cardRef,
}: {
data: Extract<ShareCardData, { type: "blindbox" }>;
cardRef: React.RefObject<HTMLDivElement | null>;
}) {
const { idea, submitter, drawer, roomName } = data;
const shareUrl =
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
return (
<div
ref={cardRef}
style={{
width: 340,
padding: 1.5,
borderRadius: 20,
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
borderRadius: 18.5,
background: "#0a0810",
position: "relative",
overflow: "hidden",
}}
>
{/* Decorative glows */}
<div
style={{
position: "absolute",
top: -30,
right: -20,
width: 140,
height: 140,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
}}
/>
<div
style={{
position: "absolute",
bottom: 80,
left: -30,
width: 120,
height: 120,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(99,102,241,0.12), transparent 70%)",
}}
/>
{/* Brand header */}
<div
style={{
padding: "14px 20px 12px",
display: "flex",
alignItems: "center",
gap: 8,
position: "relative",
}}
>
<span style={{ fontSize: 18 }}>🎁</span>
<div>
<div
style={{
fontSize: 13,
fontWeight: 800,
color: "#ffffff",
letterSpacing: "0.02em",
}}
>
NoWhatever
</div>
<div
style={{
fontSize: 9,
fontWeight: 600,
color: "rgba(255,255,255,0.3)",
letterSpacing: "0.15em",
marginTop: 1,
}}
>
便 · ADVENTURE ROULETTE
</div>
</div>
</div>
{/* Thin accent line */}
<div
style={{
height: 1,
margin: "0 20px",
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
}}
/>
{/* Room name badge */}
<div
style={{
textAlign: "center",
padding: "20px 20px 8px",
position: "relative",
}}
>
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.25em",
color: "rgba(167,139,250,0.5)",
}}
>
{roomName}
</div>
</div>
{/* Idea card */}
<div style={{ padding: "0 16px 20px" }}>
<div
style={{
borderRadius: 14,
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(167,139,250,0.1)",
padding: "28px 24px",
position: "relative",
}}
>
{/* Corner decorations */}
<div
style={{
position: "absolute",
top: 10,
left: 10,
width: 14,
height: 14,
borderLeft: "2px solid rgba(167,139,250,0.25)",
borderTop: "2px solid rgba(167,139,250,0.25)",
borderTopLeftRadius: 3,
}}
/>
<div
style={{
position: "absolute",
top: 10,
right: 10,
width: 14,
height: 14,
borderRight: "2px solid rgba(167,139,250,0.25)",
borderTop: "2px solid rgba(167,139,250,0.25)",
borderTopRightRadius: 3,
}}
/>
<div
style={{
position: "absolute",
bottom: 10,
left: 10,
width: 14,
height: 14,
borderLeft: "2px solid rgba(167,139,250,0.25)",
borderBottom: "2px solid rgba(167,139,250,0.25)",
borderBottomLeftRadius: 3,
}}
/>
<div
style={{
position: "absolute",
bottom: 10,
right: 10,
width: 14,
height: 14,
borderRight: "2px solid rgba(167,139,250,0.25)",
borderBottom: "2px solid rgba(167,139,250,0.25)",
borderBottomRightRadius: 3,
}}
/>
<div
style={{
fontSize: 22,
fontWeight: 900,
color: "#ffffff",
textAlign: "center",
lineHeight: 1.6,
letterSpacing: "0.02em",
}}
>
{idea}
</div>
<div
style={{
width: 48,
height: 1,
margin: "16px auto",
background:
"linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
}}
/>
<div
style={{
textAlign: "center",
fontSize: 10,
fontWeight: 500,
color: "rgba(167,139,250,0.35)",
}}
>
</div>
</div>
</div>
{/* Attribution */}
{(submitter || drawer) && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "0 20px 16px",
}}
>
{submitter && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 11,
color: "rgba(196,181,253,0.4)",
}}
>
<span style={{ fontSize: 14 }}>{submitter.avatar}</span>
<span>{submitter.username} </span>
</div>
)}
{submitter && drawer && (
<span style={{ color: "rgba(196,181,253,0.2)" }}>·</span>
)}
{drawer && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 11,
color: "rgba(196,181,253,0.4)",
}}
>
<span style={{ fontSize: 14 }}>{drawer.avatar}</span>
<span>{drawer.username} </span>
</div>
)}
</div>
)}
{/* QR footer */}
<div
style={{
padding: "14px 20px 16px",
display: "flex",
alignItems: "center",
gap: 14,
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<div
style={{
padding: 5,
borderRadius: 8,
background: "#ffffff",
flexShrink: 0,
}}
>
<QRCodeSVG
value={shareUrl}
size={52}
level="M"
bgColor="#ffffff"
fgColor="#0a0810"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
}}
>
便
</div>
<div
style={{
fontSize: 10,
color: "rgba(255,255,255,0.2)",
marginTop: 3,
}}
>
{shareUrl.replace(/^https?:\/\//, "")}
</div>
</div>
</div>
</div>
</div>
);
}
export default function ShareCardModal({
open,
onClose,
data,
onToast,
}: ShareCardModalProps) {
const cardRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const [generating, setGenerating] = useState(false);
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
const [imageLoading, setImageLoading] = useState(false);
useEffect(() => {
if (!open) {
setImageDataUrl(null);
return;
}
if (data.type !== "restaurant") return;
const src = data.restaurant.images?.[0];
if (!src) return;
setImageLoading(true);
loadImageAsDataUrl(src)
.then(setImageDataUrl)
.finally(() => setImageLoading(false));
}, [open, data]);
const handleGenerate = useCallback(async (): Promise<string | null> => {
if (!cardRef.current) return null;
setGenerating(true);
try {
return await generateImage(cardRef.current);
} catch {
try {
setImageDataUrl(null);
await new Promise((r) => setTimeout(r, 100));
if (!cardRef.current) return null;
return await generateImage(cardRef.current);
} catch {
return null;
}
} finally {
setGenerating(false);
}
}, []);
const handleSave = useCallback(async () => {
const png = await handleGenerate();
if (!png) {
onToast?.("生成图片失败,请重试");
return;
}
const name =
data.type === "restaurant"
? `NoWhatever_${data.restaurant.name}.png`
: `NoWhatever_周末契约.png`;
downloadDataUrl(png, name);
onToast?.("图片已保存");
}, [handleGenerate, onToast, data]);
const handleShare = useCallback(async () => {
const png = await handleGenerate();
if (!png) {
onToast?.("生成图片失败,请重试");
return;
}
const file = dataUrlToFile(png, "NoWhatever.png");
const shareData: ShareData = { files: [file] };
try {
if (navigator.canShare?.(shareData)) {
await navigator.share(shareData);
return;
}
} catch (e) {
if (e instanceof Error && e.name === "AbortError") return;
}
downloadDataUrl(png, "NoWhatever.png");
onToast?.("图片已保存,快去分享吧!");
}, [handleGenerate, onToast]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-70 overflow-y-auto overflow-x-hidden bg-black/80 backdrop-blur-sm scrollbar-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={handleBackdropClick}
>
<motion.div
className="flex min-h-dvh flex-col items-center justify-center px-5 py-6"
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
{/* Close button (floating) */}
<button
onClick={onClose}
className="mb-3 flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-full bg-white/10 text-gray-300 transition-colors active:bg-white/20"
>
<X size={18} />
</button>
{/* Card */}
<div className="flex shrink-0 justify-center">
{imageLoading ? (
<div
style={{ width: 340, height: 400 }}
className="flex items-center justify-center rounded-2xl bg-[#111113]"
>
<Loader2
size={24}
className="animate-spin text-gray-500"
/>
</div>
) : data.type === "restaurant" ? (
<RestaurantShareCard
data={data}
cardRef={cardRef}
imageDataUrl={imageDataUrl}
/>
) : (
<BlindboxShareCard data={data} cardRef={cardRef} />
)}
</div>
{/* Action buttons */}
<div className="mt-5 flex w-full max-w-[340px] shrink-0 gap-3">
<button
onClick={handleSave}
disabled={generating}
className="flex h-12 flex-1 items-center justify-center gap-2 rounded-2xl bg-white/10 text-sm font-semibold text-gray-200 transition-colors active:bg-white/15 disabled:opacity-50"
>
{generating ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
</button>
<button
onClick={handleShare}
disabled={generating}
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-2xl text-sm font-bold text-white shadow-lg transition-colors disabled:opacity-50 ${
data.type === "blindbox"
? "bg-purple-600 shadow-purple-900/30 active:bg-purple-500"
: "bg-emerald-600 shadow-emerald-900/30 active:bg-emerald-500"
}`}
>
{generating ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Share2 size={16} />
)}
</button>
</div>
<p className="mt-3 shrink-0 text-center text-[10px] text-gray-500">
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}