commit e04672db2aa9508e7bf15cce154bf20837d8d7ff Author: Ajasra Date: Wed Oct 28 01:20:01 2026 +0800 feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e722bb4 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Google Gemini API Key (Primary Provider) +GEMINI_API_KEY=your_gemini_api_key_here + +# OpenRouter API Key (Backup Provider) +OPENROUTER_API_KEY=your_openrouter_api_key_here + +# Selected model to use for generation: 'nanobanana 2', 'lite', or 'pro' +GENERATION_MODEL=nanobanana 2 + +# Optional: Port override +PORT=3000 + +# Admin Panel Password +ADMIN_PASSWORD=admin123 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a91cdc --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Dependencies +/node_modules +/.pnp +.pnp.js + +# Testing +/coverage + +# Next.js build output +/.next/ +/out/ + +# Production +/build + +# Environment variables & secrets +.env*.local +.env +!.env.example + +# Vercel +.vercel + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# OS / Editor files +.DS_Store +Thumbs.db +*.swp +.vscode/ +.idea/ + +# Uploaded / Generated user images & data backups +/data/images/ +/data/records.json +/data/*.json +/data/*.bak +/data/*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6ba7d0 --- /dev/null +++ b/README.md @@ -0,0 +1,221 @@ +# YEG SpookyBooth ๐ŸŽƒ + +> **Edmonton Halloween Magic Photo Experience** +> An interactive, responsive, and kiosk-ready photo experience celebrating Canadian autumn folklore and Edmonton's river valley magic. Built with **Next.js**, **React**, **TypeScript**, and **Tailwind CSS**. + +Users step into the enchanted portal, capture a photo, pick an iconic Canadian/Edmonton Halloween disguise, and receive a transformed 3D cartoon character portrait. + +--- + +## ๐ŸŒŸ Key Features + +- ๐Ÿ **Edmonton & Canadian Halloween Atmosphere**: + - Eerie shimmering **Northern Lights (Aurora Borealis)**. + - Silhouettes of the iconic **High Level Bridge**, **Walterdale Arch**, glowing **Muttart Conservatory Pyramids**, and river valley spruce trees. + - Radiant harvest moon, drifting autumn maple leaves, friendly bats, and a cute hovering ghost. +- ๐ŸŽจ **Stylized 3D Cartoon Character Disguises**: + - ๐ŸŒŸ **Vibe Matcher**: Keep your exact face, outfit, hair & makeup completely unchanged, but replace the background with a funny, exaggerated scene that matches your vibe! + - ๐ŸŽธ **Classic Mullet**: Keep your photo portrait intact while rocking an iconic 80s mullet hairstyle. + - ๐Ÿ›๏ธ **Muttart Pyramid Witch**: Whimsical young witch brewing emerald potions inside the glass conservatory pyramid. + - ๐Ÿฆ‡ **High Level Vampire**: Chibi vampire in a winter velvet coat hovering above the foggy bridge. +- ๐Ÿง™โ€โ™‚๏ธ **100% Magical Lore**: Zero robotic AI technical jargon. The entire user experience is written as enchanting spooky spellcraft. +- ๐Ÿ•น๏ธ **Tactile Cartoon Arcade UI**: + - Chunky 3D buttons (`.btn-cartoon-orange`, `.btn-cartoon-purple`, `.btn-cartoon-dark`) with satisfying downward click physics. + - Bold comic-book outlines and playful cartoon wobbles. +- ๐Ÿงญ **Complete Navigation**: + - **`โ† Back`** buttons across all intermediate screens (Camera, Confirm, Style Selection, and Loading). + - Dedicated **`Finish ๐Ÿ`** button on the final result screen to reset the booth. + - Top navigation bars use a strictly non-overlapping 3-column layout that never covers or blocks buttons. +- ๐Ÿ“ฒ **QR Code & Print Ready**: + - On-screen QR code allows visitors to scan and instantly save their portrait to their smartphone. + - Integrated 1-click photo printing formatted for 4x6 print media via `@media print`. +- ๐Ÿ›ก๏ธ **Intelligent Content Safety & Zero-Lockout Uptime**: + - Automatically identifies and handles image safety filter rejections (e.g. `PROHIBITED_CONTENT`, Safe-for-Work checks). + - Safety rejections immediately return the user to the camera/photo capture screen with an interactive Spooky Safety Warning modal to snap or choose another photo. + - No API cooldown lockouts: input rejections never penalize or disable the booth for subsequent guests. +- โฑ๏ธ **Kiosk Inactivity Auto-Reset**: Automatically resets to the standby welcome screen after $N$ seconds of inactivity. +- ๐Ÿท๏ธ **Sokaris Studio Attribution**: Built-in footer attribution and copyright link to [sokaris.com](https://sokaris.com). + +--- + +## ๐Ÿ–ฅ๏ธ Display Modes + +### 1. Standard Mode (Default) +By default, the booth renders in a centered, vertical 9:16 kiosk viewport (`max-width: 540px`, `max-height: 960px`) on desktop and automatically expands on mobile screens. Ideal for testing, desktop browsing, and tablets: +``` +http://localhost:3000 +``` + +### 2. Fullscreen Kiosk Mode (`?mode=fullscreen`) +For physical event touchscreens, vertical digital signage, or dedicated kiosk hardware, add `?mode=fullscreen` to the URL: +``` +http://localhost:3000/?mode=fullscreen +``` +**What this does:** +1. Expands the viewport to **100% screen width and 100% screen height** (`100vw` x `100vh`). +2. Removes all desktop borders, rounded corners, and margins. +3. Automatically triggers native browser fullscreen (`document.documentElement.requestFullscreen()`) upon the user's first tap. + +--- + +## ๐Ÿš€ Quick Start + +### 1. Prerequisites +- **Node.js** 18.17+ or 20+ +- **npm** or **yarn** + +### 2. Install Dependencies +```cmd +cmd /c npm install +``` + +### 3. Configure Environment Variables +Copy `.env.example` to `.env.local` and add your API credentials: +```env +# Google Gemini API Key (Primary Provider) +GEMINI_API_KEY=your_gemini_api_key_here + +# OpenRouter API Key (Automatic Backup Provider) +OPENROUTER_API_KEY=your_openrouter_api_key_here + +# Model Selection Override (Optional: "nanobanana 2", "nanobanana", "pro", "lite") +GENERATION_MODEL=nanobanana 2 +``` + +*(If no keys are provided, the app will run gracefully in demo fallback mode using pre-rendered sample portraits).* + +### 4. Run Development Server +```cmd +cmd /c npm run dev +``` +Open [http://localhost:3000](http://localhost:3000) (or [http://localhost:3000/?mode=fullscreen](http://localhost:3000/?mode=fullscreen)) in your browser. + +### 5. Build for Production +```cmd +cmd /c npm run build +cmd /c npm start +``` + +--- + +## โš™๏ธ Configuration Guide + +### 1. General Settings (`config/booth.config.json`) + +All booth behaviors and visuals can be customized without editing code: + +```json +{ + "appName": "YEG SpookyBooth", + "kioskMode": true, + "theme": { + "backgroundType": "color", + "backgroundColor": "#0f091f", + "primaryColor": "#ff6d00", + "accentColor": "#22c55e", + "surfaceColor": "#1e1136", + "textColor": "#ffffff", + "logoUrl": "/assets/brand/spooky_logo.svg", + "logoWidth": 260 + }, + "camera": { + "defaultFacingMode": "user", + "aspectRatio": "9:16", + "countdownSeconds": 3, + "allowUpload": true, + "maxUploadSizeMb": 10 + }, + "features": { + "showQrCode": true, + "showDownloadButton": true, + "showPrintButton": true, + "showRetakeButton": true + }, + "limits": { + "maxGenerationsPerDevice": 0, + "cooldownMinutes": 60 + }, + "kiosk": { + "enableAutoReset": true, + "idleTimeoutSeconds": 90 + }, + "labels": { + "welcomeTitle": "YEG SPOOKY BOOTH", + "welcomeSubtitle": "Edmonton Halloween Magic Portal", + "welcomeCta": "Tap To Enter If You Dare, Eh! ๐ŸŽƒ", + "cameraInstruction": "Strike your most spooktacular pose!", + "cameraSnap": "Snap Spooky Photo", + "cameraUpload": "Upload Photo", + "confirmTitle": "Review Your Spooky Shot", + "styleSelectionTitle": "Pick Your Halloween Disguise", + "styleSelectionSubtitle": "Choose an iconic local legend to transform into", + "processingTitle": "Brewing Your Transformation Spell...", + "processingSubtitle": "Mixing river valley mist, northern starlight & potion sparks...", + "resultTitle": "Your Spooktacular Portrait! ๐ŸŽƒ", + "scanQrLabel": "Scan to take your treat home", + "downloadLabel": "Save Portrait", + "printLabel": "Print Photo", + "startOverLabel": "Haunt Again" + } +} +``` + +### 2. Styles & Prompts (`config/styles.json`) + +To add, remove, or modify character disguise presets, edit `config/styles.json`: + +```json +[ + { + "id": "yeg_hockey_phantom", + "name": "YEG Hockey Phantom", + "description": "Friendly vintage hockey ghost on river ice under the glowing Northern Lights", + "thumbnail": "/assets/styles/hockey_ghost.jpg", + "prompt": "Vibrant 3D cartoon animated character portrait based on the person in the user's photo... Bubbly cartoon animation style, Hotel Transylvania and Pixar aesthetics...", + "negativePrompt": "scary, bloody, gore, grotesque, deformed hands, bad anatomy, realistic human skin pores, text watermark" + } +] +``` + +--- + +## ๐Ÿ“ Project Structure + +``` +โ”œโ”€โ”€ app/ +โ”‚ โ”œโ”€โ”€ api/ +โ”‚ โ”‚ โ”œโ”€โ”€ config/route.ts # API endpoint serving booth config & styles +โ”‚ โ”‚ โ””โ”€โ”€ generate/route.ts # AI image transformation endpoint with failover +โ”‚ โ”œโ”€โ”€ globals.css # Design tokens, cartoon button physics & animations +โ”‚ โ”œโ”€โ”€ layout.tsx # Root HTML layout & meta tags +โ”‚ โ””โ”€โ”€ page.tsx # Main state machine routing screens +โ”œโ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ KioskFrame.tsx # Kiosk frame, ?mode=fullscreen handler & footer +โ”‚ โ”œโ”€โ”€ SpookyAtmosphere.tsx # Animated Edmonton skyline, aurora & falling leaves +โ”‚ โ””โ”€โ”€ screens/ +โ”‚ โ”œโ”€โ”€ StandbyScreen.tsx # Welcome screen with bouncy hero pumpkin +โ”‚ โ”œโ”€โ”€ CameraScreen.tsx # Camera capture, countdown, and face guide +โ”‚ โ”œโ”€โ”€ ConfirmScreen.tsx # Photo review with retake / confirm +โ”‚ โ”œโ”€โ”€ StyleScreen.tsx # 2x2 grid of cartoon character cards +โ”‚ โ”œโ”€โ”€ LoadingScreen.tsx # Cauldron animation with magical brewing steps +โ”‚ โ”œโ”€โ”€ ResultScreen.tsx # Confetti celebration, download, QR code & Finish +โ”‚ โ””โ”€โ”€ RateLimitedScreen.tsx# Rate-limit cooldown screen +โ”œโ”€โ”€ config/ +โ”‚ โ”œโ”€โ”€ booth.config.json # Main configuration file +โ”‚ โ””โ”€โ”€ styles.json # Character styles and prompt presets +โ”œโ”€โ”€ lib/ +โ”‚ โ”œโ”€โ”€ ai/ # Gemini & OpenRouter provider orchestration +โ”‚ โ”œโ”€โ”€ config.ts # Server-side configuration loader +โ”‚ โ””โ”€โ”€ ratelimit.ts # Device generation limits & cooldown tracker +โ””โ”€โ”€ public/ + โ””โ”€โ”€ assets/ + โ”œโ”€โ”€ brand/ # Spooky logo SVG & Sokaris Studio logos + โ””โ”€โ”€ styles/ # Cartoon character artwork images +``` + +--- + +## ๐Ÿท๏ธ Credits & License + +- Designed and developed for **YEG Halloween Photo Experiences**. +- Attribution & branding by **Sokaris Studio** ([sokaris.com](https://sokaris.com)). diff --git a/app/api/config/route.ts b/app/api/config/route.ts new file mode 100644 index 0000000..18332ff --- /dev/null +++ b/app/api/config/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getBoothConfig, getBoothStyles } from "@/lib/config"; + +export async function GET() { + const config = getBoothConfig(); + const styles = getBoothStyles(); + + // Return public safe configuration (omit any secret internal options) + return NextResponse.json({ + appName: config.appName, + kioskMode: config.kioskMode, + theme: config.theme, + camera: config.camera, + features: config.features, + limits: config.limits, + kiosk: config.kiosk, + labels: config.labels, + styles, + }); +} diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 0000000..c10a6a2 --- /dev/null +++ b/app/api/generate/route.ts @@ -0,0 +1,168 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBoothConfig, getBoothStyles } from "@/lib/config"; +import { checkRateLimit, incrementRateLimit } from "@/lib/rate-limit"; +import { orchestrateGeneration } from "@/lib/ai/orchestrator"; +import { isContentSafetyError } from "@/lib/ai/safety"; +import { logGenerationEvent } from "@/lib/storage"; +import { validateAndSanitizeImage, getClientIp } from "@/lib/security"; + +export async function POST(req: NextRequest) { + const requestStartTime = Date.now(); + let clientId = "127.0.0.1"; + let selectedStyle: any = null; + let requestedStyleId = ""; + + try { + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json( + { error: "Invalid JSON request body" }, + { status: 400 } + ); + } + + const { imageBase64, styleId, clientId: clientProvidedId, mimeType } = body; + requestedStyleId = typeof styleId === "string" ? styleId.slice(0, 64) : ""; + + const config = getBoothConfig(); + + // 1. Image binary validation & magic bytes verification (rejects executables, scripts, etc.) + const maxUploadBytes = (config.camera.maxUploadSizeMb || 10) * 1024 * 1024; + const validation = validateAndSanitizeImage(imageBase64, maxUploadBytes); + + if (!validation.valid) { + console.warn(`[Security Alert] Rejected image upload: ${validation.error}`); + return NextResponse.json( + { error: validation.error || "Invalid or disallowed image file" }, + { status: 400 } + ); + } + + const styles = getBoothStyles(); + + // Find requested style + selectedStyle = styles.find((s) => s.id === styleId) || styles[0]; + if (!selectedStyle) { + return NextResponse.json( + { error: "No styles available in configuration" }, + { status: 400 } + ); + } + + // Determine client identifier for rate-limiting (bind strictly to verified IP) + const ip = getClientIp(req); + // Sanitize optional device token if provided, but always prefix with verified IP so clients cannot evade IP quotas + const safeClientTag = typeof clientProvidedId === "string" + ? clientProvidedId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 32) + : ""; + clientId = safeClientTag ? `${ip}#${safeClientTag}` : ip; + + // Check device rate limit + const limitCheck = checkRateLimit( + clientId, + config.limits.maxGenerationsPerDevice, + config.limits.cooldownMinutes + ); + + if (!limitCheck.allowed) { + // Record rate limit refusal + await logGenerationEvent({ + styleId: selectedStyle.id, + styleName: selectedStyle.name, + provider: "unknown", + model: "rate_limiter", + durationMs: Date.now() - requestStartTime, + status: "refused", + errorType: "rate_limit", + errorMessage: "Device reached maximum generation quota within cooldown window.", + refusalReason: `Rate limit hit. Wait ${limitCheck.resetMinutes} min.`, + clientId, + }); + + return NextResponse.json( + { + error: "Rate limit reached for this device.", + remaining: 0, + resetMinutes: limitCheck.resetMinutes, + }, + { status: 429 } + ); + } + + // Execute generation pipeline with sanitized image and verified MIME type + const result = await orchestrateGeneration({ + config, + style: selectedStyle, + imageBase64: validation.sanitizedBase64!, + mimeType: validation.mimeType, + }); + + // Save successful generation & image file to persistent storage + const record = await logGenerationEvent({ + styleId: selectedStyle.id, + styleName: selectedStyle.name, + provider: result.providerUsed, + model: result.modelUsed, + durationMs: result.durationMs, + status: "success", + rawImageUrl: result.imageUrl, + clientId, + }); + + // Increment usage for this device + incrementRateLimit(clientId); + + const updatedLimit = checkRateLimit( + clientId, + config.limits.maxGenerationsPerDevice, + config.limits.cooldownMinutes + ); + + const servedImageUrl = record.imageUrl || result.imageUrl; + + return NextResponse.json({ + success: true, + imageUrl: servedImageUrl, + providerUsed: result.providerUsed, + modelUsed: result.modelUsed, + durationMs: result.durationMs, + fallbackTriggered: result.fallbackTriggered, + remainingQuota: updatedLimit.remaining, + warnings: result.warnings, + }); + } catch (error: any) { + console.error("[API Generate] Exception:", error); + const isSafety = isContentSafetyError(error); + + // Record error or content safety refusal in admin logs + try { + await logGenerationEvent({ + styleId: selectedStyle?.id || requestedStyleId || "unknown", + styleName: selectedStyle?.name || "Unknown Disguise", + provider: error.provider || "unknown", + model: error.model || "ai-pipeline", + durationMs: Date.now() - requestStartTime, + status: isSafety ? "refused" : "error", + errorType: isSafety ? "safety_refusal" : "server_error", + errorMessage: error.message || "An unexpected error occurred during generation.", + refusalReason: isSafety + ? (error.message || "Content safety filter blocked output") + : undefined, + clientId, + }); + } catch (logErr) { + console.error("[API Generate] Failed to log error event:", logErr); + } + + return NextResponse.json( + { + success: false, + isSafetyRejection: isSafety, + error: isSafety + ? "This photo was rejected by the content safety filter. Please snap or select a different photo." + : (error.message || "An unexpected error occurred during generation."), + }, + { status: isSafety ? 422 : 500 } + ); + } +} diff --git a/app/api/images/[filename]/route.ts b/app/api/images/[filename]/route.ts new file mode 100644 index 0000000..b589346 --- /dev/null +++ b/app/api/images/[filename]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; + +export async function GET( + req: NextRequest, + { params }: { params: { filename: string } } +) { + try { + const rawFilename = params.filename; + + // 1. Strict filename validation: alphanumeric + hyphens/underscores + safe image extensions only + const validFilenameRegex = /^[a-zA-Z0-9_-]+\.(jpg|jpeg|png|webp)$/i; + if (!rawFilename || !validFilenameRegex.test(rawFilename)) { + return new NextResponse("Bad Request: Invalid or disallowed image filename", { status: 400 }); + } + + const filename = path.basename(rawFilename); + const imagesDir = path.resolve(process.cwd(), "data", "images"); + const filePath = path.resolve(imagesDir, filename); + + // 2. Prevent path traversal outside data/images directory + if (!filePath.startsWith(imagesDir)) { + return new NextResponse("Access Denied", { status: 403 }); + } + + if (!fs.existsSync(filePath)) { + return new NextResponse("Image Not Found", { status: 404 }); + } + + const fileBuffer = fs.readFileSync(filePath); + const ext = path.extname(filename).toLowerCase(); + + let contentType = "image/png"; + if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg"; + else if (ext === ".webp") contentType = "image/webp"; + + return new NextResponse(fileBuffer, { + status: 200, + headers: { + "Content-Type": contentType, + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Disposition": `inline; filename="${filename}"`, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch (error) { + console.error("[API Image Serve] Error reading file:", error); + return new NextResponse("Server Error", { status: 500 }); + } +} diff --git a/app/api/portal/auth/route.ts b/app/api/portal/auth/route.ts new file mode 100644 index 0000000..2c9ced6 --- /dev/null +++ b/app/api/portal/auth/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getExpectedToken, isAuthenticated } from "@/lib/admin-auth"; +import { + getClientIp, + constantTimeCompare, + checkAuthRateLimit, + recordAuthFailure, + recordAuthSuccess, +} from "@/lib/security"; + +export async function POST(req: NextRequest) { + const ip = getClientIp(req); + + // 1. Check brute-force lockout + const authLimit = checkAuthRateLimit(ip, 5, 15); + if (!authLimit.allowed) { + const waitMins = Math.ceil(authLimit.lockoutSeconds / 60); + console.warn(`[Portal Auth Security] IP ${ip} blocked due to too many failed login attempts.`); + return NextResponse.json( + { + success: false, + error: `Too many failed login attempts. IP temporarily blocked. Try again in ${waitMins} minute${waitMins > 1 ? "s" : ""}.`, + }, + { status: 429 } + ); + } + + try { + const body = await req.json().catch(() => ({})); + const password = body.password; + const expectedPassword = process.env.ADMIN_PASSWORD || "admin123"; + + // 2. Validate password using timing-safe comparison + if (!password || typeof password !== "string" || !constantTimeCompare(password, expectedPassword)) { + recordAuthFailure(ip, 5, 15); + const remaining = authLimit.remainingAttempts - 1; + return NextResponse.json( + { + success: false, + error: "Incorrect password", + remainingAttempts: Math.max(0, remaining), + }, + { status: 401 } + ); + } + + // 3. Clear failed attempts on successful login + recordAuthSuccess(ip); + + const token = getExpectedToken(); + const response = NextResponse.json({ + success: true, + token, + message: "Authentication successful", + }); + + response.cookies.set({ + name: "booth_portal_token", + value: token, + httpOnly: true, + path: "/", + maxAge: 60 * 60 * 24 * 7, // 7 days + sameSite: "lax", + }); + + return response; + } catch (error: any) { + console.error("[Portal Auth] Login error:", error); + return NextResponse.json( + { success: false, error: "Internal authentication error" }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + const authed = isAuthenticated(req); + if (authed) { + return NextResponse.json({ authenticated: true }); + } + return NextResponse.json({ authenticated: false }, { status: 401 }); +} + +export async function DELETE() { + const response = NextResponse.json({ success: true, message: "Logged out" }); + response.cookies.set({ + name: "booth_portal_token", + value: "", + httpOnly: true, + path: "/", + maxAge: 0, + sameSite: "lax", + }); + return response; +} diff --git a/app/api/portal/stats/route.ts b/app/api/portal/stats/route.ts new file mode 100644 index 0000000..4afa6cc --- /dev/null +++ b/app/api/portal/stats/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/lib/admin-auth"; +import { getGenerationStats } from "@/lib/storage"; + +export async function GET(req: NextRequest) { + if (!isAuthenticated(req)) { + return NextResponse.json( + { error: "Unauthorized. Please login." }, + { status: 401 } + ); + } + + try { + const { searchParams } = new URL(req.url); + const periodParam = searchParams.get("period") as any; + const rawFromDate = searchParams.get("from") || undefined; + const rawToDate = searchParams.get("to") || undefined; + + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + const fromDate = rawFromDate && dateRegex.test(rawFromDate) ? rawFromDate : undefined; + const toDate = rawToDate && dateRegex.test(rawToDate) ? rawToDate : undefined; + + const validPeriods = ["day", "week", "month", "all", "custom"]; + const period = validPeriods.includes(periodParam) ? periodParam : "week"; + + const stats = getGenerationStats({ + period, + fromDate, + toDate, + }); + + return NextResponse.json({ + success: true, + stats, + }); + } catch (error: any) { + console.error("[Portal Stats API] Error:", error); + return NextResponse.json( + { success: false, error: "Failed to retrieve statistics" }, + { status: 500 } + ); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..aeb7a0d --- /dev/null +++ b/app/globals.css @@ -0,0 +1,462 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Local Standalone Self-Hosted Fonts (Zero 3rd-party dependencies) */ +@font-face { + font-family: 'CinzelTitle'; + src: url('/fonts/Cinzel.ttf') format('truetype'); + font-weight: 700 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'DojoFun'; + src: url('/fonts/ZCOOLKuaiLe.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +.font-cinzel { + font-family: 'CinzelTitle', 'Georgia', serif; +} + +.font-dojo { + font-family: 'DojoFun', 'PingFang SC', 'Microsoft YaHei', sans-serif; +} + +/* Fantasy Game UI & Spooky UI Elements */ +.game-panel { + background: linear-gradient(180deg, #1f1438 0%, #120a22 100%); + border: 2px solid #5b358a; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.15), + inset 0 -2px 0 rgba(0, 0, 0, 0.6), + 0 8px 24px rgba(0, 0, 0, 0.7), + 0 0 15px rgba(168, 85, 247, 0.2); + border-radius: 16px; + position: relative; +} + +.game-panel-amber { + background: linear-gradient(180deg, #241432 0%, #150920 100%); + border: 2px solid #9a531e; + box-shadow: + inset 0 1px 0 rgba(251, 191, 36, 0.25), + inset 0 -2px 0 rgba(0, 0, 0, 0.7), + 0 8px 24px rgba(0, 0, 0, 0.8), + 0 0 20px rgba(249, 115, 22, 0.25); + border-radius: 18px; + position: relative; +} + +.game-btn-gold { + background: linear-gradient(180deg, #ff9d00 0%, #ea580c 45%, #c2410c 100%); + border-top: 2px solid #fef08a; + border-left: 2px solid #f59e0b; + border-right: 2px solid #f59e0b; + border-bottom: 3px solid #7c2d12; + box-shadow: + 0 6px 0 #431407, + 0 10px 25px rgba(234, 88, 12, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.4); + color: #ffffff; + text-shadow: 0 2px 2px rgba(0, 0, 0, 0.8); + border-radius: 14px; + transition: all 0.15s ease; +} + +.game-btn-gold:hover { + filter: brightness(1.1); +} + +.game-btn-gold:active { + transform: translateY(4px); + box-shadow: + 0 2px 0 #431407, + 0 4px 10px rgba(234, 88, 12, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.4); +} + +.game-btn-dark { + background: linear-gradient(180deg, #2d1a4d 0%, #1b0e32 100%); + border-top: 1.5px solid #a855f7; + border-left: 1.5px solid #7e22ce; + border-right: 1.5px solid #7e22ce; + border-bottom: 2.5px solid #3b0764; + box-shadow: + 0 4px 0 #090314, + inset 0 1px 0 rgba(255, 255, 255, 0.2); + color: #f3e8ff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); + border-radius: 14px; + transition: all 0.15s ease; +} + +.game-btn-dark:active { + transform: translateY(2px); + box-shadow: 0 2px 0 #090314; +} + +/* Ornate RPG Ribbon Banner Header */ +.game-ribbon { + background: linear-gradient(180deg, #7c3aed 0%, #581c87 100%); + border: 1.5px solid #f59e0b; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.6), + 0 0 12px rgba(245, 158, 11, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.35); + position: relative; +} + +:root { + --brand-primary: #ff6d00; + --brand-accent: #22c55e; + --brand-bg: #0f091f; + --brand-surface: #1e1136; + --brand-text: #ffffff; + --brand-muted: rgba(238, 230, 255, 0.75); + --sat: env(safe-area-inset-top, 0px); + --sab: env(safe-area-inset-bottom, 0px); + --sal: env(safe-area-inset-left, 0px); + --sar: env(safe-area-inset-right, 0px); +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +body { + margin: 0; + padding: 0; + background-color: var(--brand-bg); + color: var(--brand-text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + overflow: hidden; + height: 100vh; + height: 100dvh; + width: 100vw; + width: 100dvw; + overscroll-behavior: none; + touch-action: manipulation; +} + +/* Kiosk and Responsive Container */ +.kiosk-viewport { + width: 100%; + height: 100%; + height: 100dvh; + max-width: 540px; /* Optimal vertical kiosk aspect on desktop */ + max-height: 960px; + position: relative; + overflow: hidden; + box-shadow: 0 25px 60px -10px rgba(0, 0, 0, 0.7), 0 0 40px rgba(168, 85, 247, 0.15); + display: flex; + flex-direction: column; +} + +@media (max-width: 640px) { + .kiosk-viewport { + width: 100vw; + width: 100dvw; + height: 100vh; + height: 100dvh; + max-width: 100vw; + max-height: 100dvh; + border-radius: 0 !important; + border-width: 0 !important; + box-shadow: none !important; + } +} + +/* Fullscreen Mode URL parameter reset (?mode=fullscreen) */ +body:has([data-fullscreen="true"]) { + padding: 0 !important; + background-color: var(--brand-bg); +} + +/* Subtle border vignette */ +.bg-radial-vignette { + background: radial-gradient(circle at center, transparent 60%, rgba(10, 4, 20, 0.6) 100%); +} + +/* Spooky Halloween Animations */ +@keyframes auroraWave { + 0%, 100% { + transform: translateX(-5%) skewY(-2deg) scaleY(0.9); + opacity: 0.35; + } + 50% { + transform: translateX(5%) skewY(2deg) scaleY(1.15); + opacity: 0.55; + } +} + +@keyframes auroraPulse { + 0%, 100% { + transform: scale(0.95); + opacity: 0.25; + } + 50% { + transform: scale(1.05); + opacity: 0.45; + } +} + +@keyframes cloudDrift { + 0% { + transform: translateX(-15px); + opacity: 0.4; + } + 50% { + transform: translateX(20px); + opacity: 0.7; + } + 100% { + transform: translateX(-15px); + opacity: 0.4; + } +} + +@keyframes batFlutter { + 0%, 100% { + transform: translate(0, 0) rotate(0deg) scale(1); + } + 25% { + transform: translate(6px, -8px) rotate(6deg) scale(1.05); + } + 50% { + transform: translate(-4px, -12px) rotate(-4deg) scale(0.95); + } + 75% { + transform: translate(8px, -4px) rotate(4deg) scale(1.02); + } +} + +@keyframes ghostFloat { + 0%, 100% { + transform: translateY(0) rotate(0deg); + } + 50% { + transform: translateY(-12px) rotate(3deg); + } +} + +@keyframes leafDrift { + 0% { + transform: translateY(-20px) translateX(0) rotate(0deg); + opacity: 0; + } + 15% { + opacity: 0.85; + } + 50% { + transform: translateY(400px) translateX(30px) rotate(180deg); + opacity: 0.8; + } + 85% { + opacity: 0.7; + } + 100% { + transform: translateY(960px) translateX(-20px) rotate(360deg); + opacity: 0; + } +} + +.animate-aurora-wave { + animation: auroraWave 12s ease-in-out infinite alternate; +} + +.animate-aurora-pulse { + animation: auroraPulse 8s ease-in-out infinite alternate; +} + +.animate-cloud-drift { + animation: cloudDrift 18s ease-in-out infinite; +} + +.bat-1 { + animation: batFlutter 6s ease-in-out infinite; +} +.bat-2 { + animation: batFlutter 8s ease-in-out 1.5s infinite; +} +.bat-3 { + animation: batFlutter 7s ease-in-out 3s infinite; +} + +.spooky-ghost { + animation: ghostFloat 4.5s ease-in-out infinite; +} + +.leaf-1 { + animation: leafDrift 14s linear infinite; +} +.leaf-2 { + animation: leafDrift 18s linear 4s infinite; +} +.leaf-3 { + animation: leafDrift 16s linear 8s infinite; +} + +/* Accessible Reduced Motion Settings */ +@media (prefers-reduced-motion: reduce) { + .animate-aurora-wave, + .animate-aurora-pulse, + .animate-cloud-drift, + .bat-1, + .bat-2, + .bat-3, + .spooky-ghost, + .leaf-1, + .leaf-2, + .leaf-3 { + animation: none !important; + transform: none !important; + } +} + +/* Pulsing loader dots */ +@keyframes dotPulse { + 0%, 100% { + transform: scale(0.8); + opacity: 0.4; + } + 50% { + transform: scale(1.3); + opacity: 1; + } +} + +.animate-dot-1 { + animation: dotPulse 1.4s ease-in-out infinite; +} +.animate-dot-2 { + animation: dotPulse 1.4s ease-in-out 0.2s infinite; +} +.animate-dot-3 { + animation: dotPulse 1.4s ease-in-out 0.4s infinite; +} + +/* Cartoon UI Utilities (Tactile, chunky, playful) */ +.btn-cartoon-orange { + background: linear-gradient(180deg, #ff9100 0%, #ff6d00 50%, #ea580c 100%); + border: 3px solid #1a0826; + box-shadow: 0 6px 0 #9a3412, 0 10px 15px rgba(0, 0, 0, 0.4); + transition: all 0.12s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.btn-cartoon-orange:hover { + filter: brightness(1.08); + transform: translateY(-2px); + box-shadow: 0 8px 0 #9a3412, 0 14px 20px rgba(0, 0, 0, 0.5); +} +.btn-cartoon-orange:active { + transform: translateY(4px); + box-shadow: 0 2px 0 #9a3412, 0 4px 6px rgba(0, 0, 0, 0.4); +} + +.btn-cartoon-purple { + background: linear-gradient(180deg, #a855f7 0%, #7e22ce 50%, #581c87 100%); + border: 3px solid #1a0826; + box-shadow: 0 6px 0 #3b0764, 0 10px 15px rgba(0, 0, 0, 0.4); + transition: all 0.12s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.btn-cartoon-purple:hover { + filter: brightness(1.08); + transform: translateY(-2px); + box-shadow: 0 8px 0 #3b0764, 0 14px 20px rgba(0, 0, 0, 0.5); +} +.btn-cartoon-purple:active { + transform: translateY(4px); + box-shadow: 0 2px 0 #3b0764, 0 4px 6px rgba(0, 0, 0, 0.4); +} + +.btn-cartoon-instagram { + background: linear-gradient(135deg, #833ab4 0%, #c13584 30%, #e1306c 60%, #fd1d1d 80%, #f77737 100%); + border: 3px solid #1a0826; + box-shadow: 0 6px 0 #4c0519, 0 10px 15px rgba(0, 0, 0, 0.4); + transition: all 0.12s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.btn-cartoon-instagram:hover { + filter: brightness(1.08); + transform: translateY(-2px); + box-shadow: 0 8px 0 #4c0519, 0 14px 20px rgba(0, 0, 0, 0.5); +} +.btn-cartoon-instagram:active { + transform: translateY(4px); + box-shadow: 0 2px 0 #4c0519, 0 4px 6px rgba(0, 0, 0, 0.4); +} + +.btn-cartoon-dark { + background: linear-gradient(180deg, #2e1065 0%, #1e0e38 100%); + border: 3px solid #090314; + box-shadow: 0 5px 0 #090314, 0 8px 12px rgba(0, 0, 0, 0.4); + transition: all 0.12s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.btn-cartoon-dark:hover { + transform: translateY(-2px); + box-shadow: 0 7px 0 #090314, 0 12px 16px rgba(0, 0, 0, 0.5); +} +.btn-cartoon-dark:active { + transform: translateY(3px); + box-shadow: 0 2px 0 #090314, 0 4px 6px rgba(0, 0, 0, 0.4); +} + +.card-cartoon { + border: 3px solid #1a0826; + box-shadow: 0 6px 0 rgba(10, 4, 20, 0.8), 0 12px 20px rgba(0, 0, 0, 0.5); +} + +.card-cartoon-selected { + border: 3px solid #ff9100; + box-shadow: 0 6px 0 #9a3412, 0 0 24px rgba(255, 145, 0, 0.5); +} + +@keyframes cartoonWobble { + 0%, 100% { + transform: rotate(0deg) scale(1); + } + 25% { + transform: rotate(-3deg) scale(1.03); + } + 75% { + transform: rotate(3deg) scale(1.03); + } +} + +.animate-cartoon-wobble { + animation: cartoonWobble 3s ease-in-out infinite; +} + +/* Media Print Optimization */ +@media print { + body { + background: #ffffff !important; + color: #000000 !important; + } + + .no-print { + display: none !important; + } + + .print-only-container { + display: flex !important; + justify-content: center; + align-items: center; + width: 100vw; + height: 100vh; + margin: 0; + padding: 0; + } + + .print-photo { + max-width: 100%; + max-height: 100%; + object-fit: contain; + page-break-inside: avoid; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..e3863f4 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata, Viewport } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Spooky Hand ยท ไธ‡ๅœฃ็ฉบๆ‰‹้“้ฆ† | Shanghai Karate Club", + description: + "ไธŠๆตทๅฅๅฟ—็ฉบๆ‰‹้“้ฆ†ไธ‡ๅœฃ่Š‚ไธ“ๅฑž็…ง็›ธ้ฆ† (Shanghai Karate Club Halloween Photobooth) - ๅ‡่šไธ‡ๅœฃๆฐ”ๅˆ๏ผŒๅฎšๆ ผๆญฆ้“่‹ฑๅงฟ๏ผ", + icons: { + icon: "/assets/brand/shanghai_karate_logo.png", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + viewportFit: "cover", + themeColor: "#0b0714", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..33deee5 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,270 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { AlertTriangle, Camera } from "lucide-react"; +import { KioskFrame } from "@/components/KioskFrame"; +import { StandbyScreen } from "@/components/screens/StandbyScreen"; +import { CameraScreen } from "@/components/screens/CameraScreen"; +import { ConfirmScreen } from "@/components/screens/ConfirmScreen"; +import { StyleScreen } from "@/components/screens/StyleScreen"; +import { LoadingScreen } from "@/components/screens/LoadingScreen"; +import { ResultScreen } from "@/components/screens/ResultScreen"; +import { RateLimitedScreen } from "@/components/screens/RateLimitedScreen"; +import { BoothConfig, BoothStyle, ScreenState } from "@/types/booth"; +import { I18nProvider, useI18n } from "@/lib/i18n"; + +function PhotoBoothApp() { + const [config, setConfig] = useState(null); + const [styles, setStyles] = useState([]); + const [screen, setScreen] = useState("standby"); + const [capturedPhoto, setCapturedPhoto] = useState(null); + const [selectedStyle, setSelectedStyle] = useState(null); + const [resultImage, setResultImage] = useState(null); + const [providerInfo, setProviderInfo] = useState(null); + const [rateLimitResetMinutes, setRateLimitResetMinutes] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); + const [safetyWarning, setSafetyWarning] = useState(null); + const [clientId, setClientId] = useState(""); + + const idleTimerRef = useRef(null); + const { t } = useI18n(); + + useEffect(() => { + let storedId = localStorage.getItem("booth_client_id"); + if (!storedId) { + storedId = "dev_" + Math.random().toString(36).substring(2, 12); + localStorage.setItem("booth_client_id", storedId); + } + setClientId(storedId); + + fetch("/api/config") + .then((res) => res.json()) + .then((data) => { + if (data.styles) { + setStyles(data.styles); + } + setConfig(data); + + // URL parameter support for direct previewing, e.g. /?preview=result + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const previewScreen = params.get("preview") as ScreenState | null; + if (previewScreen === "result") { + const previewImg = params.get("image") || "/assets/styles/jack_o_lantern_sensei.jpg"; + setResultImage(previewImg); + setScreen("result"); + } + } + }) + .catch((err) => console.error("Failed to load booth config:", err)); + }, []); + + const resetIdleTimer = useCallback(() => { + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + } + if (!config?.kiosk?.enableAutoReset || screen === "standby") { + return; + } + const timeoutMs = (config.kiosk.idleTimeoutSeconds || 90) * 1000; + idleTimerRef.current = setTimeout(() => { + console.log("[Kiosk] Idle timeout reached. Resetting to standby..."); + setScreen("standby"); + setCapturedPhoto(null); + setSelectedStyle(null); + setResultImage(null); + setErrorMessage(null); + setSafetyWarning(null); + }, timeoutMs); + }, [config, screen]); + + useEffect(() => { + const handleActivity = () => resetIdleTimer(); + window.addEventListener("pointerdown", handleActivity); + window.addEventListener("keydown", handleActivity); + resetIdleTimer(); + return () => { + window.removeEventListener("pointerdown", handleActivity); + window.removeEventListener("keydown", handleActivity); + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + } + }; + }, [resetIdleTimer]); + + const handleStart = () => { + setErrorMessage(null); + setSafetyWarning(null); + setScreen("camera"); + }; + + const handlePhotoCaptured = (photoBase64: string) => { + setCapturedPhoto(photoBase64); + setScreen("confirm"); + }; + + const handleRetake = () => { + setCapturedPhoto(null); + setScreen("camera"); + }; + + const handleConfirmPhoto = () => { + setScreen("style"); + }; + + const handleStyleSelected = async (style: BoothStyle) => { + setSelectedStyle(style); + setScreen("processing"); + setErrorMessage(null); + setSafetyWarning(null); + + try { + const response = await fetch("/api/generate", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-client-id": clientId, + }, + body: JSON.stringify({ + imageBase64: capturedPhoto, + styleId: style.id, + }), + }); + + const data = await response.json(); + + if (response.status === 429) { + setRateLimitResetMinutes(data.resetInMinutes || 60); + setScreen("rate_limited"); + return; + } + + if (!response.ok) { + if (data.isSafetyViolation) { + setSafetyWarning(data.error); + } else { + setErrorMessage(data.error || "Generation failed. Please try again."); + } + setScreen("style"); + return; + } + + setResultImage(data.imageUrl); + setProviderInfo({ + provider: data.provider, + model: data.model, + durationMs: data.durationMs, + fallbackTriggered: data.fallbackTriggered, + }); + setScreen("result"); + } catch (err: any) { + console.error("API call error:", err); + setErrorMessage( + t({ + en: "Network error. Please check your internet connection and try again.", + zh: "็ฝ‘็ปœ่ฟžๆŽฅๅผ‚ๅธธ๏ผŒ่ฏทๆฃ€ๆŸฅ็ฝ‘็ปœๅŽ้‡่ฏ•ใ€‚" + }) + ); + setScreen("style"); + } + }; + + const handleStartOver = () => { + setCapturedPhoto(null); + setSelectedStyle(null); + setResultImage(null); + setErrorMessage(null); + setSafetyWarning(null); + setScreen("standby"); + }; + + if (!config) { + return ( +
+
+
+

