diff --git a/package-lock.json b/package-lock.json index ec8835e..664b278 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8db0243..11715c8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index 1076222..f0938aa 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -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() { - { setPhase("pool"); setRevealedIdea(null); }} - 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 }} - > - 继续投入想法 - +
+ 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 }} + > + + 分享契约 + + { 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 }} + > + 继续投入想法 + +
)} @@ -590,6 +602,24 @@ export default function BlindboxRoomPage() { )} + {revealedIdea && room && ( + 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 */} {toast && ( diff --git a/src/components/MatchResult.tsx b/src/components/MatchResult.tsx index deb561d..2b0ccbe 100644 --- a/src/components/MatchResult.tsx +++ b/src/components/MatchResult.tsx @@ -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 ; @@ -451,12 +416,12 @@ export default function MatchResult({ )} - 分享结果到群里 + 生成分享卡片 @@ -557,6 +522,20 @@ export default function MatchResult({ + setShowShareCard(false)} + data={{ + type: "restaurant", + restaurant, + matchType, + matchLikes, + userCount, + scene, + }} + onToast={showToast} + /> + {toast && ( void; + data: ShareCardData; + onToast?: (msg: string) => void; +} + +async function loadImageAsDataUrl(src: string): Promise { + try { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = src; + await new Promise((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 { + 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; + cardRef: React.RefObject; + 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 ( +
+
+ {/* Decorative glows */} +
+
+ + {/* Brand header */} +
+
+ +
+
+ NoWhatever +
+
+ 别说随便 · PANIC MODE +
+
+
+
+ + {/* Thin accent line */} +
+ + {/* Hero section */} +
+
+ {isUnanimous ? "🎉" : "🏆"} +
+
+ 就去这{verb}! +
+
+ {isUnanimous && ( + + )} + + {isUnanimous + ? `默契度 100% · ${userCount}人全员一致` + : `${matchLikes}/${userCount} 人选了这家`} + + {isUnanimous && ( + + )} +
+
+ + {/* Restaurant card */} +
+
+ {imageDataUrl && ( + {restaurant.name} + )} +
+
+
+ {restaurant.name} +
+ {restaurant.category && ( + + {restaurant.category} + + )} +
+ +
+ {restaurant.rating > 0 && ( + + + {restaurant.rating} + + )} + {restaurant.price && restaurant.price !== "未知" && ( + + {restaurant.price} + + )} + {restaurant.distance && ( + + + {restaurant.distance} + + )} +
+ + {restaurant.address && ( +
+ 📍 {restaurant.address} +
+ )} + + {restaurant.tag && ( +
+ {restaurant.tag + .split(",") + .slice(0, 4) + .map((t) => ( + + {t.trim()} + + ))} +
+ )} +
+
+
+ + {/* QR footer */} +
+
+ +
+
+
+ 扫码一起「别说随便」 +
+
+ {shareUrl.replace(/^https?:\/\//, "")} +
+
+
+
+
+ ); +} + +function BlindboxShareCard({ + data, + cardRef, +}: { + data: Extract; + cardRef: React.RefObject; +}) { + const { idea, submitter, drawer, roomName } = data; + const shareUrl = + typeof window !== "undefined" ? window.location.origin : "nowhatever.app"; + + return ( +
+
+ {/* Decorative glows */} +
+
+ + {/* Brand header */} +
+ 🎁 +
+
+ NoWhatever +
+
+ 别说随便 · ADVENTURE ROULETTE +
+
+
+ + {/* Thin accent line */} +
+ + {/* Room name badge */} +
+
+ ✦ {roomName} ✦ +
+
+ + {/* Idea card */} +
+
+ {/* Corner decorations */} +
+
+
+
+ +
+ {idea} +
+ +
+ +
+ 此契约一旦开启,绝不反悔 +
+
+
+ + {/* Attribution */} + {(submitter || drawer) && ( +
+ {submitter && ( +
+ {submitter.avatar} + {submitter.username} 投入 +
+ )} + {submitter && drawer && ( + · + )} + {drawer && ( +
+ {drawer.avatar} + {drawer.username} 抽中 +
+ )} +
+ )} + + {/* QR footer */} +
+
+ +
+
+
+ 扫码一起「别说随便」 +
+
+ {shareUrl.replace(/^https?:\/\//, "")} +
+
+
+
+
+ ); +} + +export default function ShareCardModal({ + open, + onClose, + data, + onToast, +}: ShareCardModalProps) { + const cardRef = useRef(null); + const backdropRef = useRef(null); + const [generating, setGenerating] = useState(false); + const [imageDataUrl, setImageDataUrl] = useState(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 => { + 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 ( + + {open && ( + + + {/* Close button (floating) */} + + + {/* Card */} +
+ {imageLoading ? ( +
+ +
+ ) : data.type === "restaurant" ? ( + + ) : ( + + )} +
+ + {/* Action buttons */} +
+ + +
+ +

+ 长按图片也可以保存到相册 +

+
+
+ )} +
+ ); +}