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:
1 parent
56da9c86d2
commit
6772e0cf37
41 files changed
+225
-97
No files matched your search
@@ -111,7 +111,7 @@ export const KioskFrame: React.FC<KioskFrameProps> = ({
|
||||
href="https://www.shanghaikarate.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[8.5px] sm:text-[9.5px] text-white/50 hover:text-amber-300 transition-colors whitespace-nowrap overflow-hidden text-ellipsis tracking-wide"
|
||||
className="kiosk-footer-label text-[10px] sm:text-[11px] text-white/70 hover:text-amber-300 transition-colors whitespace-nowrap overflow-hidden text-ellipsis tracking-wide"
|
||||
title="Shanghai Karate Club (shanghaikarate.com)"
|
||||
>
|
||||
<span>上海健志空手道馆 · Shanghai Karate Club · Est. 2008</span>
|
||||
|
||||
@@ -19,10 +19,10 @@ interface MascotGuideProps {
|
||||
}
|
||||
|
||||
const POSE_IMAGES: Record<MascotPose, string> = {
|
||||
bow: "/assets/theme/mascot_bow.png",
|
||||
punch: "/assets/theme/mascot_punch.png",
|
||||
kick: "/assets/theme/mascot_kick.png",
|
||||
zen: "/assets/theme/mascot_zen.png",
|
||||
bow: "/assets/theme/mascot_bow.webp",
|
||||
punch: "/assets/theme/mascot_punch.webp",
|
||||
kick: "/assets/theme/mascot_kick.webp",
|
||||
zen: "/assets/theme/mascot_zen.webp",
|
||||
};
|
||||
|
||||
export const MascotGuide: React.FC<MascotGuideProps> = ({
|
||||
@@ -94,7 +94,7 @@ export const MascotGuide: React.FC<MascotGuideProps> = ({
|
||||
)}
|
||||
|
||||
<p
|
||||
className={`text-amber-100 font-extrabold text-[12px] sm:text-[14px] leading-snug drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] ${
|
||||
className={`text-amber-100 font-extrabold text-sm leading-snug drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] ${
|
||||
language === "en" ? "font-cinzel" : "font-dojo"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const SpookyAtmosphere: React.FC<SpookyAtmosphereProps> = ({
|
||||
{/* 1. Shanghai Dojo Courtyard Night Background Artwork */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/assets/theme/shanghai_dojo_night_bg.jpg"
|
||||
src="/assets/theme/shanghai_dojo_night_bg.webp"
|
||||
alt="Shanghai Dojo Night Background"
|
||||
className="absolute inset-0 w-full h-full object-cover scale-105"
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ConfirmScreen: React.FC<ConfirmScreenProps> = ({
|
||||
<div className="w-full flex items-center justify-between px-1 mb-1 z-30 min-h-[40px]">
|
||||
<button
|
||||
onClick={onRetake}
|
||||
className="p-2 px-3 rounded-2xl btn-cartoon-dark text-white flex items-center space-x-1 font-black text-xs cursor-pointer shadow-md"
|
||||
className="p-2 px-3 rounded-2xl btn-cartoon-dark text-white flex items-center space-x-1 font-black text-sm cursor-pointer shadow-md"
|
||||
>
|
||||
<ArrowLeft size={16} className="stroke-[3]" />
|
||||
<span>{t(config.labels.retakeButton)}</span>
|
||||
@@ -63,7 +63,7 @@ export const ConfirmScreen: React.FC<ConfirmScreenProps> = ({
|
||||
<div className="w-full max-w-sm flex items-center justify-center py-2 z-20 min-h-[70px]">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg active:scale-95"
|
||||
className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg active:scale-95"
|
||||
>
|
||||
<Check size={17} className="stroke-[3]" />
|
||||
<span>{t(config.labels.confirmButton)}</span>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { MascotGuide } from "@/components/MascotGuide";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export const FinishedScreen: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 flex flex-col items-center justify-center gap-5 p-6 text-center select-none">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-500/15 border-2 border-emerald-400/70 text-emerald-300 flex items-center justify-center shadow-[0_0_28px_rgba(52,211,153,0.2)]">
|
||||
<Check size={34} className="stroke-[3]" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-sm space-y-3">
|
||||
<h1 className="text-2xl sm:text-3xl font-black text-amber-100 drop-shadow-[0_3px_8px_rgba(0,0,0,0.9)]">
|
||||
{t({ en: "Thanks for visiting!", zh: "感谢到访!" })}
|
||||
</h1>
|
||||
<p className="text-base leading-relaxed font-semibold text-amber-100/90">
|
||||
{t({
|
||||
en: "Your portrait is ready to take with you. You can close this page now.",
|
||||
zh: "您的万圣武道照已准备好带回家,现在可以关闭此页面。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MascotGuide pose="bow" size="sm" animate={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const StandbyScreen: React.FC<StandbyScreenProps> = ({
|
||||
<h2 className={`text-2xl sm:text-3xl md:text-4xl font-black text-amber-100 tracking-wider drop-shadow-[0_4px_12px_rgba(0,0,0,0.95)] ${language === 'en' ? 'font-cinzel' : 'font-dojo'}`}>
|
||||
{t(labels.welcomeTitle)}
|
||||
</h2>
|
||||
<p className="text-amber-400/95 font-semibold text-[11px] sm:text-xs tracking-widest uppercase drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]">
|
||||
<p className="text-amber-400/95 font-semibold text-xs sm:text-xs tracking-widest uppercase drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]">
|
||||
{t(labels.welcomeSubtitle)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ export const StandbyScreen: React.FC<StandbyScreenProps> = ({
|
||||
<div className="z-10 w-full flex flex-col items-center pb-1">
|
||||
<button
|
||||
onClick={onStart}
|
||||
className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg"
|
||||
className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg"
|
||||
>
|
||||
<span>{t(labels.welcomeCta)}</span>
|
||||
</button>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const StyleScreen: React.FC<StyleScreenProps> = ({
|
||||
<div className="w-full flex items-center justify-between px-1 mb-1 z-30 min-h-[40px]">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 px-3 game-btn-dark flex items-center space-x-1 font-bold text-xs shrink-0 cursor-pointer shadow-md"
|
||||
className="p-2 px-3 game-btn-dark flex items-center space-x-1 font-bold text-sm shrink-0 cursor-pointer shadow-md"
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeft size={16} className="stroke-[2.5]" />
|
||||
@@ -47,7 +47,7 @@ export const StyleScreen: React.FC<StyleScreenProps> = ({
|
||||
{/* Top-Right Confirm / Transform Button - Clean text, no icon */}
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="py-2 px-3.5 game-btn-gold font-extrabold text-xs tracking-wider flex items-center justify-center uppercase font-cinzel shadow-lg active:scale-95 cursor-pointer"
|
||||
className="py-2 px-3.5 game-btn-gold font-extrabold text-sm tracking-wider flex items-center justify-center uppercase font-cinzel shadow-lg active:scale-95 cursor-pointer"
|
||||
>
|
||||
<span>{t(config.labels.confirmStyleButton)}</span>
|
||||
</button>
|
||||
@@ -88,7 +88,7 @@ export const StyleScreen: React.FC<StyleScreenProps> = ({
|
||||
|
||||
{/* Title & Tag */}
|
||||
<div className="mt-2 text-center w-full px-1">
|
||||
<span className="block text-xs font-bold text-amber-100 truncate drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||
<span className="block text-sm font-bold text-amber-100 truncate drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||
{styleName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user