perf(booth): optimize assets and result flow

WebP reduces UI payload; JPEG keeps portrait saves compatible.\nFinish ends the visit without prompting another portrait.
This commit is contained in:
Ajasra committed 2026-10-30 00:00:16 +08:00
1 parent 56da9c86d2
commit 6772e0cf37
41 files changed
+225 -97

No files matched your search

+90 -23
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Download } from "lucide-react";
import { Check } from "lucide-react";
import confetti from "canvas-confetti";
import { BoothConfig } from "@/types/booth";
import { useI18n } from "@/lib/i18n";
@@ -16,17 +16,17 @@ interface ResultScreenProps {
durationMs: number;
fallbackTriggered: boolean;
};
onStartOver?: () => void;
onFinish?: () => void;
}
export const ResultScreen: React.FC<ResultScreenProps> = ({
config,
resultImageUrl,
providerInfo,
onStartOver,
onFinish,
}) => {
const { t, isWeChat } = useI18n();
const [currentUrl, setCurrentUrl] = useState("");
const [displayImageUrl, setDisplayImageUrl] = useState(resultImageUrl);
useEffect(() => {
try {
@@ -40,15 +40,74 @@ export const ResultScreen: React.FC<ResultScreenProps> = ({
// Ignore
}
if (typeof window !== "undefined") {
setCurrentUrl(window.location.href);
}
}, []);
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]);
const handleDownload = () => {
const a = document.createElement("a");
a.href = resultImageUrl;
a.download = `shanghai-karate-spooky-${Date.now()}.jpg`;
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}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -63,30 +122,30 @@ export const ResultScreen: React.FC<ResultScreenProps> = ({
<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
src={resultImageUrl}
src={displayImageUrl}
alt="Halloween Karate Result"
className="w-full h-full object-cover select-none"
className="w-full h-full object-cover select-auto"
style={{ WebkitTouchCallout: "default" }}
/>
{/* 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">
<span className="text-[10px] sm:text-[11px] font-bold text-amber-300">
<span className="text-xs font-bold text-amber-300">
👉 {t(config.labels.wechatSaveTip)}
</span>
</div>
)}
</div>
{/* Bottom Bar: Mascot on far bottom-left, Save Button on bottom-right */}
<div className="w-full max-w-[340px] sm:max-w-md mx-auto flex items-end justify-between z-30 -mt-10 sm:-mt-12 px-1 pb-1">
{/* Mascot hugging the far bottom-left with speech bubble to the right and lifted higher */}
<div className="pointer-events-none -ml-4 sm:-ml-2 -mb-1">
{/* 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">
<MascotGuide
pose="zen"
size="md"
speechPosition="right"
speechClassName="-translate-y-10 sm:-translate-y-8 max-w-[340px] sm:max-w-[400px]"
size="sm"
speechPosition="top"
speechClassName="max-w-[280px] sm:max-w-[320px]"
speechText={{
en: "Sensational! A true Shanghai Halloween Master!",
zh: "太赞了!真正的万圣空手道宗师诞生!",
@@ -94,15 +153,23 @@ export const ResultScreen: React.FC<ResultScreenProps> = ({
/>
</div>
{/* Save Button on the bottom-right */}
<div className="pb-1.5 shrink-0">
<div className="w-full grid grid-cols-2 gap-3 pb-1.5">
<button
onClick={handleDownload}
className="py-2 px-3.5 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center space-x-1.5 uppercase font-cinzel shadow-lg active:scale-95 cursor-pointer"
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"
>
<Download size={15} className="stroke-[2.5]" />
<span>{t(config.labels.downloadLabel)}</span>
</button>
{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>
)}
</div>
</div>
</div>