feat(reg): correct Phase 1 for pre-deployed devices + matrix-code binding
Teams already have their boards, so drop the kit pickup + sticker claim. New flow:
run the setup script on your board -> it self-registers and scrolls a code on its
LED matrix -> enter that code to bind the node to your team.
- api: BoardRegistry.claimByCode() binds the unique board whose claimCode matches,
no kit needed; /claim accepts code-first ({teamId, code[, members]}) and keeps the
legacy kit path. Snapshot/response use the resolved board's kitId.
- client: ClaimInput.kit optional, carries members.
- BoardClaim: setup-script command + copy, code from the matrix, 'Bind board'.
- TeamRegistration: remove KitSelector + QR-kit preselect; card is now 'Your board'.
- Tests updated + code-first coverage (registry + HTTP).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1b31f6db1b
commit
fc5f66021f
+11
-6
@@ -173,10 +173,15 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
app.post('/claim', async (req, res) => {
|
app.post('/claim', async (req, res) => {
|
||||||
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
|
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
|
||||||
const b = req.body ?? {}
|
const b = req.body ?? {}
|
||||||
if (typeof b.kit !== 'string' || typeof b.teamId !== 'string' || typeof b.code !== 'string') {
|
if (typeof b.teamId !== 'string' || typeof b.code !== 'string') {
|
||||||
return res.status(400).json({ error: 'kit, teamId and code are required' })
|
return res.status(400).json({ error: 'teamId and code are required' })
|
||||||
}
|
}
|
||||||
const result = boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
|
// Code-first (board scrolls its code on the matrix, no kit picked) is the
|
||||||
|
// default; a supplied `kit` keeps the legacy sticker-claim path working.
|
||||||
|
const result =
|
||||||
|
typeof b.kit === 'string' && b.kit
|
||||||
|
? boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
|
||||||
|
: boards.claimByCode(b.code, b.teamId, Date.parse(now()))
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.reason === 'unknown') {
|
if (result.reason === 'unknown') {
|
||||||
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' })
|
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' })
|
||||||
@@ -184,7 +189,7 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
if (result.reason === 'rate_limited') {
|
if (result.reason === 'rate_limited') {
|
||||||
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
|
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
|
||||||
}
|
}
|
||||||
return res.status(401).json({ error: 'wrong claim code' })
|
return res.status(401).json({ error: "wrong code — check what your board is showing on its matrix" })
|
||||||
}
|
}
|
||||||
const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim)
|
const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim)
|
||||||
await nodes.register({ teamId, url: result.board.url, token: result.board.token })
|
await nodes.register({ teamId, url: result.board.url, token: result.board.token })
|
||||||
@@ -196,7 +201,7 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
const team: TeamSnapshot = {
|
const team: TeamSnapshot = {
|
||||||
id: teamId,
|
id: teamId,
|
||||||
name: pickName ?? '',
|
name: pickName ?? '',
|
||||||
kit: b.kit,
|
kit: result.board.kitId,
|
||||||
members: pickMembers ?? [],
|
members: pickMembers ?? [],
|
||||||
domain: typeof prev?.domain === 'string' ? prev.domain : '',
|
domain: typeof prev?.domain === 'string' ? prev.domain : '',
|
||||||
phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
|
phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
|
||||||
@@ -208,7 +213,7 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
broadcast({ type: 'team:update', team })
|
broadcast({ type: 'team:update', team })
|
||||||
broadcastUnclaimed() // the claimed kit left the pool
|
broadcastUnclaimed() // the claimed kit left the pool
|
||||||
const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false
|
const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false
|
||||||
res.status(201).json({ teamId, kit: b.kit, url: result.board.url, online, resumed: result.resumed, team })
|
res.status(201).json({ teamId, kit: result.board.kitId, url: result.board.url, online, resumed: result.resumed, team })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Instructor action: release a kit back to the unclaimed pool and unbind its
|
// Instructor action: release a kit back to the unclaimed pool and unbind its
|
||||||
|
|||||||
@@ -112,6 +112,20 @@ describe('board self-register + claim', () => {
|
|||||||
expect(pool.body).toEqual({ kits: [] })
|
expect(pool.body).toEqual({ kits: [] })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('code-first: binds by the matrix code with no kit supplied', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/claim')
|
||||||
|
.send({ teamId: 'team-07', teamName: 'team_resonance', members: ['A. Rossi'], code: '418302' })
|
||||||
|
.expect(201)
|
||||||
|
expect(res.body).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', online: true, resumed: false })
|
||||||
|
expect(res.body.team).toMatchObject({ id: 'team-07', name: 'team_resonance', members: ['A. Rossi'], deviceConnected: true })
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('zc_secret_token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('code-first: a wrong code is rejected', async () => {
|
||||||
|
await request(app).post('/claim').send({ teamId: 'team-07', code: '000000' }).expect(401)
|
||||||
|
})
|
||||||
|
|
||||||
it('exposes public per-team liveness after a claim', async () => {
|
it('exposes public per-team liveness after a claim', async () => {
|
||||||
await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed
|
await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed
|
||||||
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ describe('board registry', () => {
|
|||||||
expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99
|
expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('claimByCode binds the matching board with no kit, and rejects a wrong code', () => {
|
||||||
|
const r = createBoardRegistry()
|
||||||
|
r.announce(board())
|
||||||
|
expect(r.claimByCode('000000', 'team-07', 0)).toEqual({ ok: false, reason: 'bad_code' })
|
||||||
|
const out = r.claimByCode('418302', 'team-07', 0)
|
||||||
|
expect(out).toMatchObject({ ok: true, resumed: false })
|
||||||
|
expect(out.ok && out.board.kitId).toBe('KIT-07')
|
||||||
|
expect(r.isClaimed('KIT-07')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('claimByCode resumes an already-claimed board to its canonical team', () => {
|
||||||
|
const r = createBoardRegistry()
|
||||||
|
r.announce(board())
|
||||||
|
r.claimByCode('418302', 'team-07', 0)
|
||||||
|
const again = r.claimByCode('418302', 'team-99', 1)
|
||||||
|
expect(again).toMatchObject({ ok: true, resumed: true })
|
||||||
|
expect(again.ok && again.board.claimedBy).toBe('team-07')
|
||||||
|
})
|
||||||
|
|
||||||
it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => {
|
it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => {
|
||||||
const r = createBoardRegistry()
|
const r = createBoardRegistry()
|
||||||
r.announce(board())
|
r.announce(board())
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export interface BoardRegistry {
|
|||||||
* lost its browser can get back onto its own board. Rate-limited per kit.
|
* lost its browser can get back onto its own board. Rate-limited per kit.
|
||||||
*/
|
*/
|
||||||
claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome
|
claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome
|
||||||
|
/**
|
||||||
|
* Claim (or resume) a board by its code ALONE — the attendee proves possession
|
||||||
|
* by reading the code the board scrolls on its own matrix, with no kit to pick.
|
||||||
|
* Finds the unique board whose `claimCode` matches; otherwise `bad_code`.
|
||||||
|
*/
|
||||||
|
claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome
|
||||||
/** Release a kit back to unclaimed; returns the freed teamId (or null). */
|
/** Release a kit back to unclaimed; returns the freed teamId (or null). */
|
||||||
release(kitId: string): string | null
|
release(kitId: string): string | null
|
||||||
get(kitId: string): Board | undefined
|
get(kitId: string): Board | undefined
|
||||||
@@ -90,6 +96,18 @@ export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
|
|||||||
}
|
}
|
||||||
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
|
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
|
||||||
},
|
},
|
||||||
|
claimByCode(code, teamId, nowMs) {
|
||||||
|
// Unique per-board codes → the code identifies the board. No match = bad code.
|
||||||
|
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
|
||||||
|
if (!board) return { ok: false, reason: 'bad_code' }
|
||||||
|
if (recentFails(board.kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
||||||
|
fails.delete(board.kitId)
|
||||||
|
if (board.claimedBy === null) {
|
||||||
|
board.claimedBy = teamId
|
||||||
|
return { ok: true, board, resumed: false }
|
||||||
|
}
|
||||||
|
return { ok: true, board, resumed: true }
|
||||||
|
},
|
||||||
release(kitId) {
|
release(kitId) {
|
||||||
const board = boards.get(kitId)
|
const board = boards.get(kitId)
|
||||||
if (!board || board.claimedBy === null) return null
|
if (!board || board.claimedBy === null) return null
|
||||||
|
|||||||
@@ -5,28 +5,31 @@ import { claimBoard, ClaimError, type ClaimResult } from '@/lib/api'
|
|||||||
|
|
||||||
export interface BoardClaimProps {
|
export interface BoardClaimProps {
|
||||||
teamId: string
|
teamId: string
|
||||||
kit: string
|
|
||||||
teamName: string
|
teamName: string
|
||||||
|
members: string[]
|
||||||
connected: boolean
|
connected: boolean
|
||||||
port: string | null
|
port: string | null
|
||||||
onClaimed: (result: ClaimResult) => void
|
onClaimed: (result: ClaimResult) => void
|
||||||
/** Pre-fill the code (from the kit QR's ?code= param). */
|
/** Pre-fill the code (e.g. from a ?code= param). */
|
||||||
initialCode?: string
|
initialCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The three physical bring-up steps an attendee performs before claiming. */
|
/** The self-service bring-up steps — the board is already set up from the week. */
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
'Plug your Uno Q into power over USB-C — the 13×8 matrix lights up.',
|
'On your board, open a terminal and run the workshop setup script (below).',
|
||||||
'Wait ~30 s for it to boot and join the workshop network.',
|
'It checks your node is ready, registers it, and scrolls a code across the LED matrix.',
|
||||||
'Enter the 6-digit claim code printed on your kit sticker.',
|
'Type the code your board is showing to bind it to your team.',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const SETUP_CMD = 'curl -fsSL https://apess.redclaw.dev/setup.sh | bash'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The board bring-up wizard: walks the attendee through powering on their Uno Q
|
* The board bring-up wizard for pre-deployed devices: the attendee runs the
|
||||||
* and claims it to their team by proving the kit's claim code. On success the
|
* setup script on their own Uno Q, which self-registers the node and shows a
|
||||||
* board is bound server-side (its bearer token never touches the browser).
|
* code on its matrix; entering that code binds the board to the team. The
|
||||||
|
* bearer token never touches the browser.
|
||||||
*/
|
*/
|
||||||
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed, initialCode }: BoardClaimProps) {
|
export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, initialCode }: BoardClaimProps) {
|
||||||
const [code, setCode] = useState(initialCode ?? '')
|
const [code, setCode] = useState(initialCode ?? '')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -49,7 +52,7 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await claimBoard({ teamId, kit, teamName, code: trimmed })
|
const result = await claimBoard({ teamId, teamName, members, code: trimmed })
|
||||||
onClaimed(result)
|
onClaimed(result)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
|
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
|
||||||
@@ -68,18 +71,29 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||||
|
<code className="font-mono text-[11px] text-foreground/90 select-all flex-1 truncate">{SETUP_CMD}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Copy setup command"
|
||||||
|
onClick={() => navigator.clipboard?.writeText(SETUP_CMD)}
|
||||||
|
className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
aria-label="Claim code"
|
aria-label="Claim code"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="418302"
|
placeholder="code on your matrix"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && claim()}
|
onKeyDown={(e) => e.key === 'Enter' && claim()}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
<Button onClick={claim} disabled={busy || !code.trim()}>
|
<Button onClick={claim} disabled={busy || !code.trim()}>
|
||||||
{busy ? 'Claiming…' : 'Claim board'}
|
{busy ? 'Binding…' : 'Bind board'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
+3
-1
@@ -85,8 +85,10 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
|
|||||||
// --- board onboarding (participant) ---------------------------------------
|
// --- board onboarding (participant) ---------------------------------------
|
||||||
export interface ClaimInput {
|
export interface ClaimInput {
|
||||||
teamId: string
|
teamId: string
|
||||||
kit: string
|
/** Optional legacy sticker path; omit for code-first (board shows its code). */
|
||||||
|
kit?: string
|
||||||
teamName?: string
|
teamName?: string
|
||||||
|
members?: string[]
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
export interface ClaimResult {
|
export interface ClaimResult {
|
||||||
|
|||||||
@@ -36,13 +36,6 @@ describe('TeamRegistration', () => {
|
|||||||
expect(useSession.getState().team.name).toBe('team_resonance')
|
expect(useSession.getState().team.name).toBe('team_resonance')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('persists the selected kit to the session store on click', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
renderPage()
|
|
||||||
await user.click(screen.getByRole('button', { name: 'KIT-04' }))
|
|
||||||
expect(useSession.getState().team.kit).toBe('KIT-04')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('gates the Proceed button until name + member + board are ready', async () => {
|
it('gates the Proceed button until name + member + board are ready', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
@@ -61,35 +54,37 @@ describe('TeamRegistration', () => {
|
|||||||
expect(proceed).toBeEnabled()
|
expect(proceed).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('claims a board with the kit code and marks it connected', async () => {
|
it('binds a board by the matrix code and marks it connected', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ teamId: 't', kit: 'KIT-01', online: true }),
|
json: async () => ({ teamId: 't', kit: 'crimson-otter', online: true }),
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/claim code/i), '418302')
|
await user.type(screen.getByLabelText(/claim code/i), '4821')
|
||||||
await user.click(screen.getByRole('button', { name: /claim board/i }))
|
await user.click(screen.getByRole('button', { name: /bind board/i }))
|
||||||
|
|
||||||
expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
|
expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
|
||||||
expect(useSession.getState().device.connected).toBe(true)
|
expect(useSession.getState().device.connected).toBe(true)
|
||||||
// the claim went out with the typed code + selected kit
|
// code-first: the claim carries the code, no kit
|
||||||
const [, init] = fetchMock.mock.calls[0]
|
const [, init] = fetchMock.mock.calls[0]
|
||||||
expect(JSON.parse(init.body)).toMatchObject({ kit: 'KIT-01', code: '418302' })
|
const body = JSON.parse(init.body)
|
||||||
|
expect(body).toMatchObject({ code: '4821' })
|
||||||
|
expect(body.kit).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('surfaces a wrong-code error from the server', async () => {
|
it('surfaces a wrong-code error from the server', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong claim code' }) }),
|
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong code — check your matrix' }) }),
|
||||||
)
|
)
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
await user.type(screen.getByLabelText(/claim code/i), '000000')
|
await user.type(screen.getByLabelText(/claim code/i), '0000')
|
||||||
await user.click(screen.getByRole('button', { name: /claim board/i }))
|
await user.click(screen.getByRole('button', { name: /bind board/i }))
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong claim code/i)
|
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong code/i)
|
||||||
expect(useSession.getState().device.connected).toBe(false)
|
expect(useSession.getState().device.connected).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,22 +98,12 @@ describe('TeamRegistration', () => {
|
|||||||
expect(useSession.getState().phases.reg).toBe(true)
|
expect(useSession.getState().phases.reg).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-selects kit from the ?kit= URL param (QR sticker flow)', () => {
|
it('pre-fills the claim code from the ?code= URL param', () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={['/workshop?kit=KIT-12']}>
|
<MemoryRouter initialEntries={['/workshop?code=4821']}>
|
||||||
<TeamRegistration />
|
<TeamRegistration />
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
)
|
)
|
||||||
expect(useSession.getState().team.kit).toBe('KIT-12')
|
expect(screen.getByLabelText(/claim code/i)).toHaveValue('4821')
|
||||||
expect(screen.getByRole('button', { name: 'KIT-12' })).toHaveAttribute('aria-pressed', 'true')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('pre-fills the claim code from the ?code= URL param (full QR flow)', () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/workshop?kit=KIT-07&code=418302']}>
|
|
||||||
<TeamRegistration />
|
|
||||||
</MemoryRouter>,
|
|
||||||
)
|
|
||||||
expect(screen.getByLabelText(/claim code/i)).toHaveValue('418302')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { MemberFields } from '@/components/MemberFields'
|
import { MemberFields } from '@/components/MemberFields'
|
||||||
import { KitSelector } from '@/components/KitSelector'
|
|
||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
import { PhaseStrip } from '@/components/PhaseStrip'
|
||||||
import { BoardClaim } from '@/components/BoardClaim'
|
import { BoardClaim } from '@/components/BoardClaim'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
@@ -21,11 +19,6 @@ export function TeamRegistration() {
|
|||||||
const resumeTeam = useSession((s) => s.resumeTeam)
|
const resumeTeam = useSession((s) => s.resumeTeam)
|
||||||
const completePhase = useSession((s) => s.completePhase)
|
const completePhase = useSession((s) => s.completePhase)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const k = params.get('kit')
|
|
||||||
if (k && /^KIT-\d{2}$/.test(k)) setTeam({ kit: k })
|
|
||||||
}, [params, setTeam])
|
|
||||||
|
|
||||||
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
|
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
|
||||||
|
|
||||||
const onProceed = () => {
|
const onProceed = () => {
|
||||||
@@ -55,8 +48,8 @@ export function TeamRegistration() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
|
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
|
||||||
Name your team, add 3–5 members, pick up your kit, and claim your board.
|
Name your team, add 3–5 members, then bind the board you already set up this
|
||||||
The QR sticker on your kit pre-selects the kit number for you.
|
week — run the setup script and enter the code it scrolls on its LED matrix.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,26 +86,20 @@ export function TeamRegistration() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Kit & device</CardTitle>
|
<CardTitle className="text-base">Your board</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
Select kit
|
Bind your node
|
||||||
</div>
|
|
||||||
<KitSelector value={team.kit} onChange={(kit) => setTeam({ kit })} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
|
||||||
Board
|
|
||||||
</div>
|
</div>
|
||||||
<BoardClaim
|
<BoardClaim
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
kit={team.kit}
|
|
||||||
teamName={team.name}
|
teamName={team.name}
|
||||||
|
members={team.members}
|
||||||
connected={device.connected}
|
connected={device.connected}
|
||||||
port={device.port}
|
port={device.port}
|
||||||
initialCode={/^\d{6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||||
onClaimed={(r) => {
|
onClaimed={(r) => {
|
||||||
// Resume (a lost-browser re-claim): adopt the board's canonical
|
// Resume (a lost-browser re-claim): adopt the board's canonical
|
||||||
// team + restore its progress instead of keeping this fresh id.
|
// team + restore its progress instead of keeping this fresh id.
|
||||||
|
|||||||
Reference in New Issue
Block a user