1"use client"
2
3import { useRef, useState } from "react"
4import Link from "next/link"
5import {
6 Check,
7 DownloadSimple,
8 Pause,
9 Play,
10 Sticker as StickerIcon,
11} from "@phosphor-icons/react"
12import type { Group as KonvaGroup } from "konva/lib/Group"
13import type { Stage as KonvaStage } from "konva/lib/Stage"
14import { useReducedMotion } from "motion/react"
15
16import { BoothPanel } from "@/features/booth/BoothPanel"
17import { Badge } from "@/components/ui/badge"
18import { Button } from "@/components/ui/button"
19import { Switch } from "@/components/ui/switch"
20import {
21 Drawer,
22 DrawerContent,
23 DrawerHeader,
24 DrawerTitle,
25} from "@/components/ui/drawer"
26import { CLIP_FRAME_COUNT } from "@/features/capture/clipRecorder"
27import { useClipTicker } from "@/features/live/useClipTicker"
28import { track } from "@/lib/analytics"
29import { getLayout, type LayoutId } from "@/lib/layouts"
30import { cn } from "@/lib/utils"
31import { countFilled, hasLiveClips, useBoothStore } from "@/stores/booth"
32import { DecorList } from "./DecorList"
33import { DecorPanel } from "./DecorPanel"
34import StripCanvas from "./StripCanvas"
35import { getEffect, getFrame } from "./presets"
36
37/* DESIGN-SYSTEM §10.3: halaman Hias Strip. Rel kiri pilihan hias (lembar bawah
38 di layar sempit), tengah kanvas strip, rel kanan kartu Strip Hidup lalu QR di
39 strip, ringkasan, dan aksi utama.
40 Berkas ini dan seluruh isi `features/editor` hanya dimuat lewat import()
41 dinamis dari halaman, supaya Konva tidak ikut bundel `/` dan `/booth/capture`. */
42
43export default function CustomizeEditor({ layoutId }: { layoutId: LayoutId }) {
44 const shots = useBoothStore((state) => state.shots)
45 const frameId = useBoothStore((state) => state.frameId)
46 const effectId = useBoothStore((state) => state.effectId)
47 const stickers = useBoothStore((state) => state.stickers)
48 const qrEnabled = useBoothStore((state) => state.share.qrEnabled)
49 const shareStrip = useBoothStore((state) => state.share.strip)
50 const setQrEnabled = useBoothStore((state) => state.setQrEnabled)
51 const liveEnabled = useBoothStore((state) => state.live.enabled)
52 const setLiveEnabled = useBoothStore((state) => state.setLiveEnabled)
53 /* M8: sesi teman tidak pernah mengunggah QR, dan salinan lokal pembuat
54 (SE-10) tidak boleh menimpa versi temannya. Di dua keadaan itu kartu QR
55 disembunyikan, dan versi hidup hanya jadi unduhan lokal. */
56 const relay = useBoothStore((state) => state.relay)
57 const localOnly = useBoothStore((state) => state.localOnly)
58 const hidesUpload = relay?.role === "guest" || localOnly
59
60 const stageRef = useRef<KonvaStage>(null)
61 const groupRef = useRef<KonvaGroup>(null)
62 const [drawerOpen, setDrawerOpen] = useState(false)
63 /* PRD §4: "Strip Hidup menambah nilai" dihitung dari sesi yang benar-benar
64 menyalakannya, jadi menyalakan-mematikan-menyalakan lagi tetap satu
65 peristiwa per kunjungan halaman Hias. */
66 const liveTracked = useRef(false)
67
68 const handleLiveToggle = (enabled: boolean) => {
69 setLiveEnabled(enabled)
70 if (enabled && !liveTracked.current) {
71 liveTracked.current = true
72 track("live_enabled")
73 }
74 }
75
76 /* SH-02: pratinjau berputar selama sakelarnya nyala, kecuali pengguna
77 meminta gerak dikurangi. Di situ klip ditampilkan diam dengan tombol
78 Putar, dan `userPlay` menjadi izin eksplisitnya. */
79 const hasClips = hasLiveClips(shots)
80 const prefersReducedMotion = useReducedMotion()
81 const [userPlay, setUserPlay] = useState(false)
82 const playing =
83 liveEnabled && hasClips && (userPlay || prefersReducedMotion === false)
84 const clipIndex = useClipTicker(playing, CLIP_FRAME_COUNT)
85
86 const layout = getLayout(layoutId)
87
88 return (
89 <>
90 <BoothPanel
91 title="Hias Strip"
92 backHref="/booth/capture"
93 actions={
94 <Badge variant="lime">
95 <Check weight="bold" aria-hidden />
96 {`${countFilled(shots)} foto siap`}
97 </Badge>
98 }
99 left={
100 <>
101 <Button
102 variant="neutral"
103 className="w-full md:hidden"
104 onClick={() => setDrawerOpen(true)}
105 >
106 <StickerIcon weight="bold" className="size-[18px]" aria-hidden />
107 Hiasan
108 </Button>
109 <div className="hidden md:block">
110 <DecorPanel />
111 </div>
112 </>
113 }
114 center={
115 <>
116 <div className="flex justify-center rounded-stage border-3 border-ink bg-cream p-6 md:p-9">
117 <StripCanvas
118 layoutId={layoutId}
119 stageRef={stageRef}
120 groupRef={groupRef}
121 live={
122 liveEnabled && hasClips
123 ? {
124 frames: shots.map((shot) => shot?.clip ?? null),
125 index: playing ? clipIndex : -1,
126 chrome: true,
127 }
128 : undefined
129 }
130 />
131 </div>
132 <p className="text-center font-mono text-xs text-muted">
133 {`Ekspor ${layout.canvas.width} x ${layout.canvas.height} px (${layout.canvas.width / 300} x ${layout.canvas.height / 300} inci, 300 dpi)`}
134 </p>
135 <DecorList />
136 </>
137 }
138 right={
139 <>
140 {/* SH-02: kartu Strip Hidup, urutan pertama rel kanan mengikuti
141 docs/ui-reference/customize.html. Sakelar selalu disertai teks
142 keadaan (DESIGN-SYSTEM §9). */}
143 <div className="flex flex-col gap-2 rounded-card border-2 border-ink bg-paper p-4">
144 <div className="flex items-center justify-between gap-3">
145 <h2 className="font-display text-base font-extrabold">
146 Strip Hidup
147 </h2>
148 <div className="flex items-center gap-2.5">
149 <span
150 className={cn(
151 "rounded-pill border-2 border-ink px-2.5 py-0.5 font-mono text-xs font-bold tracking-[0.06em] text-ink uppercase",
152 liveEnabled ? "bg-lime" : "bg-cream"
153 )}
154 >
155 {liveEnabled ? "Nyala" : "Mati"}
156 </span>
157 <Switch
158 checked={liveEnabled}
159 disabled={!hasClips || shareStrip !== null}
160 onCheckedChange={handleLiveToggle}
161 aria-label="Strip Hidup"
162 />
163 </div>
164 </div>
165 <p className="text-[15px] leading-[1.4]">
166 {!hasClips
167 ? "Klip hanya terekam saat memotret lewat kamera."
168 : hidesUpload
169 ? "Tiap foto diputar sebagai klip pendek di perangkat ini. Versi hidupnya tidak ikut diunggah."
170 : "Tiap foto diputar sebagai klip pendek di halaman strip."}
171 </p>
172 {liveEnabled && hasClips && shareStrip === null && !hidesUpload ? (
173 <p
174 role="status"
175 className="rounded-thumb border-2 border-ink bg-lime px-2.5 py-2 text-sm leading-[1.4]"
176 >
177 Versi hidup ikut diunggah saat kamu membuat link.
178 </p>
179 ) : null}
180 {liveEnabled && hasClips && prefersReducedMotion === true ? (
181 <Button
182 variant="neutral"
183 size="sm"
184 aria-pressed={userPlay}
185 onClick={() => setUserPlay((current) => !current)}
186 >
187 {userPlay ? (
188 <Pause weight="bold" className="size-[18px]" aria-hidden />
189 ) : (
190 <Play weight="bold" className="size-[18px]" aria-hidden />
191 )}
192 {userPlay ? "Jeda" : "Putar"}
193 </Button>
194 ) : null}
195 </div>
196
197 {/* SH-04: kartu QR di strip. Teksnya dari docs/ui-reference/
198 customize.html; tidak ada aksi `tangerine` baru di sini supaya
199 satu aksi utama per layar tetap "Selesai & Unduh".
200 M8: teman tidak mengunggah QR, dan salinan lokal pembuat juga
201 tidak, jadi kartunya disembunyikan di dua keadaan itu. */}
202 {hidesUpload ? null : (
203 <div className="flex flex-col gap-2 rounded-card border-2 border-ink bg-paper p-4">
204 <div className="flex items-center justify-between gap-3">
205 <h2 className="font-display text-base font-extrabold">
206 QR di strip
207 </h2>
208 <Switch
209 checked={qrEnabled || shareStrip !== null}
210 disabled={shareStrip !== null}
211 onCheckedChange={setQrEnabled}
212 aria-label="QR di strip"
213 />
214 </div>
215 <p className="text-[15px] leading-[1.4]">
216 QR membuka halaman strip. Fotomu diunggah ke link pribadi dan
217 terhapus otomatis setelah 7 hari.
218 </p>
219 {shareStrip !== null ? (
220 <p
221 role="status"
222 className="rounded-thumb border-2 border-ink bg-lime px-2.5 py-2 text-sm leading-[1.4]"
223 >
224 Link sudah dibuat di langkah Hasil.
225 </p>
226 ) : qrEnabled ? (
227 <p
228 role="status"
229 className="rounded-thumb border-2 border-ink bg-lime px-2.5 py-2 text-sm leading-[1.4]"
230 >
231 Kamu akan dimintai izin unggah saat mengunduh.
232 </p>
233 ) : null}
234 </div>
235 )}
236
237 <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-[15px]">
238 <dt className="self-center font-mono text-xs font-bold tracking-[0.06em] text-muted uppercase">
239 Frame
240 </dt>
241 <dd className="font-bold">{getFrame(frameId).label}</dd>
242 <dt className="self-center font-mono text-xs font-bold tracking-[0.06em] text-muted uppercase">
243 Efek
244 </dt>
245 <dd className="font-bold">{getEffect(effectId).label}</dd>
246 <dt className="self-center font-mono text-xs font-bold tracking-[0.06em] text-muted uppercase">
247 Stiker
248 </dt>
249 <dd className="font-bold">{stickers.length}</dd>
250 </dl>
251
252 <div className="mt-auto">
253 <Button size="lg" className="w-full" asChild>
254 <Link href="/booth/result">
255 <DownloadSimple
256 weight="bold"
257 className="size-[22px]"
258 aria-hidden
259 />
260 Selesai & Unduh
261 </Link>
262 </Button>
263 </div>
264 </>
265 }
266 />
267
268 <Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
269 <DrawerContent>
270 <DrawerHeader>
271 <DrawerTitle>Hiasan</DrawerTitle>
272 </DrawerHeader>
273 <div className="overflow-y-auto px-5 pb-6">
274 <DecorPanel />
275 </div>
276 </DrawerContent>
277 </Drawer>
278 </>
279 )
280}
281