feat: 添加分享结果卡片功能,形成用户增长闭环
- 新增 ShareCardModal 组件,支持餐厅匹配和盲盒契约两种分享卡片 - 卡片包含品牌标识、匹配结果、餐厅/想法详情、二维码 - 使用 html-to-image 生成高清 PNG,支持保存图片和 Web Share API 分享 - 餐厅图片通过 canvas 转 data URL 处理跨域 - 集成到 MatchResult(极速救场)和 BlindBox reveal(周末契约)
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user