'use client'; import { useEffect, useId, useRef, useState } from 'react'; import type { GameView } from '@/game/engine'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { Textarea } from './ui/textarea'; import { requestJson } from '@/lib/client-request'; import { HANDOVER_CLAIM_STORAGE_KEY, clearHandoverClaim, createHandoverClaim, createHandoverKit, handoverClaimConfirmed, handoverOwnerStorageKey, parseHandoverClaim, parseHandoverKit, saveHandoverClaim, serializeHandoverKit, type SavedHandoverClaim, type SeatHandoverKit, } from '@/lib/seat-handover'; type OwnerRecord = { kit: SeatHandoverKit; expiresAt: number | null; operation: 'create' | 'revoke' | null; }; type ViewCallback = (view: GameView) => void; /** Mount by room + seat, so a browser never carries one owner's kit into another seat. */ export function SeatHandoverSetup({ game, disabled, onPending, onUncertain, onRestored, }: { game: GameView; disabled: boolean; onPending: (value: boolean) => void; onUncertain: (value: boolean) => void; onRestored: ViewCallback; }) { const id = useId(), pending = useRef(false); const storageKey = handoverOwnerStorageKey(game.code, game.me); const [record, setRecord] = useState(null); const [loaded, setLoaded] = useState(false); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false); const [initialRequestPending, setInitialRequestPending] = useState(false); const [replaceAcknowledged, setReplaceAcknowledged] = useState(false); const closed = !!game.roomControl?.closed; const own = game.players.find((player) => player.id === game.me); useEffect(() => { try { const saved = sessionStorage.getItem(storageKey); if (saved !== null) { const value: unknown = JSON.parse(saved); if (!value || typeof value !== 'object') throw new Error(); const stored = value as OwnerRecord, kit = parseHandoverKit(JSON.stringify(stored.kit)); if ( kit.roomCode !== game.code || kit.playerId !== game.me || (stored.expiresAt !== null && (!Number.isSafeInteger(stored.expiresAt) || stored.expiresAt < 0)) || ![null, 'create', 'revoke'].includes(stored.operation) ) throw new Error(); // oxlint-disable-next-line react/react-compiler -- Restore this seat's private tab-scoped offer after SSR. setRecord({ kit, expiresAt: stored.expiresAt, operation: stored.operation, }); } } catch { setMessage( 'The saved offer could not be read. Creating a replacement will invalidate any previous offer when confirmed.', ); } setLoaded(true); }, [storageKey, game.code, game.me]); useEffect(() => { onUncertain(!loaded || !!record?.operation); }, [loaded, record, onUncertain]); if (!own) return null; function store(next: OwnerRecord | null) { const text = next ? JSON.stringify(next) : null; if (text === null) sessionStorage.removeItem(storageKey); else sessionStorage.setItem(storageKey, text); if (sessionStorage.getItem(storageKey) !== text) throw new Error('The offer details could not be saved in this tab.'); setRecord(next); } async function submit(operation: 'create' | 'revoke', kit: SeatHandoverKit) { if (disabled || pending.current || (closed && !record?.operation)) return; pending.current = true; setReplaceAcknowledged(false); setBusy(true); setInitialRequestPending(!record?.operation); onPending(true); try { store({ kit, expiresAt: record?.expiresAt ?? null, operation }); const result = await requestJson<{ view: GameView; expiresAt?: number; revoked?: true; }>(`/api/rooms/${game.code}/control`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( operation === 'create' ? { type: 'createSeatHandover', version: game.version, offerId: kit.offerId, handoverSecret: kit.handoverSecret, } : { type: 'revokeSeatHandover', version: game.version, offerId: kit.offerId, }, ), }); if ( result.view?.code !== kit.roomCode || result.view?.me !== kit.playerId || (operation === 'create' ? !Number.isSafeInteger(result.expiresAt) : result.revoked !== true) ) throw new Error('The server did not confirm the offer.'); store( operation === 'create' ? { kit, expiresAt: result.expiresAt!, operation: null } : null, ); onRestored(result.view); setMessage( operation === 'create' ? 'Handover ready. Share this complete kit privately with the intended new player.' : 'Handover cancelled. This kit can no longer transfer your seat.', ); } catch (error) { setMessage( `${(error as Error).message} Keep this tab open. Retry the same operation to confirm its outcome; no retry is automatic.`, ); } finally { pending.current = false; setInitialRequestPending(false); setBusy(false); onPending(false); } } return (
Pass your seat to another player

Create a private, one-time handover for {own.name}. The recipient keeps this faction, its private hand and all progress. Acceptance revokes your browser sessions and recovery kits for this seat. The new owner should protect their saved seat with a new recovery kit.

The offer expires after 24 hours. You can keep playing until it is accepted. Recovering your seat invalidates its pending handover. Only share the kit with the intended recipient.

{closed &&

The room is closed. New handovers and cancellations are stopped; exact saved requests can still confirm a completed change.

} {initialRequestPending ? Saving handover… : !record ? ( ) : (
{record.operation ? ( <>

{record.operation === 'create' ? 'Offer creation is not confirmed. Confirm it before sharing the kit.' : 'Cancellation is not confirmed. The kit may still be usable.'}

) : ( <>