Files
no-whatever/src/components/ShareCardModal.tsx
T
kurihada c9e20d4c95 refactor: 提取 useShare hook,统一分享和剪贴板逻辑
新增 useShare(Web Share API + clipboard fallback),
消除 QrInviteModal、BlindboxRoomPage、ShareCardModal 三处重复的分享代码。
2026-02-26 19:25:44 +08:00

946 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
import { useToast } from "@/hooks/useToast";
import { useShare } from "@/hooks/useShare";
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;
}
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,
}: ShareCardModalProps) {
const toast = useToast();
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) {
toast.show("生成图片失败,请重试");
return;
}
const name =
data.type === "restaurant"
? `NoWhatever_${data.restaurant.name}.png`
: `NoWhatever_周末契约.png`;
downloadDataUrl(png, name);
toast.show("图片已保存");
}, [handleGenerate, toast, data]);
const { share: nativeShare } = useShare();
const handleShare = useCallback(async () => {
const png = await handleGenerate();
if (!png) {
toast.show("生成图片失败,请重试");
return;
}
const file = dataUrlToFile(png, "NoWhatever.png");
const shared = await nativeShare({ files: [file] });
if (!shared) {
downloadDataUrl(png, "NoWhatever.png");
toast.show("图片已保存,快去分享吧!");
}
}, [handleGenerate, toast, nativeShare]);
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" }}
>
{/* Card + close button wrapper */}
<div className="relative flex shrink-0 justify-center">
<button
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/70 transition-colors active:bg-black/60"
>
<X size={16} />
</button>
{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>
);
}