๐Ÿฅ‹ ๅŠ ่ฝฝไธ‡ๅœฃ้“้ฆ†ไธญ...

+
+
+ ); + } + + return ( + + {safetyWarning && ( +
+ + {safetyWarning} +
+ )} + + {errorMessage && ( +
+ + {errorMessage} +
+ )} + + {screen === "standby" && ( + + )} + + {screen === "camera" && ( + + )} + + {screen === "confirm" && capturedPhoto && ( + + )} + + {screen === "style" && ( + setScreen("confirm")} + /> + )} + + {screen === "processing" && } + + {screen === "result" && resultImage && ( + + )} + + {screen === "rate_limited" && ( + + )} +
+ ); +} + +export default function PhotoBoothPage() { + return ( + + + + ); +} diff --git a/app/portal/page.tsx b/app/portal/page.tsx new file mode 100644 index 0000000..0b26318 --- /dev/null +++ b/app/portal/page.tsx @@ -0,0 +1,744 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { + Lock, + Eye, + EyeOff, + Calendar, + Clock, + Sparkles, + ExternalLink, + Download, + RefreshCw, + AlertTriangle, + ShieldAlert, + CheckCircle2, + XCircle, + BarChart3, + Layers, + LogOut, + ArrowLeft, + Cpu, + Image as ImageIcon, + Activity, + Filter, +} from "lucide-react"; +import { StatsSummary } from "@/lib/storage"; + +export default function PortalPage() { + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [authError, setAuthError] = useState(""); + const [isLoggingIn, setIsLoggingIn] = useState(false); + + // Filter State + const [period, setPeriod] = useState<"day" | "week" | "month" | "all" | "custom">("week"); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + const [selectedStyleFilter, setSelectedStyleFilter] = useState("all"); + + // Dashboard Data State + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [activeTab, setActiveTab] = useState<"gallery" | "errors">("gallery"); + + // Check initial authentication + const checkAuth = useCallback(async () => { + try { + const res = await fetch("/api/portal/auth"); + if (res.ok) { + setIsAuthenticated(true); + } else { + setIsAuthenticated(false); + } + } catch { + setIsAuthenticated(false); + } + }, []); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + // Fetch statistics + const fetchStats = useCallback(async () => { + if (!isAuthenticated) return; + setIsLoading(true); + try { + let url = `/api/portal/stats?period=${period}`; + if (period === "custom") { + if (fromDate) url += `&from=${fromDate}`; + if (toDate) url += `&to=${toDate}`; + } + const res = await fetch(url); + if (res.status === 401) { + setIsAuthenticated(false); + return; + } + const data = await res.json(); + if (data.success && data.stats) { + setStats(data.stats); + } + } catch (err) { + console.error("Failed to fetch stats:", err); + } finally { + setIsLoading(false); + } + }, [isAuthenticated, period, fromDate, toDate]); + + useEffect(() => { + if (isAuthenticated) { + fetchStats(); + } + }, [isAuthenticated, fetchStats]); + + // Handle Login + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setAuthError(""); + setIsLoggingIn(true); + try { + const res = await fetch("/api/portal/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (res.ok && data.success) { + setIsAuthenticated(true); + setPassword(""); + } else { + setAuthError(data.error || "Incorrect password"); + } + } catch { + setAuthError("Network error. Please try again."); + } finally { + setIsLoggingIn(false); + } + }; + + // Handle Logout + const handleLogout = async () => { + await fetch("/api/portal/auth", { method: "DELETE" }); + setIsAuthenticated(false); + setStats(null); + }; + + // Filter gallery images by selected style dropdown + const displayedImages = (stats?.images || []).filter((img) => { + if (selectedStyleFilter === "all") return true; + return img.styleId === selectedStyleFilter; + }); + + // Loading Initial Auth State + if (isAuthenticated === null) { + return ( +
+
+ + Authenticating... +
+
+ ); + } + + // Login Screen + if (!isAuthenticated) { + return ( +
+
+
+
+ +
+
+ +

