1"use client"
2
3import { useCallback, useEffect, useRef, useState } from "react"
4import { createPortal } from "react-dom"
5import { useRouter } from "next/navigation"
6import { DownloadSimple, Lock, Printer } from "@phosphor-icons/react"
7import type { Group as KonvaGroup } from "konva/lib/Group"
8import type { Image as KonvaImageNode } from "konva/lib/shapes/Image"
9import type { Stage as KonvaStage } from "konva/lib/Stage"
10import { Stage } from "react-konva"
11import { toast } from "sonner"
12
13import { BoothPanel } from "@/features/booth/BoothPanel"
14import { Badge } from "@/components/ui/badge"
15import { Button } from "@/components/ui/button"
16import { clearSession } from "@/features/session/sessionDb"
17import {
18 completeStrip,
19 requestStrip,
20 uploadStripLive,
21 uploadStripStill,
22} from "@/features/share/api"
23import type { SignedUpload } from "@/lib/http"
24import { ShareCard } from "@/features/share/ShareCard"
25import { clearStripToken, saveStripToken } from "@/features/share/stripToken"
26import {
27 completeRelay,
28 dataUrlToBlob,
29 RelaySlotsNeededError,
30 uploadRelaySlot,
31 type CompletedRelay,
32} from "@/features/relay/api"
33import { readRelayToken } from "@/features/relay/relayToken"
34import { useQrImageUrl } from "@/features/share/useQrImage"
35import { LiveStage, type LiveStageHandle } from "@/features/live/LiveStage"
36import { pickLiveKind } from "@/features/live/mediaSupport"
37import { track } from "@/lib/analytics"
38import { getLayout, type LayoutId } from "@/lib/layouts"
39import type { LiveKind } from "@/lib/schemas"
40import { countFilled, hasLiveClips, useBoothStore } from "@/stores/booth"
41import { StripStage } from "@/features/editor/StripStage"
42import { getEffect, getFrame } from "@/features/editor/presets"
43import { downloadBlob, downloadStrip, exportStrip, formatStripFilename } from "./exportStrip"
44import { composePrintSheet } from "./sheet"
45
46/* FR-11 dan FR-12: halaman Hasil. Ekspor terjadi sekali saat mount dengan
47 panggung tersembunyi, jadi tombol unduh dan cetak hanya menyerahkan berkas
48 yang sudah jadi. Lembar 4R selalu dikomposisi di sini supaya CSS cetak
49 (globals.css) tinggal menampilkan satu gambar. Halaman tanpa berkas acuan:
50 disusun dari Booth Shell dan komponen DESIGN-SYSTEM §9.
51 Berkas ini hanya boleh dimuat lewat `next/dynamic` (ARCHITECTURE §9).
52
53 M6 menambah alur bagikan (SH-04, SH-06). Ekspor dijalankan ulang setelah QR
54 siap melalui antrean satu jalur: panggung diubah ukurannya saat ekspor, jadi
55 dua ekspor tidak boleh berjalan bersamaan. Setelah QR ada di gambar, berkas
56 diunggah ke URL bertanda tangan dan rute complete memasang tanggal
57 kedaluwarsanya. */
58
59const PREVIEW_WIDTH_STRIP = 216
60const PREVIEW_WIDTH_GRID = 320
61/* Ekspor mount menunggu gambar slot (dan QR) selesai didekode; batas ini
62 menjaga halaman tidak menggantung bila salah satu gambar gagal. */
63const IMAGE_WAIT_TIMEOUT_MS = 8_000
64const IMAGE_WAIT_STEP_MS = 50
65
66/* Takhtanya tidak mendengar apa pun (`listening={false}`), jadi pengendali
67 pilihan tidak pernah dipanggil. */
68const IGNORE = () => {}
69
70/** M8 (SE-07): satu percobaan penuntasan estafet. Bila rutenya menjawab bahwa
71 * Berkas slot teman belum lengkap (mis. sesi ini pulih dari perangkat lain),
72 * foto yang ada di sesi lokal diunggah ke URL pengganti itu, lalu rutenya
73 * dicoba sekali lagi. */
74async function finishRelayOnce(
75 id: string,
76 token: string
77): Promise<CompletedRelay> {
78 try {
79 return await completeRelay(id, token)
80 } catch (error) {
81 if (!(error instanceof RelaySlotsNeededError) || error.uploads.length === 0) {
82 throw error
83 }
84 const { shots } = useBoothStore.getState()
85 await Promise.all(
86 error.uploads.map((upload) => {
87 const shot = shots[upload.index]
88 if (shot === null || shot === undefined) {
89 throw new Error("Isi ulang foto yang kurang.")
90 }
91 return uploadRelaySlot(upload, dataUrlToBlob(shot.still))
92 })
93 )
94 return await completeRelay(id, token)
95 }
96}
97
98/** Ekspor menyalin kanvas saat ini, jadi setiap gambar bernama di dalam grup
99 * harus sudah terpasang. */
100async function waitForImages(group: KonvaGroup, names: string[]): Promise<void> {
101 const deadline = Date.now() + IMAGE_WAIT_TIMEOUT_MS
102 while (Date.now() < deadline) {
103 const ready = names.every((name) => {
104 const nodes = group.find(`.${name}`)
105 return (
106 nodes.length > 0 &&
107 nodes.every((node) => (node as KonvaImageNode).image() !== undefined)
108 )
109 })
110 if (ready) {
111 return
112 }
113 const { promise, resolve } = Promise.withResolvers<void>()
114 window.setTimeout(resolve, IMAGE_WAIT_STEP_MS)
115 await promise
116 }
117 throw new Error("Foto belum siap diekspor")
118}
119
120export default function ResultView({ layoutId }: { layoutId: LayoutId }) {
121 const router = useRouter()
122 const shots = useBoothStore((state) => state.shots)
123 const frameId = useBoothStore((state) => state.frameId)
124 const effectId = useBoothStore((state) => state.effectId)
125 const stickers = useBoothStore((state) => state.stickers)
126 const texts = useBoothStore((state) => state.texts)
127 const showDate = useBoothStore((state) => state.showDate)
128 const share = useBoothStore((state) => state.share)
129 const setShareStrip = useBoothStore((state) => state.setShareStrip)
130 const reset = useBoothStore((state) => state.reset)
131 /* M8 (SE-07): sesi teman menuntaskan estafet dari sini, bukan membuat link
132 sendiri, jadi kartu bagikan tidak muncul dan aksi utamanya menuntaskan
133 dulu lalu mengunduh. */
134 const relay = useBoothStore((state) => state.relay)
135 const localOnly = useBoothStore((state) => state.localOnly)
136 const isGuest = relay?.role === "guest"
137 const hidesUpload = isGuest || localOnly
138
139 const stageRef = useRef<KonvaStage>(null)
140 const groupRef = useRef<KonvaGroup>(null)
141 const liveRef = useRef<LiveStageHandle>(null)
142 const busyRef = useRef(false)
143 const againRef = useRef(false)
144 const lastKeyRef = useRef<string | null>(null)
145 const runRef = useRef<(() => Promise<void>) | null>(null)
146 /* URL unggah sekali pakai dari POST /api/strips, menunggu gambar ber-QR. */
147 const pendingUploadRef = useRef<{
148 id: string
149 upload: SignedUpload
150 liveUpload?: SignedUpload
151 } | null>(null)
152
153 const [dataUrl, setDataUrl] = useState<string | null>(null)
154 const [sheetUrl, setSheetUrl] = useState<string | null>(null)
155 const [failed, setFailed] = useState(false)
156 const [linking, setLinking] = useState(false)
157 const [finishing, setFinishing] = useState(false)
158 const [liveBusy, setLiveBusy] = useState(false)
159 const [videoBusy, setVideoBusy] = useState(false)
160 /* PRD §4: rasio "sampai unduh" dihitung per sesi halaman, bukan per klik,
161 supaya tombol yang ditekan dua kali tidak melampaui penyebutnya. */
162 const downloadTracked = useRef(false)
163
164 const trackDownload = () => {
165 if (!downloadTracked.current) {
166 downloadTracked.current = true
167 track("download_png")
168 }
169 }
170
171 /* M7 (SH-02, SH-03): versi hidup ikut diunggah hanya bila sakelarnya nyala di
172 langkah Hias, minimal satu slot berklip, dan browsernya bisa merekam. */
173 const liveEnabled = useBoothStore((state) => state.live.enabled)
174 const liveKind: LiveKind | null = pickLiveKind()
175 const liveWanted = liveEnabled && hasLiveClips(shots) && liveKind !== null
176
177 /* QR baru digambar setelah linknya ada; sebelum itu kotak QR tidak ikut
178 diekspor supaya strip yang diunduh tidak berisi glif penanda. */
179 const qrImageUrl = useQrImageUrl(share)
180 const qr = qrImageUrl === null ? undefined : { imageUrl: qrImageUrl }
181 const shareStrip = share.strip
182
183 const layout = getLayout(layoutId)
184 const previewWidth =
185 layout.canvas.width > 600 ? PREVIEW_WIDTH_GRID : PREVIEW_WIDTH_STRIP
186
187 /** SH-06: mengunggah gambar ber-QR lalu menandai stripnya selesai. */
188 const finishShare = useCallback(
189 async (exported: string) => {
190 const pending = pendingUploadRef.current
191 if (
192 pending === null ||
193 shareStrip === null ||
194 shareStrip.id !== pending.id ||
195 shareStrip.expiresAt !== null
196 ) {
197 return
198 }
199 try {
200 /* M7: versi hidup direkam dari panggung luar layar lalu diunggah lebih
201 dulu. Kegagalannya hanya menghilangkan klip, bukan link. */
202 if (pending.liveUpload !== undefined) {
203 setLiveBusy(true)
204 try {
205 const recorded = await liveRef.current?.record()
206 if (recorded !== undefined && recorded !== null) {
207 await uploadStripLive(pending.liveUpload, recorded.blob, recorded.kind)
208 }
209 } catch {
210 toast.info("Versi hidup tidak ikut terunggah.")
211 } finally {
212 setLiveBusy(false)
213 }
214 }
215
216 const blob = await (await fetch(exported)).blob()
217 await uploadStripStill(pending.upload, blob)
218 const completed = await completeStrip(pending.id)
219 pendingUploadRef.current = null
220 setShareStrip({ id: pending.id, expiresAt: completed.expiresAt })
221 toast.success("Link strip dibuat.")
222 } catch (error) {
223 /* Baris draf yang yatim dibersihkan cron dalam 24 jam; token di
224 perangkat dibuang supaya tidak ada tombol hapus yang menyesatkan. */
225 pendingUploadRef.current = null
226 clearStripToken(pending.id)
227 setShareStrip(null)
228 toast.error(
229 error instanceof Error ? error.message : "Unggahan gagal. Coba lagi."
230 )
231 }
232 },
233 [shareStrip, setShareStrip]
234 )
235
236 const runExport = useCallback(async () => {
237 /* Link sudah dibuat tetapi QR-nya belum siap: tunggu render berikutnya
238 supaya strip yang diunggah tidak pernah kehilangan QR-nya. */
239 if (shareStrip !== null && qrImageUrl === null) {
240 return
241 }
242 const stage = stageRef.current
243 const group = groupRef.current
244 if (stage === null || group === null) {
245 setFailed(true)
246 return
247 }
248 const key = `${layoutId}|${effectId}|${qrImageUrl ?? ""}`
249 if (lastKeyRef.current === key) {
250 return
251 }
252 try {
253 await waitForImages(
254 group,
255 qrImageUrl === null ? ["slot-photo"] : ["slot-photo", "qr-plate"]
256 )
257 const exported = await exportStrip(stage, group, {
258 canvas: layout.canvas,
259 effect: getEffect(effectId).params,
260 })
261 setDataUrl(exported)
262 setSheetUrl(await composePrintSheet(exported, layoutId))
263 setFailed(false)
264 lastKeyRef.current = key
265 await finishShare(exported)
266 } catch {
267 setFailed(true)
268 }
269 }, [effectId, finishShare, layout, layoutId, qrImageUrl, shareStrip])
270
271 useEffect(() => {
272 runRef.current = runExport
273 })
274
275 /* Antrean ekspor: satu ekspor berjalan pada satu waktu, dan permintaan yang
276 datang di tengah ekspor dijalankan sekali lagi setelahnya. */
277 useEffect(() => {
278 async function pump() {
279 if (busyRef.current) {
280 againRef.current = true
281 return
282 }
283 busyRef.current = true
284 try {
285 do {
286 againRef.current = false
287 await runRef.current?.()
288 } while (againRef.current)
289 } finally {
290 busyRef.current = false
291 }
292 }
293 void pump()
294 }, [effectId, layoutId, qrImageUrl])
295
296 /** SE-02 (dipakai SH-04): dialog persetujuan sudah dijawab, sekarang minta
297 * id dan URL unggah, lalu simpan token hapus di perangkat. */
298 async function handleCreateLink() {
299 setLinking(true)
300 try {
301 const created = await requestStrip(
302 layoutId,
303 liveWanted && liveKind !== null ? liveKind : undefined
304 )
305 saveStripToken(created.id, created.deleteToken)
306 pendingUploadRef.current = {
307 id: created.id,
308 upload: created.upload,
309 ...(created.liveUpload === undefined
310 ? {}
311 : { liveUpload: created.liveUpload }),
312 }
313 setShareStrip({ id: created.id, expiresAt: null })
314 track("strip_link_created")
315 } catch (error) {
316 toast.error(
317 error instanceof Error
318 ? error.message
319 : "Link belum bisa dibuat. Coba lagi sebentar lagi."
320 )
321 } finally {
322 setLinking(false)
323 }
324 }
325
326 function handleDownload() {
327 if (dataUrl === null) {
328 return
329 }
330 downloadStrip(dataUrl, formatStripFilename(new Date()))
331 trackDownload()
332 toast.success("Strip tersimpan di perangkatmu.")
333 }
334
335 /** M8 (SE-07): "Selesai & Unduh" milik teman. Estafetnya dituntaskan dulu
336 * (foto slot yang belum terunggah ikut dikirim), stripnya diunggah, lalu
337 * berkasnya diunduh dan halaman beralih ke /s/{id}. */
338 async function handleFinishRelay() {
339 if (dataUrl === null || finishing || relay === null) {
340 return
341 }
342 const stored = readRelayToken(relay.id)
343 if (stored === null || stored.role !== "guest") {
344 toast.error("Token estafet tidak ada. Buka lagi link estafetmu.")
345 return
346 }
347 setFinishing(true)
348 try {
349 const completed = await finishRelayOnce(relay.id, stored.token)
350 if (completed.deleteToken !== undefined) {
351 saveStripToken(completed.stripId, completed.deleteToken)
352 }
353 if (completed.upload !== undefined) {
354 const blob = await (await fetch(dataUrl)).blob()
355 await uploadStripStill(completed.upload, blob)
356 await completeStrip(completed.stripId)
357 }
358 downloadStrip(dataUrl, formatStripFilename(new Date()))
359 track("relay_completed")
360 trackDownload()
361 toast.success("Estafet selesai. Strip kamu ada di link pribadi.")
362 /* FR-14: rekaman perangkat dihapus supaya sesi teman tidak terbawa ke
363 sesi berikutnya. `reset()` tidak dipanggil di sini: mengosongkan
364 layout dan foto sebelum navigasi selesai membuat gerbang langkah booth
365 mengalihkan ke pemilih layout lebih dulu. Sesi di memori dibiarkan
366 utuh sampai pengguna memulai sesi baru; yang dilepas hanya irisan
367 estafetnya, supaya baki foto tidak lagi terkunci. */
368 await clearSession()
369 useBoothStore.setState({ relay: null })
370 router.push(`/s/${completed.stripId}`)
371 } catch (error) {
372 toast.error(
373 error instanceof Error
374 ? error.message
375 : "Estafet belum bisa diselesaikan. Coba lagi sebentar lagi."
376 )
377 } finally {
378 setFinishing(false)
379 }
380 }
381
382 function handleRestart() {
383 /* FR-14: rekaman di perangkat dihapus lebih dulu, lalu sesi di memori. */
384 void clearSession()
385 reset()
386 router.replace("/booth/layout")
387 }
388
389 /** SH-03: unduhan lokal versi hidup, tanpa unggahan. */
390 async function handleDownloadVideo() {
391 if (videoBusy) {
392 return
393 }
394 setVideoBusy(true)
395 try {
396 const recorded = await liveRef.current?.record()
397 if (recorded === undefined || recorded === null) {
398 toast.error("Video belum bisa direkam di browser ini.")
399 return
400 }
401 downloadBlob(recorded.blob, formatStripFilename(new Date(), recorded.kind))
402 toast.success("Video strip tersimpan di perangkatmu.")
403 } catch {
404 toast.error("Video belum bisa direkam di browser ini.")
405 } finally {
406 setVideoBusy(false)
407 }
408 }
409
410 const preparing = dataUrl === null && !failed
411
412 return (
413 <>
414 <BoothPanel
415 title="Hasil"
416 backHref="/booth/customize"
417 actions={<Badge variant="lime">{`${countFilled(shots)} foto`}</Badge>}
418 left={
419 <div className="flex flex-col gap-3 rounded-card border-2 border-ink bg-paper p-4">
420 <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[15px]">
421 <dt className="self-center font-mono text-xs font-bold tracking-[0.06em] text-muted uppercase">
422 Layout
423 </dt>
424 <dd className="font-bold">{layout.label}</dd>
425 <dt className="self-center font-mono text-xs font-bold tracking-[0.06em] text-muted uppercase">
426 Frame
427 </dt>
428 <dd className="font-bold">{getFrame(frameId).label}</dd>
429 <dt className="self-center font-mono text-xs font-bold tracking-[0.06em] text-muted uppercase">
430 Efek
431 </dt>
432 <dd className="font-bold">{getEffect(effectId).label}</dd>
433 <dt className="self-center font-mono text-xs font-bold tracking-[0.06em] text-muted uppercase">
434 Stiker
435 </dt>
436 <dd className="font-bold">{stickers.length}</dd>
437 </dl>
438 </div>
439 }
440 center={
441 <>
442 <div className="flex justify-center rounded-stage border-3 border-ink bg-cream p-6 md:p-9">
443 {dataUrl === null ? (
444 <div
445 className="flex items-center justify-center rounded-thumb border-2 border-dashed border-muted"
446 style={{
447 width: previewWidth,
448 aspectRatio: `${layout.canvas.width} / ${layout.canvas.height}`,
449 }}
450 >
451 <span className="sr-only">Menyiapkan hasil</span>
452 </div>
453 ) : (
454 /* DataURL hasil ekspor, bukan aset: next/image tidak dipakai. */
455 /* eslint-disable-next-line @next/next/no-img-element */
456 <img
457 src={dataUrl}
458 alt="Pratinjau strip"
459 style={{ width: previewWidth }}
460 className="h-auto border-2 border-ink"
461 />
462 )}
463 </div>
464 <p className="text-center font-mono text-xs text-muted">
465 {`Ekspor ${layout.canvas.width} x ${layout.canvas.height} px`}
466 </p>
467 <p className="flex items-center justify-center gap-2 text-sm text-muted">
468 <Lock weight="bold" className="size-5 shrink-0" aria-hidden />
469 {isGuest
470 ? "Kamera dan hiasan berjalan di perangkat ini. Stripnya diunggah saat estafetnya diselesaikan."
471 : localOnly
472 ? "Salinan ini hanya di perangkatmu. Tidak ada yang diunggah."
473 : shareStrip === null
474 ? "Semua diproses di perangkat ini. Tidak ada yang diunggah."
475 : "Strip kamu ada di link pribadi. Tidak ada yang lain diunggah."}
476 </p>
477 </>
478 }
479 right={
480 <div className="mt-auto flex flex-col gap-3">
481 {hidesUpload ? null : share.qrEnabled || shareStrip !== null ? (
482 <ShareCard
483 strip={shareStrip}
484 busy={linking}
485 onCreate={handleCreateLink}
486 withLive={liveWanted}
487 statusText={liveBusy ? "Merekam versi hidup..." : undefined}
488 />
489 ) : null}
490
491 <div className="flex flex-col gap-2">
492 <Button
493 size="lg"
494 className="w-full"
495 disabled={dataUrl === null || finishing}
496 onClick={() =>
497 isGuest ? void handleFinishRelay() : handleDownload()
498 }
499 >
500 <DownloadSimple
501 weight="bold"
502 className="size-[22px]"
503 aria-hidden
504 />
505 {isGuest && finishing
506 ? "Menyelesaikan estafet..."
507 : preparing
508 ? "Menyiapkan berkas..."
509 : "Selesai & Unduh"}
510 </Button>
511 <Button
512 variant="neutral"
513 className="w-full"
514 disabled={sheetUrl === null}
515 onClick={() => {
516 track("print_4r")
517 window.print()
518 }}
519 >
520 <Printer weight="bold" className="size-[22px]" aria-hidden />
521 Cetak 4R
522 </Button>
523 {/* SH-03: tombolnya hanya ada saat browsernya bisa merekam dan
524 sesinya punya klip; unduhan lokal tidak butuh sakelar Hias. */}
525 {liveKind !== null && hasLiveClips(shots) ? (
526 <Button
527 variant="neutral"
528 className="w-full"
529 disabled={videoBusy}
530 onClick={() => void handleDownloadVideo()}
531 >
532 <DownloadSimple
533 weight="bold"
534 className="size-[22px]"
535 aria-hidden
536 />
537 {videoBusy ? "Merekam..." : "Unduh Video"}
538 </Button>
539 ) : null}
540 <Button variant="ghost" className="w-full" onClick={handleRestart}>
541 Mulai dari awal
542 </Button>
543 {failed ? (
544 <p className="text-sm leading-[1.4] text-danger" role="alert">
545 Berkas belum bisa disiapkan. Muat ulang halaman, lalu coba
546 lagi.
547 </p>
548 ) : null}
549 </div>
550 </div>
551 }
552 />
553
554 {/* Panggung ekspor di luar layar: sumber dataUrl di atas. */}
555 <div className="absolute top-0 left-[-9999px]" aria-hidden>
556 <Stage
557 ref={stageRef}
558 width={previewWidth}
559 height={layout.canvas.height * (previewWidth / layout.canvas.width)}
560 listening={false}
561 >
562 <StripStage
563 layoutId={layoutId}
564 shots={shots}
565 frame={getFrame(frameId)}
566 effect={getEffect(effectId).params}
567 stickers={stickers}
568 texts={texts}
569 showDate={showDate}
570 qr={qr}
571 scale={previewWidth / layout.canvas.width}
572 groupRef={groupRef}
573 onSelect={IGNORE}
574 onChangeSticker={IGNORE}
575 onChangeText={IGNORE}
576 />
577 </Stage>
578 </div>
579
580 {/* SH-03: panggung rekam versi hidup, di luar layar seperti panggung
581 ekspor. Hanya dipakai saat pengguna membuat link berklip atau meminta
582 unduhan video. */}
583 <LiveStage
584 ref={liveRef}
585 layoutId={layoutId}
586 shots={shots}
587 frame={getFrame(frameId)}
588 effect={getEffect(effectId).params}
589 stickers={stickers}
590 texts={texts}
591 showDate={showDate}
592 qr={qr}
593 />
594
595 {/* FR-12: satu halaman 4 x 6 inci. Portal ke `body` supaya CSS cetak
596 bisa menghilangkan seluruh isi halaman dan menyisakan lembar ini;
597 tersembunyi di layar lewat `hidden`, ditampilkan blok `@media print`
598 di globals.css. */}
599 {sheetUrl === null
600 ? null
601 : createPortal(
602 <div data-print-sheet="4r" className="hidden">
603 {/* DataURL lembar cetak, bukan aset: next/image tidak dipakai. */}
604 {/* eslint-disable-next-line @next/next/no-img-element */}
605 <img src={sheetUrl} alt="Lembar cetak 4R" />
606 </div>,
607 document.body
608 )}
609 </>
610 )
611}
612