feat(web): board claim wizard replaces Web-Serial connect (onboarding slice 2)
Attendee-facing half of the "preloaded + self-register + claim" flow. - BoardClaim wizard: three physical bring-up steps (power → boot → enter code) + a claim-code field that calls POST /claim. On success the board shows as claimed; ClaimError surfaces the server's message (wrong code / not powered on / rate-limited). - TeamRegistration: swaps the Web-Serial "connect device · 115200 baud" step for the wizard, with a "use the simulator instead" escape hatch (sim needs no board). serial.ts stays — Module1/2 still use it for the simulated sense path. - EnvSetup: live self-test now polls GET /nodes/:teamId/status (board liveness) instead of reading Web-Serial frames; sim path unchanged. - api client: claimBoard() + ClaimError + getNodeStatus(). - api: GET /nodes/:teamId/status (public per-team liveness). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
14e08b3623
commit
0442f7e865
@@ -200,6 +200,16 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
res.status(202).json({ accepted: true })
|
res.status(202).json({ accepted: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Public liveness for a team's board — the wizard/self-test polls this after
|
||||||
|
// a claim. Online reflects the bridge's live /health + SSE view.
|
||||||
|
app.get('/nodes/:teamId/status', (req, res) => {
|
||||||
|
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
|
||||||
|
const teamId = String(req.params.teamId)
|
||||||
|
const view = nodes.list().find((n) => n.teamId === teamId)
|
||||||
|
if (!view) return res.status(404).json({ error: 'no node registered for team' })
|
||||||
|
res.json({ teamId, online: view.online })
|
||||||
|
})
|
||||||
|
|
||||||
// Participant-scoped SSE: a team watches only its own board's activity
|
// Participant-scoped SSE: a team watches only its own board's activity
|
||||||
// (the /ws hub is admin/judge only). Public, keyed by teamId.
|
// (the /ws hub is admin/judge only). Public, keyed by teamId.
|
||||||
app.get('/nodes/:teamId/events', (req, res) => {
|
app.get('/nodes/:teamId/events', (req, res) => {
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ describe('board self-register + claim', () => {
|
|||||||
expect(events.some((e) => e.type === 'team:update' && e.team.id === 'team-07')).toBe(true)
|
expect(events.some((e) => e.type === 'team:update' && e.team.id === 'team-07')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
||||||
|
const res = await request(app).get('/nodes/team-07/status').expect(200)
|
||||||
|
expect(res.body).toEqual({ teamId: 'team-07', online: true })
|
||||||
|
})
|
||||||
|
|
||||||
it('is single-use — a second claim of the same kit 404s', async () => {
|
it('is single-use — a second claim of the same kit 404s', async () => {
|
||||||
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)
|
||||||
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-08', code: '418302' }).expect(404)
|
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-08', code: '418302' }).expect(404)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { claimBoard, ClaimError, type ClaimResult } from '@/lib/api'
|
||||||
|
|
||||||
|
export interface BoardClaimProps {
|
||||||
|
teamId: string
|
||||||
|
kit: string
|
||||||
|
teamName: string
|
||||||
|
connected: boolean
|
||||||
|
port: string | null
|
||||||
|
onClaimed: (result: ClaimResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three physical bring-up steps an attendee performs before claiming. */
|
||||||
|
const STEPS = [
|
||||||
|
'Plug your Uno Q into power over USB-C — the 13×8 matrix lights up.',
|
||||||
|
'Wait ~30 s for it to boot and join the workshop network.',
|
||||||
|
'Enter the 6-digit claim code printed on your kit sticker.',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board bring-up wizard: walks the attendee through powering on their Uno Q
|
||||||
|
* and claims it to their team by proving the kit's claim code. On success the
|
||||||
|
* board is bound server-side (its bearer token never touches the browser).
|
||||||
|
*/
|
||||||
|
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed }: BoardClaimProps) {
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
return (
|
||||||
|
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3" data-testid="board-connected">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" />
|
||||||
|
<span className="text-sm font-medium">Board claimed</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground mt-1">{port}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = async () => {
|
||||||
|
const trimmed = code.trim()
|
||||||
|
if (!trimmed || busy) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await claimBoard({ teamId, kit, teamName, code: trimmed })
|
||||||
|
onClaimed(result)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{STEPS.map((s, i) => (
|
||||||
|
<li key={i} className="flex gap-2.5 text-xs text-muted-foreground leading-relaxed">
|
||||||
|
<span className="font-mono text-[10px] font-bold text-primary w-4 shrink-0">{i + 1}</span>
|
||||||
|
<span>{s}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label="Claim code"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="418302"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && claim()}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button onClick={claim} disabled={busy || !code.trim()}>
|
||||||
|
{busy ? 'Claiming…' : 'Claim board'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-red-500 leading-relaxed">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -69,6 +69,53 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
|
|||||||
return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard')
|
return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- board onboarding (participant) ---------------------------------------
|
||||||
|
export interface ClaimInput {
|
||||||
|
teamId: string
|
||||||
|
kit: string
|
||||||
|
teamName?: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
export interface ClaimResult {
|
||||||
|
teamId: string
|
||||||
|
kit: string
|
||||||
|
online: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failed claim, carrying the HTTP status + the server's human message. */
|
||||||
|
export class ClaimError extends Error {
|
||||||
|
status: number
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ClaimError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim a powered-on board to this team by proving the kit's claim code. The
|
||||||
|
* bearer token stays server-side; success binds the board and brings it online.
|
||||||
|
*/
|
||||||
|
export async function claimBoard(input: ClaimInput): Promise<ClaimResult> {
|
||||||
|
const res = await fetch(`${API_BASE}/claim`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||||
|
throw new ClaimError(res.status, body.error ?? `claim ${res.status}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as ClaimResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll a team's board liveness (used by the setup self-test). */
|
||||||
|
export async function getNodeStatus(teamId: string): Promise<{ teamId: string; online: boolean }> {
|
||||||
|
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/status`)
|
||||||
|
if (!res.ok) throw new Error(`getNodeStatus ${res.status}`)
|
||||||
|
return (await res.json()) as { teamId: string; online: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
// --- ZeroClaw node (participant) ------------------------------------------
|
// --- ZeroClaw node (participant) ------------------------------------------
|
||||||
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
|
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
|
||||||
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
|
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import userEvent from '@testing-library/user-event'
|
|||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { EnvSetup } from './EnvSetup'
|
import { EnvSetup } from './EnvSetup'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
import { getNodeStatus } from '@/lib/api'
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', async (orig) => ({
|
||||||
|
...(await orig<typeof import('@/lib/api')>()),
|
||||||
|
getNodeStatus: vi.fn(),
|
||||||
|
}))
|
||||||
|
const mockNodeStatus = vi.mocked(getNodeStatus)
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
@@ -44,9 +51,10 @@ describe('EnvSetup', () => {
|
|||||||
beforeEach(() => vi.useFakeTimers())
|
beforeEach(() => vi.useFakeTimers())
|
||||||
afterEach(() => vi.useRealTimers())
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
it('passes once frames flow, enabling Proceed without polluting stats', async () => {
|
it('passes once the board reports online, enabling Proceed without polluting stats', async () => {
|
||||||
|
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true })
|
||||||
useSession.getState().setMode('live')
|
useSession.getState().setMode('live')
|
||||||
useSession.getState().setDevice({ connected: true, port: 'mock-serial://uno-q', uptimeS: 0 })
|
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 })
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
const proceed = screen.getByRole('button', { name: /proceed/i })
|
const proceed = screen.getByRole('button', { name: /proceed/i })
|
||||||
|
|||||||
+17
-10
@@ -6,15 +6,18 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
import { PhaseStrip } from '@/components/PhaseStrip'
|
||||||
import { HarnessProviderSelect } from '@/components/HarnessProviderSelect'
|
import { HarnessProviderSelect } from '@/components/HarnessProviderSelect'
|
||||||
import { useSession, type RunMode } from '@/store/session'
|
import { useSession, type RunMode } from '@/store/session'
|
||||||
import { requestPort } from '@/lib/serial'
|
import { getNodeStatus } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type SelfTest = 'idle' | 'running' | 'ok'
|
type SelfTest = 'idle' | 'running' | 'ok'
|
||||||
|
|
||||||
const SELFTEST_FRAMES = 3
|
/** Live self-test: poll the claimed board's liveness this many times. */
|
||||||
|
const SELFTEST_POLLS = 12
|
||||||
|
const SELFTEST_POLL_MS = 500
|
||||||
|
|
||||||
export function EnvSetup() {
|
export function EnvSetup() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const teamId = useSession((s) => s.teamId)
|
||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const mode = useSession((s) => s.mode)
|
const mode = useSession((s) => s.mode)
|
||||||
const setMode = useSession((s) => s.setMode)
|
const setMode = useSession((s) => s.setMode)
|
||||||
@@ -38,16 +41,20 @@ export function EnvSetup() {
|
|||||||
setSelfTest('ok')
|
setSelfTest('ok')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const conn = await requestPort()
|
// Live: confirm the claimed board is reachable and online.
|
||||||
let count = 0
|
for (let i = 0; i < SELFTEST_POLLS; i++) {
|
||||||
const unsub = conn.onFrame(() => {
|
try {
|
||||||
count += 1
|
const s = await getNodeStatus(teamId)
|
||||||
if (count >= SELFTEST_FRAMES) {
|
if (s.online) {
|
||||||
unsub()
|
|
||||||
void conn.close()
|
|
||||||
setSelfTest('ok')
|
setSelfTest('ok')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
} catch {
|
||||||
|
/* board not registered yet / transient — keep polling */
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, SELFTEST_POLL_MS))
|
||||||
|
}
|
||||||
|
setSelfTest('idle') // couldn't confirm — let them retry
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceReady = mode === 'sim' || device.connected
|
const deviceReady = mode === 'sim' || device.connected
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
@@ -18,6 +18,9 @@ describe('TeamRegistration', () => {
|
|||||||
useSession.getState().reset()
|
useSession.getState().reset()
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
})
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the phase strip and the team form heading', () => {
|
it('renders the phase strip and the team form heading', () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
@@ -40,7 +43,7 @@ describe('TeamRegistration', () => {
|
|||||||
expect(useSession.getState().team.kit).toBe('KIT-04')
|
expect(useSession.getState().team.kit).toBe('KIT-04')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gates the Proceed button until name + member + device 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()
|
||||||
const proceed = screen.getByRole('button', { name: /proceed/i })
|
const proceed = screen.getByRole('button', { name: /proceed/i })
|
||||||
@@ -53,16 +56,49 @@ describe('TeamRegistration', () => {
|
|||||||
await user.type(memberInput, 'A. Rossi{Enter}')
|
await user.type(memberInput, 'A. Rossi{Enter}')
|
||||||
expect(proceed).toBeDisabled()
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /connect device/i }))
|
// the simulator escape hatch satisfies the board requirement without hardware
|
||||||
|
await user.click(screen.getByRole('button', { name: /use the simulator/i }))
|
||||||
expect(proceed).toBeEnabled()
|
expect(proceed).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('claims a board with the kit code and marks it connected', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ teamId: 't', kit: 'KIT-01', online: true }),
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/claim code/i), '418302')
|
||||||
|
await user.click(screen.getByRole('button', { name: /claim board/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
|
||||||
|
expect(useSession.getState().device.connected).toBe(true)
|
||||||
|
// the claim went out with the typed code + selected kit
|
||||||
|
const [, init] = fetchMock.mock.calls[0]
|
||||||
|
expect(JSON.parse(init.body)).toMatchObject({ kit: 'KIT-01', code: '418302' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a wrong-code error from the server', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong claim code' }) }),
|
||||||
|
)
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
await user.type(screen.getByLabelText(/claim code/i), '000000')
|
||||||
|
await user.click(screen.getByRole('button', { name: /claim board/i }))
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong claim code/i)
|
||||||
|
expect(useSession.getState().device.connected).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('marks the reg phase complete when Proceed is clicked', async () => {
|
it('marks the reg phase complete when Proceed is clicked', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
await user.type(screen.getByLabelText(/team name/i), 'team_x')
|
await user.type(screen.getByLabelText(/team name/i), 'team_x')
|
||||||
await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}')
|
await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}')
|
||||||
await user.click(screen.getByRole('button', { name: /connect device/i }))
|
await user.click(screen.getByRole('button', { name: /use the simulator/i }))
|
||||||
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
||||||
expect(useSession.getState().phases.reg).toBe(true)
|
expect(useSession.getState().phases.reg).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { MemberChips } from '@/components/MemberChips'
|
import { MemberChips } from '@/components/MemberChips'
|
||||||
import { KitSelector } from '@/components/KitSelector'
|
import { KitSelector } from '@/components/KitSelector'
|
||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
import { PhaseStrip } from '@/components/PhaseStrip'
|
||||||
|
import { BoardClaim } from '@/components/BoardClaim'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
import { requestPort, serialSupported } from '@/lib/serial'
|
|
||||||
|
|
||||||
export function TeamRegistration() {
|
export function TeamRegistration() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [params] = useSearchParams()
|
const [params] = useSearchParams()
|
||||||
|
const teamId = useSession((s) => s.teamId)
|
||||||
const team = useSession((s) => s.team)
|
const team = useSession((s) => s.team)
|
||||||
const device = useSession((s) => s.device)
|
const device = useSession((s) => s.device)
|
||||||
const setTeam = useSession((s) => s.setTeam)
|
const setTeam = useSession((s) => s.setTeam)
|
||||||
@@ -31,16 +32,7 @@ export function TeamRegistration() {
|
|||||||
navigate('/workshop/setup')
|
navigate('/workshop/setup')
|
||||||
}
|
}
|
||||||
|
|
||||||
const onConnect = async () => {
|
const useSimulator = () => setDevice({ connected: true, port: 'simulator · no board', uptimeS: 0 })
|
||||||
// Without Web Serial (tests, unsupported browsers) connect synchronously to
|
|
||||||
// the mock so the flow stays usable; otherwise prompt for the real board.
|
|
||||||
if (!serialSupported()) {
|
|
||||||
setDevice({ connected: true, port: 'mock-serial://uno-q', uptimeS: 0 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const conn = await requestPort()
|
|
||||||
setDevice({ connected: true, port: conn.port, uptimeS: 0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<main className="min-h-screen bg-background">
|
||||||
@@ -64,7 +56,7 @@ 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 connect the board over USB.
|
Name your team, add 3–5 members, pick up your kit, and claim your board.
|
||||||
The QR sticker on your kit pre-selects the kit number for you.
|
The QR sticker on your kit pre-selects the kit number for you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,26 +105,25 @@ export function TeamRegistration() {
|
|||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
Device
|
Board
|
||||||
</div>
|
</div>
|
||||||
{device.connected ? (
|
<BoardClaim
|
||||||
<div className="border border-teal/40 bg-teal/5 rounded-md px-4 py-3">
|
teamId={teamId}
|
||||||
<div className="flex items-center gap-2">
|
kit={team.kit}
|
||||||
<span className="w-2 h-2 rounded-full bg-teal animate-pulse" />
|
teamName={team.name}
|
||||||
<span className="text-sm font-medium">Connected</span>
|
connected={device.connected}
|
||||||
</div>
|
port={device.port}
|
||||||
<div className="font-mono text-[10px] text-muted-foreground mt-1">{device.port}</div>
|
onClaimed={(r) => setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0 })}
|
||||||
</div>
|
/>
|
||||||
) : (
|
{!device.connected && (
|
||||||
<Button onClick={onConnect} variant="outline" className="w-full justify-start">
|
<button
|
||||||
<span className="w-2 h-2 rounded-full bg-muted-foreground mr-3" />
|
type="button"
|
||||||
Connect device · 115200 baud
|
onClick={useSimulator}
|
||||||
</Button>
|
className="font-mono text-[10px] text-muted-foreground hover:text-foreground underline"
|
||||||
|
>
|
||||||
|
No board yet? Use the simulator instead
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
|
|
||||||
Uses the browser's Web Serial API to talk to the board over USB. Falls back to a
|
|
||||||
simulated device on browsers without Web Serial support.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user