+ ไธ‡ๅœฃ็ฉบๆ‰‹ ยท ็ฎก็†ๅŽๅฐ +

+

+ ไธŠๆตทๅฅๅฟ—็ฉบๆ‰‹้“้ฆ† ยท Halloween Management & Gallery +

+ +
+
+ setPassword(e.target.value)} + autoFocus + className="w-full px-4 py-3.5 bg-[#0e071c] border border-purple-800/50 rounded-2xl text-white placeholder-purple-400/40 focus:outline-none focus:border-orange-500 focus:ring-2 focus:ring-orange-500/20 text-sm font-mono pr-12 transition-all" + /> + +
+ + {authError && ( +
+ + {authError} +
+ )} + + +
+ +
+ + + Back to Photo Booth + +
+
+
+ ); + } + + // Main Portal Dashboard + return ( +
+ {/* Top Navigation Bar */} +
+
+
+
+ +
+
+
+

+ ไธ‡ๅœฃ็ฉบๆ‰‹้“้ฆ† ยท Spooky Hand + + Portal + +

+

+ ไธŠๆตทๅฅๅฟ—็ฉบๆ‰‹้“้ฆ† ยท Live statistics, error tracking & gallery +

+
+
+ +
+ + + + + Photo Booth + + + +
+
+ + {/* Main Container */} +
+ {/* Period Filter Bar */} +
+
+ + + Time Period: + +
+ +
+ {( + [ + { id: "day", label: "Today" }, + { id: "week", label: "Last 7 Days" }, + { id: "month", label: "Last 30 Days" }, + { id: "all", label: "All Time" }, + { id: "custom", label: "Custom Range" }, + ] as const + ).map((item) => ( + + ))} +
+ + {/* Custom Date Pickers */} + {period === "custom" && ( +
+ setFromDate(e.target.value)} + className="bg-[#0e071c] border border-purple-800/60 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500" + /> + to + setToDate(e.target.value)} + className="bg-[#0e071c] border border-purple-800/60 rounded-xl px-3 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500" + /> +
+ )} +
+ + {/* KPI Summary Cards */} +
+ {/* Card 1: Total Generations */} +
+
+ Total Generations +
+ +
+
+
+ {stats?.totalAttempts ?? 0} +
+
+ + + {stats?.successCount ?? 0} success + + โ€ข + + {stats?.successRate ?? 0}% rate + +
+
+ + {/* Card 2: Average Generation Speed */} +
+
+ Avg Latency +
+ +
+
+
+ {stats?.avgDurationMs ? `${(stats.avgDurationMs / 1000).toFixed(1)}s` : "0s"} +
+
+ End-to-end transformation time +
+
+ + {/* Card 3: Safety Refusals */} +
+
+ Content Refusals +
+ +
+
+
+ {stats?.refusedCount ?? 0} +
+
+ Filtered by SFW / safety guardrails +
+
+ + {/* Card 4: Server / Provider Errors */} +
+
+ Server Errors +
+ +
+
+
+ {stats?.errorCount ?? 0} +
+
+ API timeouts & network failures +
+
+
+ + {/* Analytics Breakdown Row: Daily Trend & Disguise Popularity */} +
+ {/* Daily Trend Histogram */} +
+
+
+ +

