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.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 */}
+
+
+ {/* 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 */}
+
+
+
+
+
+
+ 长按图片也可以保存到相册
+
+
+
+ )}
+
+ );
+}