2026-10-28 01:20:01 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect } from "react";
|
2026-10-30 00:00:16 +08:00
|
|
|
import { Check } from "lucide-react";
|
2026-10-28 01:20:01 +08:00
|
|
|
import confetti from "canvas-confetti";
|
|
|
|
|
import { BoothConfig } from "@/types/booth";
|
|
|
|
|
import { useI18n } from "@/lib/i18n";
|
|
|
|
|
import { MascotGuide } from "@/components/MascotGuide";
|
|
|
|
|
|
|
|
|
|
interface ResultScreenProps {
|
|
|
|
|
config: BoothConfig;
|
|
|
|
|
resultImageUrl: string;
|
|
|
|
|
providerInfo?: {
|
|
|
|
|
provider: string;
|
|
|
|
|
model: string;
|
|
|
|
|
durationMs: number;
|
|
|
|
|
fallbackTriggered: boolean;
|
|
|
|
|
};
|
2026-10-30 00:00:16 +08:00
|
|
|
onFinish?: () => void;
|
2026-10-28 01:20:01 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const ResultScreen: React.FC<ResultScreenProps> = ({
|
|
|
|
|
config,
|
|
|
|
|
resultImageUrl,
|
|
|
|
|
providerInfo,
|
2026-10-30 00:00:16 +08:00
|
|
|
onFinish,
|
2026-10-28 01:20:01 +08:00
|
|
|
}) => {
|
|
|
|
|
const { t, isWeChat } = useI18n();
|
2026-10-30 00:00:16 +08:00
|
|
|
const [displayImageUrl, setDisplayImageUrl] = useState(resultImageUrl);
|
2026-10-28 01:20:01 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
try {
|
|
|
|
|
confetti({
|
|
|
|
|
particleCount: 80,
|
|
|
|
|
spread: 85,
|
|
|
|
|
origin: { y: 0.65 },
|
|
|
|
|
colors: ["#ff6d00", "#06b6d4", "#a855f7", "#fbbf24", "#ffffff"],
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-10-30 00:00:16 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
let jpegObjectUrl: string | undefined;
|
|
|
|
|
setDisplayImageUrl(resultImageUrl);
|
|
|
|
|
|
|
|
|
|
const prepareJpegForDisplay = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(resultImageUrl);
|
|
|
|
|
if (!response.ok) return;
|
|
|
|
|
|
|
|
|
|
const sourceBlob = await response.blob();
|
|
|
|
|
if (sourceBlob.type.toLowerCase() === "image/jpeg") return;
|
|
|
|
|
|
|
|
|
|
const bitmap = await createImageBitmap(sourceBlob);
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.width = bitmap.width;
|
|
|
|
|
canvas.height = bitmap.height;
|
|
|
|
|
const context = canvas.getContext("2d");
|
|
|
|
|
if (!context) {
|
|
|
|
|
bitmap.close();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.fillStyle = "#ffffff";
|
|
|
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
context.drawImage(bitmap, 0, 0);
|
|
|
|
|
bitmap.close();
|
|
|
|
|
|
|
|
|
|
const jpegBlob = await new Promise<Blob>((resolve, reject) => {
|
|
|
|
|
canvas.toBlob((blob) => {
|
|
|
|
|
if (blob) resolve(blob);
|
|
|
|
|
else reject(new Error("Could not encode portrait as JPEG"));
|
|
|
|
|
}, "image/jpeg", 0.92);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
jpegObjectUrl = URL.createObjectURL(jpegBlob);
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
URL.revokeObjectURL(jpegObjectUrl);
|
|
|
|
|
} else {
|
|
|
|
|
setDisplayImageUrl(jpegObjectUrl);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Keep the source image when the browser cannot fetch or encode it.
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void prepareJpegForDisplay();
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
if (jpegObjectUrl) URL.revokeObjectURL(jpegObjectUrl);
|
|
|
|
|
};
|
|
|
|
|
}, [resultImageUrl]);
|
|
|
|
|
|
2026-10-28 01:20:01 +08:00
|
|
|
const handleDownload = () => {
|
|
|
|
|
const a = document.createElement("a");
|
2026-10-30 00:00:16 +08:00
|
|
|
const isStoredPortrait = resultImageUrl.startsWith("/api/images/");
|
|
|
|
|
const isStoredJpeg = isStoredPortrait && /\.jpe?g(?:[?#]|$)/i.test(resultImageUrl);
|
|
|
|
|
a.href = isStoredJpeg
|
|
|
|
|
? `${resultImageUrl}?download=1`
|
|
|
|
|
: displayImageUrl;
|
|
|
|
|
const extension = isStoredJpeg
|
|
|
|
|
? "jpg"
|
|
|
|
|
: displayImageUrl.startsWith("blob:")
|
|
|
|
|
? "jpg"
|
|
|
|
|
: resultImageUrl.match(/\.(jpe?g|png|webp)(?:[?#]|$)/i)?.[1] || "webp";
|
|
|
|
|
a.download = `shanghai-karate-spooky-${Date.now()}.${extension}`;
|
2026-10-28 01:20:01 +08:00
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative flex-1 flex flex-col justify-between p-2 sm:p-3 overflow-hidden select-none">
|
|
|
|
|
{/* Top spacing */}
|
|
|
|
|
<div className="w-full min-h-[4px] sm:min-h-[8px]" />
|
|
|
|
|
|
|
|
|
|
{/* Main Result Image Card - Compact 9:16 frame */}
|
|
|
|
|
<div className="relative w-full max-w-[275px] sm:max-w-[310px] mx-auto aspect-[9/16] rounded-2xl sm:rounded-3xl overflow-hidden bg-black border-4 border-black shadow-[0_8px_0_#090314,0_0_25px_rgba(255,109,0,0.3)] flex items-center justify-center my-auto">
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
<img
|
2026-10-30 00:00:16 +08:00
|
|
|
src={displayImageUrl}
|
2026-10-28 01:20:01 +08:00
|
|
|
alt="Halloween Karate Result"
|
2026-10-30 00:00:16 +08:00
|
|
|
className="w-full h-full object-cover select-auto"
|
|
|
|
|
style={{ WebkitTouchCallout: "default" }}
|
2026-10-28 01:20:01 +08:00
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* WeChat Long-Press Save Floating Badge */}
|
|
|
|
|
{isWeChat && (
|
|
|
|
|
<div className="absolute top-2 inset-x-3 py-1 px-3 rounded-full bg-black/75 backdrop-blur-sm border border-amber-400/50 text-center">
|
2026-10-30 00:00:16 +08:00
|
|
|
<span className="text-xs font-bold text-amber-300">
|
2026-10-28 01:20:01 +08:00
|
|
|
👉 {t(config.labels.wechatSaveTip)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-10-30 00:00:16 +08:00
|
|
|
{/* Mascot sits above a single row of portrait actions. */}
|
|
|
|
|
<div className="w-full max-w-[340px] sm:max-w-md mx-auto flex flex-col items-center z-30 -mt-6 px-1 pb-1">
|
|
|
|
|
<div className="pointer-events-none -mb-3">
|
2026-10-28 01:20:01 +08:00
|
|
|
<MascotGuide
|
|
|
|
|
pose="zen"
|
2026-10-30 00:00:16 +08:00
|
|
|
size="sm"
|
|
|
|
|
speechPosition="top"
|
|
|
|
|
speechClassName="max-w-[280px] sm:max-w-[320px]"
|
2026-10-28 01:20:01 +08:00
|
|
|
speechText={{
|
|
|
|
|
en: "Sensational! A true Shanghai Halloween Master!",
|
|
|
|
|
zh: "太赞了!真正的万圣空手道宗师诞生!",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-10-30 00:00:16 +08:00
|
|
|
<div className="w-full grid grid-cols-2 gap-3 pb-1.5">
|
2026-10-28 01:20:01 +08:00
|
|
|
<button
|
|
|
|
|
onClick={handleDownload}
|
2026-10-30 00:00:16 +08:00
|
|
|
className="min-h-11 w-full justify-center px-3 game-btn-gold font-extrabold text-sm tracking-wider flex items-center shadow-lg active:scale-95 cursor-pointer"
|
2026-10-28 01:20:01 +08:00
|
|
|
>
|
|
|
|
|
<span>{t(config.labels.downloadLabel)}</span>
|
|
|
|
|
</button>
|
2026-10-30 00:00:16 +08:00
|
|
|
|
|
|
|
|
{onFinish && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={onFinish}
|
|
|
|
|
className="min-h-11 w-full justify-center px-3 game-btn-dark text-sm font-bold tracking-wide flex items-center space-x-1.5 shadow-lg active:scale-95 cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<Check size={16} className="stroke-[3]" />
|
|
|
|
|
<span>{t(config.labels.startOverLabel)}</span>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-10-28 01:20:01 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|