+ Generations by Day +

+
+ + {stats?.dailyTrends?.length || 0} days shown + +
+ + {stats?.dailyTrends && stats.dailyTrends.length > 0 ? ( +
+ {(() => { + const maxCount = Math.max( + 1, + ...stats.dailyTrends.map((d) => d.total) + ); + return stats.dailyTrends.map((day) => { + const heightPercent = Math.max( + 8, + Math.round((day.total / maxCount) * 100) + ); + const formattedDate = day.date.slice(5); // MM-DD + return ( +
+ {/* Hover Tooltip */} +
+ {day.date}: {day.total} total ({day.success} ok, {day.refused} refused, {day.error} err) +
+ + {/* Bar */} +
+
+
+ + {/* Label */} + + {formattedDate} + +
+ ); + }); + })()} +
+ ) : ( +
+ No generations recorded for this time range yet. +
+ )} +
+ + {/* Popular Disguise Breakdown */} +
+
+ +

+ Top Disguise Styles +

+
+ +
+ {stats?.styleBreakdown && stats.styleBreakdown.length > 0 ? ( + stats.styleBreakdown.map((style) => ( +
+
+ + {style.styleName} + + + {style.count} ({style.percentage}%) + +
+
+
+
+
+ )) + ) : ( +
+ No styles used in this period. +
+ )} +
+ + {/* Provider Health Chips */} +
+ {stats?.providerBreakdown?.map((p) => ( + + + {p.provider}: {p.count} + + ))} +
+
+
+ + {/* View Tabs: Gallery vs Errors */} +
+
+
+ + + +
+ + {/* Style Filter for Gallery */} + {activeTab === "gallery" && ( +
+ + +
+ )} +
+ + {/* TAB 1: Generated Images Gallery */} + {activeTab === "gallery" && ( +
+ {displayedImages.length > 0 ? ( +
+ {displayedImages.map((img) => ( + + ))} +
+ ) : ( +
+ +

+ No generated portraits found for this selection. +

+

+ Snap photos in the booth to see them appear here live! +

+
+ )} +
+ )} + + {/* TAB 2: Error & Content Refusal Logs */} + {activeTab === "errors" && ( +
+ {stats?.errorLogs && stats.errorLogs.length > 0 ? ( +
+
+ + + + + + + + + + + + + {stats.errorLogs.map((log) => { + const isSafety = log.errorType === "safety_refusal" || log.status === "refused"; + return ( + + + + + + + + + ); + })} + +
TimeTypeDisguiseProvider / ModelReason / Error MessageClient ID
+ {new Date(log.timestamp).toLocaleString("en-CA", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + + + {isSafety ? "Safety Refusal" : "Server / API Error"} + + + {log.styleName} + + {log.provider} ({log.model}) + + {log.refusalReason || log.errorMessage || "Unknown error"} + + {log.clientId || "127.0.0.1"} +
+
+
+ ) : ( +
+ +

+ No errors or content refusals recorded! +

+

+ Your photo booth is running smoothly. +

+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/components/KioskFrame.tsx b/components/KioskFrame.tsx new file mode 100644 index 0000000..2e95fb0 --- /dev/null +++ b/components/KioskFrame.tsx @@ -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 = ({ + 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 ( +
+ {/* Dynamic Martial Arts & Shanghai Halloween Atmosphere */} + + + {/* Floating Language Toggle (Top Right) */} + {showLanguageToggle && ( +
+ +
+ )} + + {/* Brand Header Logo - 20% larger */} + {showHeaderLogo && ( +
+
+ {theme.logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {config.appName} + ) : ( +

+ {config.appName} +

+ )} +
+
+ )} + + {/* Screen Content Layer */} +
+ {children} +
+ + {/* Dojo Brand Footer - clean single line, smaller font, no emoji */} + +
+ ); +}; diff --git a/components/MascotGuide.tsx b/components/MascotGuide.tsx new file mode 100644 index 0000000..56dc3e0 --- /dev/null +++ b/components/MascotGuide.tsx @@ -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 = { + 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 = ({ + 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 ( +
+ {/* Mascot Cutout Character */} +
+ {/* Soft magical glow backdrop */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Sensei +
+ + {/* Comic Speech Bubble */} + {speechText && ( +
+
+ {/* Bubble arrow tail pointing towards the ghost */} + {speechPosition === "right" && ( +
+ )} + {speechPosition === "left" && ( +
+ )} + {speechPosition === "top" && ( +
+ )} + +

+ {t(speechText)} +

+
+
+ )} +
+ ); +}; diff --git a/components/SpookyAtmosphere.tsx b/components/SpookyAtmosphere.tsx new file mode 100644 index 0000000..f7d3a3e --- /dev/null +++ b/components/SpookyAtmosphere.tsx @@ -0,0 +1,43 @@ +"use client"; + +import React from "react"; + +interface SpookyAtmosphereProps { + className?: string; + variant?: "kiosk" | "fullscreen"; +} + +export const SpookyAtmosphere: React.FC = ({ + className = "", +}) => { + return ( +