"use client" import { useCallback, useEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import { useRouter } from "next/navigation" import { DownloadSimple, Lock, Printer } from "@phosphor-icons/react" import type { Group as KonvaGroup } from "konva/lib/Group" import type { Image as KonvaImageNode } from "konva/lib/shapes/Image" import type { Stage as KonvaStage } from "konva/lib/Stage" import { Stage } from "react-konva" import { toast } from "sonner" import { BoothPanel } from "@/features/booth/BoothPanel" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { clearSession } from "@/features/session/sessionDb" import { completeStrip, requestStrip, uploadStripLive, uploadStripStill, } from "@/features/share/api" import type { SignedUpload } from "@/lib/http" import { ShareCard } from "@/features/share/ShareCard" import { clearStripToken, saveStripToken } from "@/features/share/stripToken" import { completeRelay, dataUrlToBlob, RelaySlotsNeededError, uploadRelaySlot, type CompletedRelay, } from "@/features/relay/api" import { readRelayToken } from "@/features/relay/relayToken" import { useQrImageUrl } from "@/features/share/useQrImage" import { LiveStage, type LiveStageHandle } from "@/features/live/LiveStage" import { pickLiveKind } from "@/features/live/mediaSupport" import { track } from "@/lib/analytics" import { getLayout, type LayoutId } from "@/lib/layouts" import type { LiveKind } from "@/lib/schemas" import { countFilled, hasLiveClips, useBoothStore } from "@/stores/booth" import { StripStage } from "@/features/editor/StripStage" import { getEffect, getFrame } from "@/features/editor/presets" import { downloadBlob, downloadStrip, exportStrip, formatStripFilename } from "./exportStrip" import { composePrintSheet } from "./sheet" /* FR-11 dan FR-12: halaman Hasil. Ekspor terjadi sekali saat mount dengan panggung tersembunyi, jadi tombol unduh dan cetak hanya menyerahkan berkas yang sudah jadi. Lembar 4R selalu dikomposisi di sini supaya CSS cetak (globals.css) tinggal menampilkan satu gambar. Halaman tanpa berkas acuan: disusun dari Booth Shell dan komponen DESIGN-SYSTEM §9. Berkas ini hanya boleh dimuat lewat `next/dynamic` (ARCHITECTURE §9). M6 menambah alur bagikan (SH-04, SH-06). Ekspor dijalankan ulang setelah QR siap melalui antrean satu jalur: panggung diubah ukurannya saat ekspor, jadi dua ekspor tidak boleh berjalan bersamaan. Setelah QR ada di gambar, berkas diunggah ke URL bertanda tangan dan rute complete memasang tanggal kedaluwarsanya. */ const PREVIEW_WIDTH_STRIP = 216 const PREVIEW_WIDTH_GRID = 320 /* Ekspor mount menunggu gambar slot (dan QR) selesai didekode; batas ini menjaga halaman tidak menggantung bila salah satu gambar gagal. */ const IMAGE_WAIT_TIMEOUT_MS = 8_000 const IMAGE_WAIT_STEP_MS = 50 /* Takhtanya tidak mendengar apa pun (`listening={false}`), jadi pengendali pilihan tidak pernah dipanggil. */ const IGNORE = () => {} /** M8 (SE-07): satu percobaan penuntasan estafet. Bila rutenya menjawab bahwa * Berkas slot teman belum lengkap (mis. sesi ini pulih dari perangkat lain), * foto yang ada di sesi lokal diunggah ke URL pengganti itu, lalu rutenya * dicoba sekali lagi. */ async function finishRelayOnce( id: string, token: string ): Promise { try { return await completeRelay(id, token) } catch (error) { if (!(error instanceof RelaySlotsNeededError) || error.uploads.length === 0) { throw error } const { shots } = useBoothStore.getState() await Promise.all( error.uploads.map((upload) => { const shot = shots[upload.index] if (shot === null || shot === undefined) { throw new Error("Isi ulang foto yang kurang.") } return uploadRelaySlot(upload, dataUrlToBlob(shot.still)) }) ) return await completeRelay(id, token) } } /** Ekspor menyalin kanvas saat ini, jadi setiap gambar bernama di dalam grup * harus sudah terpasang. */ async function waitForImages(group: KonvaGroup, names: string[]): Promise { const deadline = Date.now() + IMAGE_WAIT_TIMEOUT_MS while (Date.now() < deadline) { const ready = names.every((name) => { const nodes = group.find(`.${name}`) return ( nodes.length > 0 && nodes.every((node) => (node as KonvaImageNode).image() !== undefined) ) }) if (ready) { return } const { promise, resolve } = Promise.withResolvers() window.setTimeout(resolve, IMAGE_WAIT_STEP_MS) await promise } throw new Error("Foto belum siap diekspor") } export default function ResultView({ layoutId }: { layoutId: LayoutId }) { const router = useRouter() const shots = useBoothStore((state) => state.shots) const frameId = useBoothStore((state) => state.frameId) const effectId = useBoothStore((state) => state.effectId) const stickers = useBoothStore((state) => state.stickers) const texts = useBoothStore((state) => state.texts) const showDate = useBoothStore((state) => state.showDate) const share = useBoothStore((state) => state.share) const setShareStrip = useBoothStore((state) => state.setShareStrip) const reset = useBoothStore((state) => state.reset) /* M8 (SE-07): sesi teman menuntaskan estafet dari sini, bukan membuat link sendiri, jadi kartu bagikan tidak muncul dan aksi utamanya menuntaskan dulu lalu mengunduh. */ const relay = useBoothStore((state) => state.relay) const localOnly = useBoothStore((state) => state.localOnly) const isGuest = relay?.role === "guest" const hidesUpload = isGuest || localOnly const stageRef = useRef(null) const groupRef = useRef(null) const liveRef = useRef(null) const busyRef = useRef(false) const againRef = useRef(false) const lastKeyRef = useRef(null) const runRef = useRef<(() => Promise) | null>(null) /* URL unggah sekali pakai dari POST /api/strips, menunggu gambar ber-QR. */ const pendingUploadRef = useRef<{ id: string upload: SignedUpload liveUpload?: SignedUpload } | null>(null) const [dataUrl, setDataUrl] = useState(null) const [sheetUrl, setSheetUrl] = useState(null) const [failed, setFailed] = useState(false) const [linking, setLinking] = useState(false) const [finishing, setFinishing] = useState(false) const [liveBusy, setLiveBusy] = useState(false) const [videoBusy, setVideoBusy] = useState(false) /* PRD §4: rasio "sampai unduh" dihitung per sesi halaman, bukan per klik, supaya tombol yang ditekan dua kali tidak melampaui penyebutnya. */ const downloadTracked = useRef(false) const trackDownload = () => { if (!downloadTracked.current) { downloadTracked.current = true track("download_png") } } /* M7 (SH-02, SH-03): versi hidup ikut diunggah hanya bila sakelarnya nyala di langkah Hias, minimal satu slot berklip, dan browsernya bisa merekam. */ const liveEnabled = useBoothStore((state) => state.live.enabled) const liveKind: LiveKind | null = pickLiveKind() const liveWanted = liveEnabled && hasLiveClips(shots) && liveKind !== null /* QR baru digambar setelah linknya ada; sebelum itu kotak QR tidak ikut diekspor supaya strip yang diunduh tidak berisi glif penanda. */ const qrImageUrl = useQrImageUrl(share) const qr = qrImageUrl === null ? undefined : { imageUrl: qrImageUrl } const shareStrip = share.strip const layout = getLayout(layoutId) const previewWidth = layout.canvas.width > 600 ? PREVIEW_WIDTH_GRID : PREVIEW_WIDTH_STRIP /** SH-06: mengunggah gambar ber-QR lalu menandai stripnya selesai. */ const finishShare = useCallback( async (exported: string) => { const pending = pendingUploadRef.current if ( pending === null || shareStrip === null || shareStrip.id !== pending.id || shareStrip.expiresAt !== null ) { return } try { /* M7: versi hidup direkam dari panggung luar layar lalu diunggah lebih dulu. Kegagalannya hanya menghilangkan klip, bukan link. */ if (pending.liveUpload !== undefined) { setLiveBusy(true) try { const recorded = await liveRef.current?.record() if (recorded !== undefined && recorded !== null) { await uploadStripLive(pending.liveUpload, recorded.blob, recorded.kind) } } catch { toast.info("Versi hidup tidak ikut terunggah.") } finally { setLiveBusy(false) } } const blob = await (await fetch(exported)).blob() await uploadStripStill(pending.upload, blob) const completed = await completeStrip(pending.id) pendingUploadRef.current = null setShareStrip({ id: pending.id, expiresAt: completed.expiresAt }) toast.success("Link strip dibuat.") } catch (error) { /* Baris draf yang yatim dibersihkan cron dalam 24 jam; token di perangkat dibuang supaya tidak ada tombol hapus yang menyesatkan. */ pendingUploadRef.current = null clearStripToken(pending.id) setShareStrip(null) toast.error( error instanceof Error ? error.message : "Unggahan gagal. Coba lagi." ) } }, [shareStrip, setShareStrip] ) const runExport = useCallback(async () => { /* Link sudah dibuat tetapi QR-nya belum siap: tunggu render berikutnya supaya strip yang diunggah tidak pernah kehilangan QR-nya. */ if (shareStrip !== null && qrImageUrl === null) { return } const stage = stageRef.current const group = groupRef.current if (stage === null || group === null) { setFailed(true) return } const key = `${layoutId}|${effectId}|${qrImageUrl ?? ""}` if (lastKeyRef.current === key) { return } try { await waitForImages( group, qrImageUrl === null ? ["slot-photo"] : ["slot-photo", "qr-plate"] ) const exported = await exportStrip(stage, group, { canvas: layout.canvas, effect: getEffect(effectId).params, }) setDataUrl(exported) setSheetUrl(await composePrintSheet(exported, layoutId)) setFailed(false) lastKeyRef.current = key await finishShare(exported) } catch { setFailed(true) } }, [effectId, finishShare, layout, layoutId, qrImageUrl, shareStrip]) useEffect(() => { runRef.current = runExport }) /* Antrean ekspor: satu ekspor berjalan pada satu waktu, dan permintaan yang datang di tengah ekspor dijalankan sekali lagi setelahnya. */ useEffect(() => { async function pump() { if (busyRef.current) { againRef.current = true return } busyRef.current = true try { do { againRef.current = false await runRef.current?.() } while (againRef.current) } finally { busyRef.current = false } } void pump() }, [effectId, layoutId, qrImageUrl]) /** SE-02 (dipakai SH-04): dialog persetujuan sudah dijawab, sekarang minta * id dan URL unggah, lalu simpan token hapus di perangkat. */ async function handleCreateLink() { setLinking(true) try { const created = await requestStrip( layoutId, liveWanted && liveKind !== null ? liveKind : undefined ) saveStripToken(created.id, created.deleteToken) pendingUploadRef.current = { id: created.id, upload: created.upload, ...(created.liveUpload === undefined ? {} : { liveUpload: created.liveUpload }), } setShareStrip({ id: created.id, expiresAt: null }) track("strip_link_created") } catch (error) { toast.error( error instanceof Error ? error.message : "Link belum bisa dibuat. Coba lagi sebentar lagi." ) } finally { setLinking(false) } } function handleDownload() { if (dataUrl === null) { return } downloadStrip(dataUrl, formatStripFilename(new Date())) trackDownload() toast.success("Strip tersimpan di perangkatmu.") } /** M8 (SE-07): "Selesai & Unduh" milik teman. Estafetnya dituntaskan dulu * (foto slot yang belum terunggah ikut dikirim), stripnya diunggah, lalu * berkasnya diunduh dan halaman beralih ke /s/{id}. */ async function handleFinishRelay() { if (dataUrl === null || finishing || relay === null) { return } const stored = readRelayToken(relay.id) if (stored === null || stored.role !== "guest") { toast.error("Token estafet tidak ada. Buka lagi link estafetmu.") return } setFinishing(true) try { const completed = await finishRelayOnce(relay.id, stored.token) if (completed.deleteToken !== undefined) { saveStripToken(completed.stripId, completed.deleteToken) } if (completed.upload !== undefined) { const blob = await (await fetch(dataUrl)).blob() await uploadStripStill(completed.upload, blob) await completeStrip(completed.stripId) } downloadStrip(dataUrl, formatStripFilename(new Date())) track("relay_completed") trackDownload() toast.success("Estafet selesai. Strip kamu ada di link pribadi.") /* FR-14: rekaman perangkat dihapus supaya sesi teman tidak terbawa ke sesi berikutnya. `reset()` tidak dipanggil di sini: mengosongkan layout dan foto sebelum navigasi selesai membuat gerbang langkah booth mengalihkan ke pemilih layout lebih dulu. Sesi di memori dibiarkan utuh sampai pengguna memulai sesi baru; yang dilepas hanya irisan estafetnya, supaya baki foto tidak lagi terkunci. */ await clearSession() useBoothStore.setState({ relay: null }) router.push(`/s/${completed.stripId}`) } catch (error) { toast.error( error instanceof Error ? error.message : "Estafet belum bisa diselesaikan. Coba lagi sebentar lagi." ) } finally { setFinishing(false) } } function handleRestart() { /* FR-14: rekaman di perangkat dihapus lebih dulu, lalu sesi di memori. */ void clearSession() reset() router.replace("/booth/layout") } /** SH-03: unduhan lokal versi hidup, tanpa unggahan. */ async function handleDownloadVideo() { if (videoBusy) { return } setVideoBusy(true) try { const recorded = await liveRef.current?.record() if (recorded === undefined || recorded === null) { toast.error("Video belum bisa direkam di browser ini.") return } downloadBlob(recorded.blob, formatStripFilename(new Date(), recorded.kind)) toast.success("Video strip tersimpan di perangkatmu.") } catch { toast.error("Video belum bisa direkam di browser ini.") } finally { setVideoBusy(false) } } const preparing = dataUrl === null && !failed return ( <> {`${countFilled(shots)} foto`}} left={
Layout
{layout.label}
Frame
{getFrame(frameId).label}
Efek
{getEffect(effectId).label}
Stiker
{stickers.length}
} center={ <>
{dataUrl === null ? (
Menyiapkan hasil
) : ( /* DataURL hasil ekspor, bukan aset: next/image tidak dipakai. */ /* eslint-disable-next-line @next/next/no-img-element */ Pratinjau strip )}

{`Ekspor ${layout.canvas.width} x ${layout.canvas.height} px`}

{isGuest ? "Kamera dan hiasan berjalan di perangkat ini. Stripnya diunggah saat estafetnya diselesaikan." : localOnly ? "Salinan ini hanya di perangkatmu. Tidak ada yang diunggah." : shareStrip === null ? "Semua diproses di perangkat ini. Tidak ada yang diunggah." : "Strip kamu ada di link pribadi. Tidak ada yang lain diunggah."}

} right={
{hidesUpload ? null : share.qrEnabled || shareStrip !== null ? ( ) : null}
{/* SH-03: tombolnya hanya ada saat browsernya bisa merekam dan sesinya punya klip; unduhan lokal tidak butuh sakelar Hias. */} {liveKind !== null && hasLiveClips(shots) ? ( ) : null} {failed ? (

Berkas belum bisa disiapkan. Muat ulang halaman, lalu coba lagi.

) : null}
} /> {/* Panggung ekspor di luar layar: sumber dataUrl di atas. */}
{/* SH-03: panggung rekam versi hidup, di luar layar seperti panggung ekspor. Hanya dipakai saat pengguna membuat link berklip atau meminta unduhan video. */} {/* FR-12: satu halaman 4 x 6 inci. Portal ke `body` supaya CSS cetak bisa menghilangkan seluruh isi halaman dan menyisakan lembar ini; tersembunyi di layar lewat `hidden`, ditampilkan blok `@media print` di globals.css. */} {sheetUrl === null ? null : createPortal(
{/* DataURL lembar cetak, bukan aset: next/image tidak dipakai. */} {/* eslint-disable-next-line @next/next/no-img-element */} Lembar cetak 4R
, document.body )} ) }