1"use client"
2
3import { useEffect } from "react"
4import { useRouter } from "next/navigation"
5import { nanoid } from "nanoid"
6import { create } from "zustand"
7
8import { DEFAULT_EFFECT_ID, DEFAULT_FRAME_ID } from "@/features/editor/defaults"
9import { LAYOUTS, FOOTER_WORDMARK_ROW, type LayoutId } from "@/lib/layouts"
10
11/* ARCHITECTURE §5: state sesi booth. Layout terpilih, foto per slot, pengaturan
12 perangkat, dan slice editor (M4) serta bagikan (M6). `live` dan `relay`
13 ditambahkan milestone pemiliknya.
14 Invarian bundel: berkas ini tidak boleh mengimpor Konva supaya `/` dan
15 `/booth/capture` tidak ikut memuat pustaka kanvas. */
16
17interface BoothSettings {
18 gestureEnabled: boolean
19 cameraDeviceId: string | null
20}
21
22/** Satu foto tersimpan di memori sebagai dataURL JPEG (IndexedDB menyusul di M5).
23 * M7: `clip` adalah bingkai Strip Hidup (SH-01) bila perekam berhasil; hanya
24 * di memori, tidak ikut rekaman sesi (`features/session/sessionDb.ts`). */
25export interface Shot {
26 still: string
27 capturedAt: number
28 clip?: ImageBitmap[]
29}
30
31type Shots = (Shot | null)[]
32
33/** Stiker yang sudah ditempel. Koordinat ruang kanvas layout (lib/layouts.ts);
34 * `x` dan `y` adalah sudut kiri atas, sama seperti node Konva. */
35export interface StickerInstance {
36 id: string
37 assetId: string
38 x: number
39 y: number
40 rotation: number
41 width: number
42}
43
44/** Teks pendek FR-10. `x` adalah titik tengah kotak teks, `y` tepi atasnya,
45 * supaya teks panjang tetap terpusat di kaki strip. */
46export interface StripText {
47 id: string
48 value: string
49 x: number
50 y: number
51 rotation: number
52 fontSize: number
53}
54
55export const STICKER_WIDTH_MIN = 48
56export const STICKER_WIDTH_MAX = 480
57export const TEXT_LENGTH_MAX = 24
58export const TEXT_FONT_MIN = 24
59export const TEXT_FONT_MAX = 96
60
61/** M6 (SH-04 sampai SH-06): irisan bagikan. `qrEnabled` adalah pilihan di
62 * langkah Hias; `strip` terisi setelah link dibuat dan stripnya selesai
63 * diunggah. Token hapus tidak masuk state, melainkan penyimpanan perangkat
64 * (`features/share/stripToken.ts`). */
65export interface ShareState {
66 qrEnabled: boolean
67 strip: { id: string; expiresAt: string | null } | null
68}
69
70/** M7 (SH-02): irisan Strip Hidup. `enabled` adalah sakelar di langkah Hias;
71 * kesiapannya mengikuti ada tidaknya klip di `shots`. */
72interface LiveState {
73 enabled: boolean
74}
75
76/** M8 (SE-04, SE-05, SE-10): irisan Strip Estafet. `lockedSlots` berisi indeks
77 * slot milik pihak lain yang tidak boleh diubah dari perangkat ini;
78 * `ghostEnabled` adalah sakelar bayangan pose teman di pratinjau kamera.
79 * `role` menentukan siapa yang boleh menuntaskan unggahan di langkah Hasil. */
80export interface RelaySession {
81 id: string
82 role: "host" | "guest"
83 lockedSlots: number[]
84 ghostEnabled: boolean
85}
86
87/** Perubahan yang boleh ditulis balik dari kanvas atau daftar "Isi strip". */
88export type StickerPatch = Partial<
89 Pick<StickerInstance, "x" | "y" | "rotation" | "width">
90>
91export type TextPatch = Partial<
92 Pick<StripText, "x" | "y" | "rotation" | "fontSize">
93>
94
95/* Posisi bawaan item baru mengikuti geometri layout aktif (lib/layouts.ts):
96 stiker di tengah kanvas, teks tepat di bawah baris wordmark kaki strip. */
97
98interface EditorState {
99 frameId: string
100 effectId: string
101 stickers: StickerInstance[]
102 texts: StripText[]
103 showDate: boolean
104}
105
106interface BoothState extends EditorState {
107 layoutId: LayoutId | null
108 shots: Shots
109 settings: BoothSettings
110 share: ShareState
111 live: LiveState
112 relay: RelaySession | null
113 /** M8 (SE-10): sesi yang dibuka dari estafet yang sudah selesai. Hanya
114 * lokal: tidak ada unggahan baru, jadi kartu bagikan dan QR disembunyikan. */
115 localOnly: boolean
116 /** FR-14: `true` setelah SessionSync selesai membaca IndexedDB. Bukan milik
117 * rekaman dan tidak direset, supaya gerbang langkah booth menunggu, bukan
118 * memantul lebih dulu. */
119 sessionReady: boolean
120 setLayout: (id: LayoutId) => void
121 setShot: (index: number, shot: Shot) => void
122 setCameraDevice: (deviceId: string) => void
123 setGestureEnabled: (enabled: boolean) => void
124 setFrame: (id: string) => void
125 setEffect: (id: string) => void
126 addSticker: (assetId: string) => void
127 updateSticker: (id: string, patch: StickerPatch) => void
128 removeSticker: (id: string) => void
129 setStripText: (value: string) => void
130 updateText: (id: string, patch: TextPatch) => void
131 removeText: (id: string) => void
132 setShowDate: (shown: boolean) => void
133 setQrEnabled: (enabled: boolean) => void
134 setShareStrip: (strip: ShareState["strip"]) => void
135 setLiveEnabled: (enabled: boolean) => void
136 setRelay: (relay: RelaySession | null) => void
137 setGhostEnabled: (enabled: boolean) => void
138 setLocalOnly: (localOnly: boolean) => void
139 reset: () => void
140}
141
142/* GS-06: pilihan Gesture Snap diingat di perangkat, bukan di server. Kunci ini
143 satu-satunya jejak yang ditulis ke localStorage. */
144export const GESTURE_PREF_KEY = "snapme:gesture-enabled"
145
146/** Dipanggil sekali saat halaman capture dipasang, setelah hidrasi SSR. */
147export function hydrateGesturePreference(): void {
148 if (typeof window === "undefined") {
149 return
150 }
151 const stored = window.localStorage.getItem(GESTURE_PREF_KEY)
152 if (stored === null) {
153 return
154 }
155 useBoothStore.setState((state) => ({
156 settings: { ...state.settings, gestureEnabled: stored === "1" },
157 }))
158}
159
160/* Invarian: `shots.length` selalu sama dengan `shotCount` layout terpilih. */
161function freshShots(id: LayoutId): Shots {
162 return Array<Shot | null>(LAYOUTS[id].shotCount).fill(null)
163}
164
165/* Stiker baru ditempel miring 6 derajat di tengah strip (DESIGN-SYSTEM §1:
166 stiker ditempel miring sebagai aksen). */
167function freshState() {
168 return {
169 layoutId: null,
170 shots: [] as Shots,
171 settings: { gestureEnabled: true, cameraDeviceId: null },
172 frameId: DEFAULT_FRAME_ID,
173 effectId: DEFAULT_EFFECT_ID,
174 stickers: [] as StickerInstance[],
175 texts: [] as StripText[],
176 showDate: false,
177 share: { qrEnabled: false, strip: null },
178 live: { enabled: false },
179 relay: null as RelaySession | null,
180 localOnly: false,
181 }
182}
183
184/* SH-01: bingkai klip menahan memori sampai `close()` dipanggil. Setiap jalur
185 yang mengganti atau membuang foto harus melepasnya. */
186function closeShotClips(shots: Shots): void {
187 for (const shot of shots) {
188 shot?.clip?.forEach((bitmap) => bitmap.close())
189 }
190}
191
192export const useBoothStore = create<BoothState>((set) => ({
193 ...freshState(),
194 sessionReady: false,
195 /* Ganti layout mengosongkan foto, jadi hiasan lama tidak boleh nyangkut:
196 jumlah slot berbeda dan posisinya tidak lagi cocok. Klip lama dilepas
197 lebih dulu supaya bingkainya tidak menahan memori (SH-01). */
198 setLayout: (id) =>
199 set((state) => {
200 closeShotClips(state.shots)
201 return { ...freshState(), layoutId: id, shots: freshShots(id) }
202 }),
203 setShot: (index, shot) =>
204 set((state) => {
205 closeShotClips([state.shots[index] ?? null])
206 const shots = state.shots.slice()
207 shots[index] = shot
208 return { shots }
209 }),
210 setCameraDevice: (deviceId) =>
211 set((state) => ({
212 settings: { ...state.settings, cameraDeviceId: deviceId },
213 })),
214 setGestureEnabled: (enabled) =>
215 set((state) => {
216 if (typeof window !== "undefined") {
217 window.localStorage.setItem(GESTURE_PREF_KEY, enabled ? "1" : "0")
218 }
219 return { settings: { ...state.settings, gestureEnabled: enabled } }
220 }),
221 setFrame: (id) => set({ frameId: id }),
222 setEffect: (id) => set({ effectId: id }),
223 /* Stiker baru ditempel miring 6 derajat di tengah kanvas (DESIGN-SYSTEM §1:
224 stiker ditempel miring sebagai aksen). */
225 addSticker: (assetId) =>
226 set((state) => {
227 const canvas =
228 state.layoutId === null
229 ? LAYOUTS["strip-4"].canvas
230 : LAYOUTS[state.layoutId].canvas
231 return {
232 stickers: [
233 ...state.stickers,
234 {
235 id: nanoid(8),
236 assetId,
237 x: canvas.width / 2,
238 y: canvas.height / 2,
239 rotation: -6,
240 width: 120,
241 },
242 ],
243 }
244 }),
245 updateSticker: (id, patch) =>
246 set((state) => ({
247 stickers: state.stickers.map((sticker) =>
248 sticker.id === id
249 ? {
250 ...sticker,
251 ...patch,
252 ...(patch.width === undefined
253 ? {}
254 : {
255 width: Math.min(
256 STICKER_WIDTH_MAX,
257 Math.max(STICKER_WIDTH_MIN, patch.width)
258 ),
259 }),
260 }
261 : sticker
262 ),
263 })),
264 removeSticker: (id) =>
265 set((state) => ({
266 stickers: state.stickers.filter((sticker) => sticker.id !== id),
267 })),
268 /* FR-10: maksimal 24 karakter. `Array.from` menghitung titik kode, bukan
269 satuan UTF-16, supaya emoji tidak terpotong di tengah pasangan surrogate. */
270 setStripText: (value) =>
271 set((state) => {
272 const trimmed = Array.from(value).slice(0, TEXT_LENGTH_MAX).join("")
273 if (trimmed.length === 0) {
274 return { texts: [] }
275 }
276 const [first] = state.texts
277 const layout =
278 state.layoutId === null ? LAYOUTS["strip-4"] : LAYOUTS[state.layoutId]
279 if (first === undefined) {
280 return {
281 texts: [
282 {
283 id: nanoid(8),
284 value: trimmed,
285 x: layout.canvas.width / 2,
286 y: layout.footerRect.y + FOOTER_WORDMARK_ROW,
287 rotation: 0,
288 fontSize: 44,
289 },
290 ],
291 }
292 }
293 return { texts: [{ ...first, value: trimmed }] }
294 }),
295 updateText: (id, patch) =>
296 set((state) => ({
297 texts: state.texts.map((text) =>
298 text.id === id
299 ? {
300 ...text,
301 ...patch,
302 ...(patch.fontSize === undefined
303 ? {}
304 : {
305 fontSize: Math.min(
306 TEXT_FONT_MAX,
307 Math.max(TEXT_FONT_MIN, patch.fontSize)
308 ),
309 }),
310 }
311 : text
312 ),
313 })),
314 removeText: (id) =>
315 set((state) => ({ texts: state.texts.filter((text) => text.id !== id) })),
316 setShowDate: (shown) => set({ showDate: shown }),
317 setQrEnabled: (enabled) =>
318 set((state) => ({ share: { ...state.share, qrEnabled: enabled } })),
319 setShareStrip: (strip) =>
320 set((state) => ({ share: { ...state.share, strip } })),
321 setLiveEnabled: (enabled) => set({ live: { enabled } }),
322 setRelay: (relay) => set({ relay }),
323 setGhostEnabled: (enabled) =>
324 set((state) =>
325 state.relay === null
326 ? {}
327 : { relay: { ...state.relay, ghostEnabled: enabled } }
328 ),
329 setLocalOnly: (localOnly) => set({ localOnly }),
330 reset: () =>
331 set((state) => {
332 closeShotClips(state.shots)
333 return freshState()
334 }),
335}))
336
337export function countFilled(shots: Shots): number {
338 return shots.reduce((total, shot) => (shot === null ? total : total + 1), 0)
339}
340
341/** SH-02: pratinjau hidup hanya punya bahan bila minimal satu slot berklip. */
342export function hasLiveClips(shots: Shots): boolean {
343 return shots.some((shot) => (shot?.clip?.length ?? 0) > 0)
344}
345
346export function allFilled(shots: Shots): boolean {
347 return shots.length > 0 && shots.every((shot) => shot !== null)
348}
349
350/** ARCHITECTURE §5: langkah booth tanpa prasyarat dialihkan ke langkah yang belum selesai.
351 * Selama `sessionReady` masih `false`, pengalihan ditunda: sesi tersimpan
352 * (FR-14) belum terlihat, jadi muat ulang tidak boleh memantul ke pemilih. */
353export function useRequireLayout(): boolean {
354 const router = useRouter()
355 const layoutId = useBoothStore((state) => state.layoutId)
356 const sessionReady = useBoothStore((state) => state.sessionReady)
357
358 useEffect(() => {
359 if (sessionReady && layoutId === null) {
360 router.replace("/booth/layout")
361 }
362 }, [sessionReady, layoutId, router])
363
364 return layoutId !== null
365}
366
367/** Langkah setelah foto tanpa semua slot terisi dialihkan kembali ke Ambil Foto. */
368export function useRequireShots(): boolean {
369 const router = useRouter()
370 const sessionReady = useBoothStore((state) => state.sessionReady)
371 const filled = useBoothStore((state) => allFilled(state.shots))
372
373 useEffect(() => {
374 if (sessionReady && !filled) {
375 router.replace("/booth/capture")
376 }
377 }, [sessionReady, filled, router])
378
379 return sessionReady && filled
380}
381