'use client'; import { useCallback, useEffect, useState } from 'react'; export type PublicRoomState = { id: string; settings: { chzzkEnabled: boolean; liveUrl: string; channelId: string | null; cheesePerDice: number; multiplyByCheese: boolean; maxMultiplier: number; }; diceQueue: number; channelName: string | null; chatChannelId: string | null; chatStatus: 'idle' | 'connecting' | 'connected' | 'error' | 'disconnected'; chatError: string | null; lastDonations: Array<{ id: string; at: number; payAmount: number; diceAdded: number; donatorNickname: string; donationText?: string; simulated?: boolean; }>; chatFeed: Array<{ id: string; at: number; nickname: string; message: string; kind: 'chat' | 'donation'; payAmount?: number; }>; }; export function useRoom(roomId: string | null) { const [room, setRoom] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (!roomId) return; const clientId = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `c-${Date.now()}-${Math.random().toString(16).slice(2)}`; let es: EventSource | null = null; let cancelled = false; void fetch(`/api/rooms/${roomId}`) .then(async (r) => { const j = await r.json(); if (cancelled) return; if (!r.ok) { setError(j.error || '방을 불러오지 못했습니다.'); return; } if (j.room) setRoom(j.room as PublicRoomState); else setError(j.error || '방을 불러오지 못했습니다.'); es = new EventSource( `/api/rooms/${roomId}/events?clientId=${encodeURIComponent(clientId)}`, ); es.addEventListener('room', (ev) => { try { setRoom(JSON.parse((ev as MessageEvent).data) as PublicRoomState); setError(null); } catch { // ignore } }); es.onerror = () => { // keep last state; browser will retry SSE }; }) .catch(() => { if (!cancelled) setError('방을 불러오지 못했습니다.'); }); const leave = () => { try { const payload = JSON.stringify({ clientId }); if (typeof navigator !== 'undefined' && navigator.sendBeacon) { navigator.sendBeacon( `/api/rooms/${roomId}/leave`, new Blob([payload], { type: 'application/json' }), ); } else { void fetch(`/api/rooms/${roomId}/leave`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, keepalive: true, }); } } catch { // ignore } }; window.addEventListener('pagehide', leave); return () => { cancelled = true; window.removeEventListener('pagehide', leave); es?.close(); leave(); }; }, [roomId]); const consumeDice = useCallback( async (count = 1) => { if (!roomId) return { ok: true as const }; const res = await fetch(`/api/rooms/${roomId}/consume-dice`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ count }), }); const json = await res.json(); if (!res.ok) { return { ok: false as const, error: (json.error as string) || '주사위를 소모할 수 없습니다.', }; } setRoom(json.room as PublicRoomState); return { ok: true as const }; }, [roomId], ); const simulateDonation = useCallback( async (payAmount: number) => { if (!roomId) return; const res = await fetch(`/api/rooms/${roomId}/simulate-donation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ payAmount }), }); const json = await res.json(); if (!res.ok) throw new Error(json.error || '시뮬레이션 실패'); setRoom(json.room as PublicRoomState); }, [roomId], ); const addDice = useCallback( async (count = 1) => { if (!roomId) return { ok: false as const, error: '방이 없습니다.' }; const res = await fetch(`/api/rooms/${roomId}/add-dice`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ count }), }); const json = await res.json(); if (!res.ok) { return { ok: false as const, error: (json.error as string) || '주사위를 추가할 수 없습니다.', }; } setRoom(json.room as PublicRoomState); return { ok: true as const }; }, [roomId], ); return { room, error, consumeDice, simulateDonation, addDice }; }