feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates

This commit is contained in:
Ajasra committed 2026-10-28 01:20:01 +08:00
commit e04672db2a
104 files changed
+8262

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
"use client";
import React, { useState, useEffect } from "react";
import { BoothConfig } from "@/types/booth";
import { SpookyAtmosphere } from "@/components/SpookyAtmosphere";
import { useI18n } from "@/lib/i18n";
import { Globe } from "lucide-react";
interface KioskFrameProps {
config: BoothConfig;
children: React.ReactNode;
showHeaderLogo?: boolean;
showLanguageToggle?: boolean;
}
export const KioskFrame: React.FC<KioskFrameProps> = ({
config,
children,
showHeaderLogo = true,
showLanguageToggle = true,
}) => {
const [isModeFullscreen, setIsModeFullscreen] = useState(false);
const { language, toggleLanguage, isWeChat } = useI18n();
useEffect(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
const isFull = params.get("mode") === "fullscreen";
setIsModeFullscreen(isFull);
if (isFull) {
const triggerFullscreen = () => {
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch((err) => {
console.log("[Kiosk] Fullscreen request prevented:", err);
});
}
};
window.addEventListener("pointerdown", triggerFullscreen, { once: true });
return () => window.removeEventListener("pointerdown", triggerFullscreen);
}
}
}, []);
const theme = config.theme;
return (
<div
data-fullscreen={isModeFullscreen}
className={`relative flex flex-col overflow-hidden select-none transition-all duration-300 ${
isModeFullscreen
? "w-screen h-screen max-w-none max-h-none rounded-none border-0 shadow-none"
: "kiosk-viewport rounded-none sm:rounded-3xl border-4 border-black shadow-[0_12px_0_#090314,0_0_40px_rgba(255,109,0,0.25)]"
}`}
style={
{
"--brand-primary": theme.primaryColor,
"--brand-accent": theme.accentColor,
"--brand-bg": theme.backgroundColor,
"--brand-surface": theme.surfaceColor,
"--brand-text": theme.textColor,
} as React.CSSProperties
}
>
{/* Dynamic Martial Arts & Shanghai Halloween Atmosphere */}
<SpookyAtmosphere />
{/* Floating Language Toggle (Top Right) */}
{showLanguageToggle && (
<div className="absolute top-[max(0.75rem,env(safe-area-inset-top))] right-4 z-30">
<button
onClick={toggleLanguage}
className="flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-[#1b0f2e]/90 hover:bg-[#2b184a] text-amber-300 border-2 border-black shadow-[0_3px_0_#090314] text-xs font-black tracking-wider transition active:scale-95"
title={language === "zh" ? "Switch to English" : "切换为中文"}
>
<Globe size={13} className="text-amber-400" />
<span>{language === "zh" ? "EN" : "中"}</span>
</button>
</div>
)}
{/* Brand Header Logo - 20% larger */}
{showHeaderLogo && (
<header className="no-print pt-[max(0.5rem,env(safe-area-inset-top))] sm:pt-3 pb-0.5 px-4 flex justify-center items-center z-20">
<div className="relative flex items-center justify-center">
{theme.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={theme.logoUrl}
alt={config.appName}
className="w-56 sm:w-72 md:w-84 h-auto max-h-16 sm:max-h-20 object-contain drop-shadow-[0_4px_14px_rgba(0,0,0,0.85)] filter"
/>
) : (
<h1 className="text-lg sm:text-xl font-black tracking-wider text-white uppercase drop-shadow-[0_3px_0_#090314]">
{config.appName}
</h1>
)}
</div>
</header>
)}
{/* Screen Content Layer */}
<main className="flex-1 relative flex flex-col overflow-hidden z-10 w-full pl-[max(0px,env(safe-area-inset-left))] pr-[max(0px,env(safe-area-inset-right))]">
{children}
</main>
{/* Dojo Brand Footer - clean single line, smaller font, no emoji */}
<footer className="no-print pt-0.5 pb-[max(0.75rem,env(safe-area-inset-bottom))] px-2 flex items-center justify-center z-20 select-none">
<a
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"
title="Shanghai Karate Club (shanghaikarate.com)"
>
<span>上海健志空手道馆 · Shanghai Karate Club · Est. 2008</span>
</a>
</footer>
</div>
);
};
+108
View File
@@ -0,0 +1,108 @@
"use client";
import React from "react";
import { useI18n } from "@/lib/i18n";
export type MascotPose = "bow" | "punch" | "kick" | "zen";
interface MascotGuideProps {
pose?: MascotPose;
speechText?: {
en: string;
zh: string;
};
speechPosition?: "left" | "right" | "top";
className?: string;
speechClassName?: string;
size?: "sm" | "md" | "lg" | "xl" | "2xl";
animate?: boolean;
}
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",
};
export const MascotGuide: React.FC<MascotGuideProps> = ({
pose = "bow",
speechText,
speechPosition = "right",
className = "",
speechClassName = "",
size = "md",
animate = true,
}) => {
const { t, language } = useI18n();
const sizeClasses = {
sm: "w-28 h-28 sm:w-36 sm:h-36",
md: "w-40 h-40 sm:w-48 sm:h-48",
lg: "w-48 h-48 sm:w-56 sm:h-56",
xl: "w-56 h-56 sm:w-68 sm:h-68",
"2xl": "w-64 h-64 sm:w-80 sm:h-80",
}[size];
return (
<div
className={`relative flex items-center select-none pointer-events-none ${
speechPosition === "left"
? "flex-row-reverse"
: speechPosition === "top"
? "flex-col-reverse items-center"
: "flex-row"
} ${className}`}
>
{/* Mascot Cutout Character */}
<div
className={`relative shrink-0 ${
animate ? "animate-cartoon-wobble" : ""
}`}
>
{/* Soft magical glow backdrop */}
<div className="absolute inset-0 rounded-full bg-amber-400/25 blur-2xl transform scale-125" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={POSE_IMAGES[pose]}
alt={`Sensei Spook - ${pose}`}
className={`${sizeClasses} object-contain filter drop-shadow-[0_12px_22px_rgba(0,0,0,0.85)] relative z-10`}
/>
</div>
{/* Comic Speech Bubble */}
{speechText && (
<div
className={`relative z-20 pointer-events-auto ${
speechPosition === "top"
? "mb-1"
: speechPosition === "left"
? "-mr-1 sm:mr-0"
: "-ml-3 sm:-ml-2"
} ${speechClassName}`}
>
<div className="relative px-3.5 py-2.5 rounded-2xl bg-[#1b0f2e]/95 border-2 border-amber-400/90 shadow-[0_4px_18px_rgba(0,0,0,0.85)] max-w-[240px] sm:max-w-[290px] text-left backdrop-blur-sm">
{/* Bubble arrow tail pointing towards the ghost */}
{speechPosition === "right" && (
<div className="absolute -left-2 top-1/2 -translate-y-1/2 w-0 h-0 border-y-[7px] border-y-transparent border-r-[8px] border-r-amber-400 after:content-[''] after:absolute after:-top-[6px] after:left-[2px] after:border-y-[6px] after:border-y-transparent after:border-r-[7px] after:border-r-[#1b0f2e]" />
)}
{speechPosition === "left" && (
<div className="absolute -right-2 top-1/2 -translate-y-1/2 w-0 h-0 border-y-[7px] border-y-transparent border-l-[8px] border-l-amber-400 after:content-[''] after:absolute after:-top-[6px] after:right-[2px] after:border-y-[6px] after:border-y-transparent after:border-l-[7px] after:border-l-[#1b0f2e]" />
)}
{speechPosition === "top" && (
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-0 h-0 border-x-[7px] border-x-transparent border-t-[8px] border-t-amber-400 after:content-[''] after:absolute after:-left-[6px] after:-top-[8px] after:border-x-[6px] after:border-x-transparent after:border-t-[7px] after:border-t-[#1b0f2e]" />
)}
<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)] ${
language === "en" ? "font-cinzel" : "font-dojo"
}`}
>
{t(speechText)}
</p>
</div>
</div>
)}
</div>
);
};
+43
View File
@@ -0,0 +1,43 @@
"use client";
import React from "react";
interface SpookyAtmosphereProps {
className?: string;
variant?: "kiosk" | "fullscreen";
}
export const SpookyAtmosphere: React.FC<SpookyAtmosphereProps> = ({
className = "",
}) => {
return (
<div
aria-hidden="true"
className={`absolute inset-0 overflow-hidden pointer-events-none select-none z-0 ${className}`}
>
{/* 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"
alt="Shanghai Dojo Night Background"
className="absolute inset-0 w-full h-full object-cover scale-105"
/>
{/* 2. Cinematic Vignette & Deep Twilight Tint Overlay */}
<div className="absolute inset-0 bg-gradient-to-b from-[#06020c]/85 via-[#0e0618]/50 to-[#07020d]/90 mix-blend-multiply" />
{/* 3. Radial Torchlight / Lantern Glow from Bottom Corners */}
<div className="absolute -bottom-10 -left-10 w-72 h-72 rounded-full bg-orange-600/25 blur-3xl" />
<div className="absolute -bottom-10 -right-10 w-72 h-72 rounded-full bg-amber-500/20 blur-3xl" />
{/* 4. Top Header Moon Radiance */}
<div className="absolute -top-16 -right-16 w-80 h-80 rounded-full bg-amber-300/15 blur-3xl" />
{/* 5. Floating Halloween Sparks & Chi Embers */}
<div className="absolute top-[35%] left-[10%] w-2 h-2 rounded-full bg-amber-400 opacity-80 animate-ping" />
<div className="absolute top-[22%] right-[18%] w-1.5 h-1.5 rounded-full bg-cyan-300 opacity-70 animate-pulse" />
<div className="absolute top-[62%] right-[12%] w-2.5 h-2.5 rounded-full bg-orange-500 opacity-60 animate-bounce" />
<div className="absolute top-[75%] left-[20%] w-1.5 h-1.5 rounded-full bg-amber-200 opacity-50 animate-ping" />
</div>
);
};
+357
View File
@@ -0,0 +1,357 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Camera, SwitchCamera, Upload, AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
import { BoothConfig } from "@/types/booth";
import { useI18n } from "@/lib/i18n";
import { MascotGuide } from "@/components/MascotGuide";
interface CameraScreenProps {
config: BoothConfig;
onPhotoCaptured: (imageBase64: string) => void;
onBack: () => void;
}
export const CameraScreen: React.FC<CameraScreenProps> = ({
config,
onPhotoCaptured,
onBack,
}) => {
const { t, isWeChat } = useI18n();
const [facingMode, setFacingMode] = useState<"user" | "environment">(
config.camera.defaultFacingMode || "user"
);
const [stream, setStream] = useState<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const [countdown, setCountdown] = useState<number | null>(null);
const [isFlashing, setIsFlashing] = useState(false);
const [hasMultipleCameras, setHasMultipleCameras] = useState<boolean>(false);
const [isSwitchingCamera, setIsSwitchingCamera] = useState<boolean>(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const stopStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => {
try {
track.stop();
} catch (e) {
console.warn("Error stopping track:", e);
}
});
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setStream(null);
}, []);
const checkCameraDevices = useCallback(async () => {
if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) {
return;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = devices.filter((device) => device.kind === "videoinput");
setHasMultipleCameras(videoInputs.length > 1);
} catch (err) {
console.warn("Could not enumerate camera devices:", err);
}
}, []);
useEffect(() => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.addEventListener) {
const handleDeviceChange = () => {
checkCameraDevices();
};
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return () => {
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
};
}
}, [checkCameraDevices]);
const startCamera = useCallback(async (targetFacingMode: "user" | "environment") => {
try {
setCameraError(null);
stopStream();
if (
typeof window !== "undefined" &&
!window.isSecureContext &&
window.location.hostname !== "localhost" &&
window.location.hostname !== "127.0.0.1"
) {
setCameraError(
t({
en: "Camera access requires HTTPS or localhost. Please take a photo with your device.",
zh: "摄像头需要安全连接(HTTPS)。请直接使用手机拍摄或上传照片。"
})
);
return;
}
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
setCameraError(
t({
en: "Live camera is not supported. Please click Upload to take a photo.",
zh: "当前环境不支持实时取景,请点击右下角按钮直接拍照或上传。"
})
);
return;
}
await new Promise((resolve) => setTimeout(resolve, 60));
let newStream: MediaStream | null = null;
try {
const primaryConstraints: MediaStreamConstraints = {
audio: false,
video: {
facingMode: { ideal: targetFacingMode },
width: { ideal: 1920 },
height: { ideal: 1080 },
},
};
newStream = await navigator.mediaDevices.getUserMedia(primaryConstraints);
} catch (tier1Error: any) {
try {
const secondaryConstraints: MediaStreamConstraints = {
audio: false,
video: {
facingMode: { ideal: targetFacingMode },
},
};
newStream = await navigator.mediaDevices.getUserMedia(secondaryConstraints);
} catch (tier2Error: any) {
newStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
}
}
if (!newStream) {
throw new Error("Unable to acquire media stream.");
}
streamRef.current = newStream;
setStream(newStream);
if (videoRef.current) {
const video = videoRef.current;
video.srcObject = newStream;
video.setAttribute("playsinline", "true");
video.setAttribute("webkit-playsinline", "true");
video.onloadedmetadata = () => {
video.play().catch((playErr) => {
console.log("[Camera] Video play deferred:", playErr);
});
};
}
checkCameraDevices();
} catch (err: any) {
console.error("Camera access error:", err);
let errorMsg = t({
en: "Camera access was denied or not found. Please click Upload to take/choose a photo.",
zh: "无法访问摄像头。请点击下方上传按钮直接拍照或从相册选择。"
});
setCameraError(errorMsg);
}
}, [stopStream, checkCameraDevices, t]);
useEffect(() => {
startCamera(facingMode);
return () => {
stopStream();
};
}, [facingMode, startCamera, stopStream]);
const toggleCameraFacingMode = async () => {
if (isSwitchingCamera) return;
setIsSwitchingCamera(true);
const nextMode = facingMode === "user" ? "environment" : "user";
setFacingMode(nextMode);
setTimeout(() => {
setIsSwitchingCamera(false);
}, 400);
};
const handleCapture = () => {
if (!videoRef.current) return;
const video = videoRef.current;
const videoWidth = video.videoWidth || 1080;
const videoHeight = video.videoHeight || 1920;
const canvas = document.createElement("canvas");
canvas.width = videoWidth;
canvas.height = videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (facingMode === "user") {
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
}
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const photoBase64 = canvas.toDataURL("image/jpeg", 0.92);
setIsFlashing(true);
setTimeout(() => {
setIsFlashing(false);
stopStream();
onPhotoCaptured(photoBase64);
}, 200);
};
const startCountdown = () => {
if (countdown !== null) return;
const seconds = config.camera.countdownSeconds || 3;
setCountdown(seconds);
let remaining = seconds;
const interval = setInterval(() => {
remaining -= 1;
if (remaining > 0) {
setCountdown(remaining);
} else {
clearInterval(interval);
setCountdown(null);
handleCapture();
}
}, 1000);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
stopStream();
onPhotoCaptured(result);
};
reader.readAsDataURL(file);
};
return (
<div className="relative flex-1 flex flex-col items-center justify-between p-2 sm:p-4 overflow-hidden select-none">
{/* Top Header */}
<div className="w-full flex items-center justify-between px-2 mb-1 z-30 min-h-[40px]">
<button
onClick={() => {
stopStream();
onBack();
}}
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"
>
<ArrowLeft size={16} className="stroke-[3]" />
<span>{t(config.labels.backButton)}</span>
</button>
{hasMultipleCameras ? (
<button
onClick={toggleCameraFacingMode}
disabled={isSwitchingCamera || countdown !== null}
className="p-2.5 rounded-2xl btn-cartoon-dark text-amber-300 hover:text-white transition active:scale-95 shadow-md"
title="Switch front/back camera"
>
<SwitchCamera size={18} className={`stroke-[2.5] ${isSwitchingCamera ? "animate-spin" : ""}`} />
</button>
) : (
<div className="w-10" />
)}
</div>
{/* Video Viewport - Made bigger, flex-1 with Mascot overlaying */}
<div className="relative w-full max-w-md flex-1 min-h-0 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-1">
{cameraError ? (
<div className="p-6 text-center text-red-200 flex flex-col items-center justify-center space-y-4">
<AlertCircle size={44} className="text-amber-400 stroke-[2.5]" />
<p className="text-xs sm:text-sm font-bold leading-relaxed">{cameraError}</p>
</div>
) : (
<>
<video
ref={videoRef}
autoPlay
playsInline
muted
className={`w-full h-full object-cover ${facingMode === "user" ? "scale-x-[-1]" : ""}`}
/>
{isFlashing && <div className="absolute inset-0 bg-white z-50 animate-flash" />}
{countdown !== null && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-[2px] z-40">
<span className="text-8xl sm:text-9xl font-black text-amber-400 animate-ping drop-shadow-[0_8px_0_#090314]">
{countdown}
</span>
</div>
)}
{/* Martial Framing Guides */}
<div className="absolute inset-x-6 top-6 bottom-20 pointer-events-none border border-dashed border-amber-400/30 rounded-2xl flex flex-col justify-start items-center py-3">
<span className="text-[10px] font-black tracking-widest text-amber-300/60 uppercase">
⚡ {t({ en: "HEAD & MARTIAL POSE", zh: "头部与武道姿势区域" })} ⚡
</span>
</div>
{/* Mascot Character - Overlaying bottom-left of the picture frame */}
<div className="absolute -bottom-4 -left-3 sm:-bottom-2 sm:-left-2 z-30 pointer-events-none">
<MascotGuide
pose="kick"
size="lg"
speechPosition="right"
speechText={{
en: "Strike your pose! Punch, kick, or victory stance!",
zh: "摆出你的武道英姿!冲拳、高踢或胜利架势!",
}}
/>
</div>
</>
)}
</div>
{/* Bottom Action Bar */}
<div className="w-full max-w-sm flex items-center justify-between px-6 py-2 z-20 min-h-[76px]">
{/* Upload / Device Photo Button */}
{config.camera.allowUpload ? (
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileUpload}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="p-3.5 rounded-2xl btn-cartoon-dark text-amber-300 transition active:scale-95 shadow-md flex items-center justify-center"
title={t(config.labels.cameraUpload)}
>
<Upload size={20} className="stroke-[2.5]" />
</button>
</div>
) : (
<div className="w-12" />
)}
{/* Shutter Button */}
<button
onClick={startCountdown}
disabled={Boolean(cameraError) || countdown !== null || !stream}
className="w-18 h-18 sm:w-20 sm:h-20 rounded-full btn-cartoon-orange flex items-center justify-center disabled:opacity-50 active:scale-95 shadow-lg"
title={t(config.labels.cameraSnap)}
>
<Camera size={32} className="text-white drop-shadow-[0_2px_0_#9a3412]" />
</button>
{/* Balance spacer */}
<div className="w-12" />
</div>
</div>
);
};
+74
View File
@@ -0,0 +1,74 @@
"use client";
import React from "react";
import { ArrowLeft, Check, RefreshCw } from "lucide-react";
import { BoothConfig } from "@/types/booth";
import { useI18n } from "@/lib/i18n";
import { MascotGuide } from "@/components/MascotGuide";
interface ConfirmScreenProps {
config: BoothConfig;
photoUrl: string;
onConfirm: () => void;
onRetake: () => void;
}
export const ConfirmScreen: React.FC<ConfirmScreenProps> = ({
config,
photoUrl,
onConfirm,
onRetake,
}) => {
const { t } = useI18n();
return (
<div className="relative flex-1 flex flex-col items-center justify-between p-3 sm:p-4 overflow-hidden select-none">
{/* Top Header - Back/Retake button on top left, title removed */}
<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"
>
<ArrowLeft size={16} className="stroke-[3]" />
<span>{t(config.labels.retakeButton)}</span>
</button>
<div className="w-16" />
</div>
{/* Captured Photo Preview Card */}
<div className="relative w-full max-w-sm aspect-[9/16] 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={photoUrl}
alt="Captured Pose"
className="w-full h-full object-cover select-none"
/>
{/* Mascot Comic Callout in preview */}
<div className="absolute -bottom-2 -left-2 sm:bottom-1 sm:left-1 pointer-events-none z-20">
<MascotGuide
pose="bow"
size="md"
speechPosition="right"
speechText={{
en: "Epic martial focus! Ready to pick your style?",
zh: "气势十足!准备好选择你的万圣风格了吗?",
}}
/>
</div>
</div>
{/* Bottom Action Bar - Single centered button matching Standby Screen style */}
<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"
>
<Check size={17} className="stroke-[3]" />
<span>{t(config.labels.confirmButton)}</span>
</button>
</div>
</div>
);
};
+68
View File
@@ -0,0 +1,68 @@
"use client";
import React, { useEffect, useState } from "react";
import { Sparkles, Loader2, Flame } from "lucide-react";
import { BoothConfig } from "@/types/booth";
import { useI18n } from "@/lib/i18n";
import { MascotGuide } from "@/components/MascotGuide";
interface LoadingScreenProps {
config: BoothConfig;
currentStepMessage?: string;
}
export const LoadingScreen: React.FC<LoadingScreenProps> = ({
config,
currentStepMessage,
}) => {
const { t } = useI18n();
const [progress, setProgress] = useState(15);
useEffect(() => {
const timer = setInterval(() => {
setProgress((prev) => {
if (prev >= 92) return prev;
return prev + Math.floor(Math.random() * 8) + 3;
});
}, 700);
return () => clearInterval(timer);
}, []);
return (
<div className="relative flex-1 flex flex-col items-center justify-center p-6 text-center select-none overflow-hidden">
{/* Animated Center Karate Ghost & Ki-Sphere */}
<div className="relative mb-4 flex flex-col items-center">
<MascotGuide
pose="punch"
size="md"
speechPosition="top"
speechText={{
en: "KIAI! Gathering dojo ki and pumpkin magic...",
zh: "气合!正在汇聚道馆灵气与南瓜魔法...",
}}
/>
</div>
<h2 className="text-xl sm:text-2xl font-black text-white tracking-wide mb-2 drop-shadow-[0_2px_0_#090314]">
{t(config.labels.processingTitle)}
</h2>
<p className="text-xs sm:text-sm text-purple-200 max-w-xs mb-6 font-medium leading-relaxed drop-shadow">
{currentStepMessage || t(config.labels.processingSubtitle)}
</p>
{/* Chunky Progress Bar */}
<div className="w-full max-w-xs h-6 rounded-full bg-black/60 border-2 border-black p-1 shadow-inner relative overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-400 via-orange-500 to-cyan-400 transition-all duration-300 shadow-[0_0_15px_rgba(251,191,36,0.6)]"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-[11px] font-black text-amber-300 mt-2 tracking-wider">
{progress}% · {t({ en: "MARTIAL MAGIC BREWING", zh: "武道气合凝聚中" })}
</span>
</div>
);
};
+56
View File
@@ -0,0 +1,56 @@
"use client";
import React from "react";
import { RotateCcw } from "lucide-react";
import { BoothConfig } from "@/types/booth";
import { useI18n } from "@/lib/i18n";
interface RateLimitedScreenProps {
config: BoothConfig;
resetMinutes: number;
onReset: () => void;
}
export const RateLimitedScreen: React.FC<RateLimitedScreenProps> = ({
config,
resetMinutes,
onReset,
}) => {
const { t } = useI18n();
return (
<div className="relative flex-1 flex flex-col items-center justify-center p-8 text-center select-none space-y-6">
<div className="w-24 h-24 rounded-full bg-gradient-to-tr from-purple-900 to-orange-950 flex items-center justify-center border-2 border-orange-500/40 shadow-[0_0_30px_rgba(255,109,0,0.3)]">
<span className="text-4xl animate-bounce">⏳</span>
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-2xl font-black text-white tracking-wide drop-shadow-[0_2px_0_#090314]">
{t({ en: "Martial Energy Cooling Down! ⚡", zh: "武道气合修整中!⚡" })}
</h2>
<p className="text-amber-200/90 text-sm leading-relaxed font-bold">
{t({
en: "You have conjured the maximum number of Halloween portraits for this session.",
zh: "当前设备生成的万圣武道照已达上限,稍作休息吧!"
})}
</p>
{resetMinutes > 0 && (
<p className="text-slate-300 text-xs mt-2 bg-black/40 px-3 py-1.5 rounded-full border border-purple-500/30">
{t({
en: `More energy ready in approx ${resetMinutes} minutes.`,
zh: `预计约 ${resetMinutes} 分钟后恢复。`
})}
</p>
)}
</div>
<button
onClick={onReset}
className="px-6 py-3.5 rounded-2xl btn-cartoon-orange text-white font-black text-sm shadow-xl flex items-center space-x-2 transition active:scale-95"
>
<RotateCcw size={18} />
<span>{t({ en: "Return to Dojo 🥋", zh: "返回万圣道馆 🥋" })}</span>
</button>
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
"use client";
import React, { useState, useEffect } from "react";
import { Download } from "lucide-react";
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;
};
onStartOver?: () => void;
}
export const ResultScreen: React.FC<ResultScreenProps> = ({
config,
resultImageUrl,
providerInfo,
onStartOver,
}) => {
const { t, isWeChat } = useI18n();
const [currentUrl, setCurrentUrl] = useState("");
useEffect(() => {
try {
confetti({
particleCount: 80,
spread: 85,
origin: { y: 0.65 },
colors: ["#ff6d00", "#06b6d4", "#a855f7", "#fbbf24", "#ffffff"],
});
} catch (e) {
// Ignore
}
if (typeof window !== "undefined") {
setCurrentUrl(window.location.href);
}
}, []);
const handleDownload = () => {
const a = document.createElement("a");
a.href = resultImageUrl;
a.download = `shanghai-karate-spooky-${Date.now()}.jpg`;
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
src={resultImageUrl}
alt="Halloween Karate Result"
className="w-full h-full object-cover select-none"
/>
{/* 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">
👉 {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">
<MascotGuide
pose="zen"
size="md"
speechPosition="right"
speechClassName="-translate-y-10 sm:-translate-y-8 max-w-[340px] sm:max-w-[400px]"
speechText={{
en: "Sensational! A true Shanghai Halloween Master!",
zh: "太赞了!真正的万圣空手道宗师诞生!",
}}
/>
</div>
{/* Save Button on the bottom-right */}
<div className="pb-1.5 shrink-0">
<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"
>
<Download size={15} className="stroke-[2.5]" />
<span>{t(config.labels.downloadLabel)}</span>
</button>
</div>
</div>
</div>
);
};
+61
View File
@@ -0,0 +1,61 @@
"use client";
import React from "react";
import { BoothConfig } from "@/types/booth";
import { useI18n } from "@/lib/i18n";
import { MascotGuide } from "@/components/MascotGuide";
interface StandbyScreenProps {
config: BoothConfig;
onStart: () => void;
}
export const StandbyScreen: React.FC<StandbyScreenProps> = ({
config,
onStart,
}) => {
const { labels } = config;
const { t, language } = useI18n();
return (
<div
onClick={onStart}
className="relative flex-1 flex flex-col items-center justify-between px-3 py-2 sm:px-6 sm:py-4 cursor-pointer select-none overflow-hidden"
>
{/* Center Hero Character - Clean 3D Mascot */}
<div className="my-auto flex flex-col items-center text-center z-10 space-y-2 sm:space-y-3 px-2 max-w-sm">
{/* Title & Subtitle with Stylized Typography */}
<div className="space-y-0.5">
<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)]">
{t(labels.welcomeSubtitle)}
</p>
</div>
{/* Floating 3D Karate Ghost Mascot with Comic Speech Bubble */}
<MascotGuide
pose="bow"
size="lg"
speechPosition="top"
className="my-1"
speechText={{
en: "Osu! Welcome to our dojo! Strike your fiercest martial arts pose & become an epic Shanghai legend!",
zh: "押忍!欢迎来到道馆!摆出你最帅气的武道姿势,成为魔都万圣传奇!",
}}
/>
</div>
{/* Halloween Game Action Button */}
<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"
>
<span>{t(labels.welcomeCta)}</span>
</button>
</div>
</div>
);
};
+114
View File
@@ -0,0 +1,114 @@
"use client";
import React, { useState } from "react";
import { Check, ArrowLeft, Wand2 } from "lucide-react";
import { BoothConfig, BoothStyle } from "@/types/booth";
import { useI18n } from "@/lib/i18n";
import { MascotGuide } from "@/components/MascotGuide";
interface StyleScreenProps {
config: BoothConfig;
styles: BoothStyle[];
onStyleSelected: (style: BoothStyle) => void;
onBack: () => void;
}
export const StyleScreen: React.FC<StyleScreenProps> = ({
config,
styles,
onStyleSelected,
onBack,
}) => {
const { t } = useI18n();
const [selectedId, setSelectedId] = useState<string>(styles[0]?.id || "");
const handleConfirm = () => {
const chosen = styles.find((s) => s.id === selectedId) || styles[0];
if (chosen) {
onStyleSelected(chosen);
}
};
const currentStyle = styles.find((s) => s.id === selectedId);
return (
<div className="relative flex-1 flex flex-col justify-between p-3 sm:p-4 overflow-hidden select-none">
{/* Top Header - Back on left, Confirm on right */}
<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"
title="Back"
>
<ArrowLeft size={16} className="stroke-[2.5]" />
<span>{t(config.labels.backButton)}</span>
</button>
{/* 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"
>
<span>{t(config.labels.confirmStyleButton)}</span>
</button>
</div>
{/* Main Styles Grid (2x2) with Game Stone/Wood Frame Card Styling - Moved down with spacing */}
<div className="w-full max-w-sm mx-auto grid grid-cols-2 gap-3 z-20 mt-3 mb-auto py-1">
{styles.map((style) => {
const isSelected = selectedId === style.id;
const styleName = t(style.name);
return (
<div
key={style.id}
onClick={() => setSelectedId(style.id)}
className={`relative flex flex-col items-center p-2.5 rounded-2xl cursor-pointer transition-all duration-200 ${
isSelected
? "game-panel-amber ring-2 ring-amber-400 scale-[1.03] shadow-[0_8px_25px_rgba(249,115,22,0.6)]"
: "game-panel opacity-85 hover:opacity-100 hover:border-purple-400/80"
}`}
>
{isSelected && (
<div className="absolute -top-2.5 -right-2.5 w-7 h-7 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 border-2 border-black flex items-center justify-center text-black z-30 shadow-[0_2px_8px_rgba(0,0,0,0.8)]">
<Check size={16} className="stroke-[3.5]" />
</div>
)}
{/* Artwork Thumbnail */}
<div className="relative w-full aspect-square rounded-xl overflow-hidden bg-black/80 border-2 border-[#3d1e6d] shadow-inner">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={style.thumbnail}
alt={styleName}
className="w-full h-full object-cover select-none"
loading="lazy"
/>
</div>
{/* 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)]">
{styleName}
</span>
</div>
</div>
);
})}
</div>
{/* Selected Style Description Card & Mascot Comic Guide - Lifted up to slightly overlay bottom of style cards */}
<div className="w-full max-w-sm mx-auto z-30 flex items-center justify-start gap-1 px-1 -mt-5 sm:-mt-6 pb-2 pointer-events-none">
<MascotGuide
pose="punch"
size="md"
speechPosition="right"
speechText={{
en: "Choose your legendary martial transformation!",
zh: "挑选你专属的万圣节武道变身奥义!",
}}
/>
</div>
</div>
);
};