@startsimpli/ui 0.4.13 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +457 -398
  2. package/package.json +20 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/calendar-view.test.tsx +97 -0
  5. package/src/components/__tests__/chat.test.tsx +129 -0
  6. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  7. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  8. package/src/components/__tests__/upcoming-meetings.test.tsx +104 -0
  9. package/src/components/__tests__/workspace.test.tsx +106 -0
  10. package/src/components/account/__tests__/account.test.tsx +5 -32
  11. package/src/components/account/change-password-form.tsx +1 -28
  12. package/src/components/calendar/calendar-view.tsx +253 -0
  13. package/src/components/calendar/index.ts +20 -0
  14. package/src/components/calendar/meetings-list.tsx +202 -0
  15. package/src/components/calendar/upcoming-meetings.tsx +211 -0
  16. package/src/components/chat/ChatComposer.tsx +113 -0
  17. package/src/components/chat/ChatMessage.tsx +81 -0
  18. package/src/components/chat/ChatThread.tsx +57 -0
  19. package/src/components/chat/index.ts +12 -0
  20. package/src/components/chat/types.ts +20 -0
  21. package/src/components/index.ts +16 -0
  22. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  23. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  24. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  25. package/src/components/slide-deck/index.ts +7 -0
  26. package/src/components/slide-deck/types.ts +18 -0
  27. package/src/components/team/DomainClaimCard.tsx +170 -0
  28. package/src/components/team/InviteMemberDialog.tsx +182 -0
  29. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  30. package/src/components/team/MembersTable.tsx +138 -0
  31. package/src/components/team/OrgSwitcher.tsx +68 -0
  32. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  33. package/src/components/team/RoleSelector.tsx +68 -0
  34. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  35. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  36. package/src/components/team/index.ts +57 -0
  37. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  38. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  39. package/src/components/team/members-table-default-class-names.ts +39 -0
  40. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  41. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  42. package/src/components/team/role-selector-default-class-names.ts +11 -0
  43. package/src/components/team/types.ts +97 -0
  44. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  45. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  46. package/src/components/workflows/NodeInspector.tsx +257 -0
  47. package/src/components/workflows/NodePalette.tsx +119 -0
  48. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  49. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  50. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  51. package/src/components/workflows/WorkflowNode.tsx +198 -0
  52. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  53. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  54. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  55. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  56. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  57. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  58. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  59. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  60. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  61. package/src/components/workflows/exec-status.ts +90 -0
  62. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  63. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  64. package/src/components/workflows/index.ts +78 -0
  65. package/src/components/workflows/layout/auto-layout.ts +142 -0
  66. package/src/components/workflows/node-icons.ts +31 -0
  67. package/src/components/workflows/serialization.ts +171 -0
  68. package/src/components/workflows/theme/categories.ts +96 -0
  69. package/src/components/workflows/types.ts +231 -0
  70. package/src/components/workflows/workflows.css +29 -0
  71. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  72. package/src/components/workspace/SplitPane.tsx +174 -0
  73. package/src/components/workspace/index.ts +4 -0
@@ -0,0 +1,20 @@
1
+ export type ChatRole = 'user' | 'assistant' | 'system' | 'tool'
2
+
3
+ export type ChatMessageStatus = 'streaming' | 'complete' | 'error'
4
+
5
+ export type ChatMessageKind = 'text' | 'tool' | 'status'
6
+
7
+ export interface ChatMessageData {
8
+ id: string
9
+ role: ChatRole
10
+ content: string
11
+ /** Display name for the author (defaults derived from role). */
12
+ author?: string
13
+ timestamp?: string | number | Date
14
+ /** `streaming` shows a live indicator; `error` styles the bubble as failed. */
15
+ status?: ChatMessageStatus
16
+ /** `tool`/`status` render as compact system rows rather than chat bubbles. */
17
+ kind?: ChatMessageKind
18
+ /** Name of the tool when kind === 'tool'. */
19
+ toolName?: string
20
+ }
@@ -53,6 +53,15 @@ export * from './unified-table/utils'
53
53
  // Export Navigation
54
54
  export * from './navigation/sidebar'
55
55
 
56
+ // Workspace layout primitives (SplitPane, DualPaneWorkspace) — generic two-pane shell
57
+ export * from './workspace'
58
+
59
+ // Chat surface primitives (ChatThread, ChatMessage, ChatComposer)
60
+ export * from './chat'
61
+
62
+ // Slide-deck viewer (Google-Slides-style canvas + filmstrip)
63
+ export * from './slide-deck'
64
+
56
65
  // HTML rendering with XSS protection
57
66
  export { SafeHtml } from './safe-html'
58
67
 
@@ -101,6 +110,10 @@ export * from './command-palette'
101
110
  // Settings components (SettingsLayout, SettingsNav, SettingsCard)
102
111
  export * from './settings'
103
112
 
113
+ // Team management (startsim-o7s) — MembersTable, InviteMemberDialog, RoleSelector,
114
+ // PendingInvitationCallout, DomainClaimCard, LeaveTeamDialog, OrgSwitcher.
115
+ export * from './team'
116
+
104
117
  // Kanban board layout
105
118
  export * from './kanban'
106
119
 
@@ -110,6 +123,9 @@ export * from './lists'
110
123
  // Pipeline components (StageTransitionModal)
111
124
  export * from './pipeline'
112
125
 
126
+ // Calendar (month/week/day event grid wrapping react-big-calendar)
127
+ export * from './calendar'
128
+
113
129
  // Activity components (timeline, quick log, log dialog)
114
130
  export { ActivityTimeline } from './ActivityTimeline'
115
131
  export type { ActivityTimelineProps, ActivityTimelineItem } from './ActivityTimeline'
@@ -0,0 +1,68 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ImageOff, Loader2, RefreshCw } from 'lucide-react'
5
+ import { cn } from '../../lib/utils'
6
+ import type { SlideData } from './types'
7
+
8
+ export interface SlideCanvasProps {
9
+ slide: SlideData
10
+ /** Retry/regenerate handler shown in the error state. */
11
+ onRetry?: () => void
12
+ /** Render structured slides (future); falls back to content text for now. */
13
+ renderStructured?: (slide: SlideData) => React.ReactNode
14
+ className?: string
15
+ }
16
+
17
+ /**
18
+ * Renders a single slide at a fixed 16:9 aspect ratio. Image render mode shows
19
+ * the generated full-bleed image with loading / error states; structured mode
20
+ * delegates to `renderStructured`. App-agnostic.
21
+ */
22
+ export function SlideCanvas({ slide, onRetry, renderStructured, className }: SlideCanvasProps) {
23
+ const status = slide.status ?? (slide.imageUrl ? 'ready' : 'pending')
24
+ const mode = slide.renderMode ?? 'image'
25
+
26
+ return (
27
+ <div
28
+ className={cn(
29
+ 'relative mx-auto aspect-video w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-white shadow-sm',
30
+ className
31
+ )}
32
+ data-status={status}
33
+ aria-busy={status === 'generating' || undefined}
34
+ >
35
+ {mode === 'structured' && renderStructured ? (
36
+ renderStructured(slide)
37
+ ) : status === 'ready' && slide.imageUrl ? (
38
+ // eslint-disable-next-line @next/next/no-img-element
39
+ <img
40
+ src={slide.imageUrl}
41
+ alt={slide.alt ?? slide.title ?? `Slide ${slide.slideNumber}`}
42
+ className="h-full w-full object-contain"
43
+ />
44
+ ) : status === 'error' ? (
45
+ <div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
46
+ <ImageOff className="h-8 w-8" aria-hidden="true" />
47
+ <p className="text-sm">This slide failed to generate.</p>
48
+ {onRetry && (
49
+ <button
50
+ type="button"
51
+ onClick={onRetry}
52
+ className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm hover:bg-accent"
53
+ >
54
+ <RefreshCw className="h-3.5 w-3.5" aria-hidden="true" /> Retry
55
+ </button>
56
+ )}
57
+ </div>
58
+ ) : (
59
+ <div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground">
60
+ <Loader2 className={cn('h-8 w-8', status === 'generating' && 'animate-spin')} aria-hidden="true" />
61
+ <p className="text-sm">
62
+ {status === 'generating' ? `Generating slide ${slide.slideNumber}…` : 'Waiting to generate…'}
63
+ </p>
64
+ </div>
65
+ )}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,144 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'
5
+ import { cn } from '../../lib/utils'
6
+ import { SlideCanvas } from './SlideCanvas'
7
+ import { SlideFilmstrip } from './SlideFilmstrip'
8
+ import type { SlideData } from './types'
9
+
10
+ export interface SlideDeckViewerProps {
11
+ slides: SlideData[]
12
+ /** Controlled active slide id. Omit for uncontrolled (defaults to first). */
13
+ activeId?: string
14
+ onActiveChange?: (id: string) => void
15
+ /** Regenerate the currently-active slide. */
16
+ onRegenerate?: (slide: SlideData) => void
17
+ emptyState?: React.ReactNode
18
+ /** Extra actions rendered in the stage toolbar (e.g. approve / edit). */
19
+ toolbarActions?: (slide: SlideData) => React.ReactNode
20
+ renderStructured?: (slide: SlideData) => React.ReactNode
21
+ className?: string
22
+ }
23
+
24
+ /**
25
+ * Google-Slides-style deck viewer: a large active-slide stage with prev/next +
26
+ * regenerate controls, a position indicator, and a thumbnail filmstrip.
27
+ * Keyboard arrows navigate. App-agnostic — `present-web` supplies the slides.
28
+ */
29
+ export function SlideDeckViewer({
30
+ slides,
31
+ activeId,
32
+ onActiveChange,
33
+ onRegenerate,
34
+ emptyState,
35
+ toolbarActions,
36
+ renderStructured,
37
+ className,
38
+ }: SlideDeckViewerProps) {
39
+ const isControlled = activeId !== undefined
40
+ const [internalId, setInternalId] = React.useState<string | undefined>(slides[0]?.id)
41
+
42
+ const currentId = isControlled ? activeId : internalId
43
+ const index = Math.max(0, slides.findIndex((s) => s.id === currentId))
44
+ const active = slides[index]
45
+
46
+ const goTo = React.useCallback(
47
+ (i: number) => {
48
+ const clamped = Math.min(slides.length - 1, Math.max(0, i))
49
+ const next = slides[clamped]
50
+ if (!next) return
51
+ if (!isControlled) setInternalId(next.id)
52
+ onActiveChange?.(next.id)
53
+ },
54
+ [slides, isControlled, onActiveChange]
55
+ )
56
+
57
+ const onKeyDown = (e: React.KeyboardEvent) => {
58
+ if (e.key === 'ArrowRight') {
59
+ e.preventDefault()
60
+ goTo(index + 1)
61
+ } else if (e.key === 'ArrowLeft') {
62
+ e.preventDefault()
63
+ goTo(index - 1)
64
+ }
65
+ }
66
+
67
+ if (slides.length === 0) {
68
+ return (
69
+ <div className={cn('flex h-full items-center justify-center p-6 text-center text-muted-foreground', className)}>
70
+ {emptyState ?? 'No slides yet.'}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ return (
76
+ <div className={cn('flex h-full min-h-0 flex-col bg-muted/20', className)}>
77
+ {/* Stage toolbar */}
78
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border bg-background/60 px-3 py-2">
79
+ <div className="flex items-center gap-1">
80
+ <button
81
+ type="button"
82
+ onClick={() => goTo(index - 1)}
83
+ disabled={index === 0}
84
+ aria-label="Previous slide"
85
+ className="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
86
+ >
87
+ <ChevronLeft className="h-4 w-4" aria-hidden="true" />
88
+ </button>
89
+ <span className="min-w-14 text-center text-sm font-medium tabular-nums">
90
+ {index + 1} / {slides.length}
91
+ </span>
92
+ <button
93
+ type="button"
94
+ onClick={() => goTo(index + 1)}
95
+ disabled={index === slides.length - 1}
96
+ aria-label="Next slide"
97
+ className="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30"
98
+ >
99
+ <ChevronRight className="h-4 w-4" aria-hidden="true" />
100
+ </button>
101
+ </div>
102
+ <div className="flex items-center gap-2">
103
+ {active && toolbarActions?.(active)}
104
+ {onRegenerate && active && (
105
+ <button
106
+ type="button"
107
+ onClick={() => onRegenerate(active)}
108
+ className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm shadow-sm hover:bg-accent"
109
+ >
110
+ <RefreshCw className="h-3.5 w-3.5" aria-hidden="true" /> Regenerate
111
+ </button>
112
+ )}
113
+ </div>
114
+ </div>
115
+
116
+ {/* Stage */}
117
+ <div
118
+ className="flex min-h-0 flex-1 items-center justify-center p-4 focus:outline-none"
119
+ data-testid="slide-deck-stage"
120
+ tabIndex={0}
121
+ role="group"
122
+ aria-label={`Slide ${index + 1} of ${slides.length}`}
123
+ onKeyDown={onKeyDown}
124
+ >
125
+ {active && (
126
+ <SlideCanvas
127
+ slide={active}
128
+ onRetry={onRegenerate ? () => onRegenerate(active) : undefined}
129
+ renderStructured={renderStructured}
130
+ />
131
+ )}
132
+ </div>
133
+
134
+ {/* Filmstrip */}
135
+ <div className="shrink-0 border-t border-border bg-background/60">
136
+ <SlideFilmstrip
137
+ slides={slides}
138
+ activeId={currentId}
139
+ onSelect={(id) => goTo(slides.findIndex((s) => s.id === id))}
140
+ />
141
+ </div>
142
+ </div>
143
+ )
144
+ }
@@ -0,0 +1,73 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Loader2 } from 'lucide-react'
5
+ import { cn } from '../../lib/utils'
6
+ import type { SlideData } from './types'
7
+
8
+ export interface SlideFilmstripProps {
9
+ slides: SlideData[]
10
+ activeId?: string
11
+ onSelect: (id: string) => void
12
+ /** `horizontal` (default) for a bottom strip, `vertical` for a side rail. */
13
+ orientation?: 'horizontal' | 'vertical'
14
+ className?: string
15
+ }
16
+
17
+ /**
18
+ * Scrollable strip of slide thumbnails. Highlights the active slide and emits
19
+ * its id on click. App-agnostic.
20
+ */
21
+ export function SlideFilmstrip({
22
+ slides,
23
+ activeId,
24
+ onSelect,
25
+ orientation = 'horizontal',
26
+ className,
27
+ }: SlideFilmstripProps) {
28
+ return (
29
+ <div
30
+ className={cn(
31
+ 'flex gap-2 overflow-auto p-2',
32
+ orientation === 'horizontal' ? 'flex-row' : 'flex-col',
33
+ className
34
+ )}
35
+ role="group"
36
+ aria-label="Slides"
37
+ >
38
+ {slides.map((slide) => {
39
+ const active = slide.id === activeId
40
+ const status = slide.status ?? (slide.imageUrl ? 'ready' : 'pending')
41
+ return (
42
+ <button
43
+ key={slide.id}
44
+ type="button"
45
+ aria-current={active ? 'true' : undefined}
46
+ aria-label={`Slide ${slide.slideNumber}${slide.title ? `: ${slide.title}` : ''}`}
47
+ onClick={() => onSelect(slide.id)}
48
+ className={cn(
49
+ 'group relative aspect-video w-28 shrink-0 overflow-hidden rounded-md border bg-white text-left transition-all',
50
+ active ? 'border-primary ring-2 ring-primary' : 'border-border hover:border-primary/50'
51
+ )}
52
+ >
53
+ <span className="absolute left-1 top-1 z-10 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
54
+ {slide.slideNumber}
55
+ </span>
56
+ {status === 'ready' && slide.imageUrl ? (
57
+ // eslint-disable-next-line @next/next/no-img-element
58
+ <img
59
+ src={slide.imageUrl}
60
+ alt={slide.title ?? `Slide ${slide.slideNumber}`}
61
+ className="h-full w-full object-cover"
62
+ />
63
+ ) : (
64
+ <div className="flex h-full w-full items-center justify-center bg-muted text-muted-foreground">
65
+ <Loader2 className={cn('h-4 w-4', status === 'generating' && 'animate-spin')} aria-hidden="true" />
66
+ </div>
67
+ )}
68
+ </button>
69
+ )
70
+ })}
71
+ </div>
72
+ )
73
+ }
@@ -0,0 +1,7 @@
1
+ export { SlideCanvas } from './SlideCanvas'
2
+ export type { SlideCanvasProps } from './SlideCanvas'
3
+ export { SlideFilmstrip } from './SlideFilmstrip'
4
+ export type { SlideFilmstripProps } from './SlideFilmstrip'
5
+ export { SlideDeckViewer } from './SlideDeckViewer'
6
+ export type { SlideDeckViewerProps } from './SlideDeckViewer'
7
+ export type { SlideData, SlideStatus, SlideRenderMode } from './types'
@@ -0,0 +1,18 @@
1
+ export type SlideStatus = 'pending' | 'generating' | 'ready' | 'error'
2
+
3
+ export type SlideRenderMode = 'image' | 'structured'
4
+
5
+ export interface SlideData {
6
+ id: string
7
+ slideNumber: number
8
+ title?: string
9
+ /** Raw text content / outline for this slide. */
10
+ content?: string
11
+ /** Full-bleed image URL (image render mode). */
12
+ imageUrl?: string
13
+ /** Alt text for the slide image. */
14
+ alt?: string
15
+ status?: SlideStatus
16
+ /** `image` (default) renders the generated image; `structured` renders blocks. */
17
+ renderMode?: SlideRenderMode
18
+ }
@@ -0,0 +1,170 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * DomainClaimCard — renders one EmailDomainClaim row + drives the two
5
+ * verification paths (DNS TXT + email attestation).
6
+ *
7
+ * The card never knows the API directly — apps supply onVerifyDns /
8
+ * onVerifyEmail / onRevoke callbacks that typically wrap
9
+ * api.domainClaims.*. The verificationToken (creator-only, single-show) is
10
+ * rendered when present, with copy-to-clipboard help text. startsim-o7s.
11
+ */
12
+
13
+ import * as React from 'react'
14
+ import type { DomainClaimLite } from './types'
15
+ import {
16
+ DOMAIN_CLAIM_DEFAULTS,
17
+ type DomainClaimCardClassNames,
18
+ } from './domain-claim-card-default-class-names'
19
+
20
+ export interface DomainClaimCardProps {
21
+ claim: DomainClaimLite
22
+ onVerifyDns?: (claim: DomainClaimLite) => Promise<void> | void
23
+ onInitiateEmail?: (claim: DomainClaimLite) => Promise<void> | void
24
+ onVerifyEmail?: (claim: DomainClaimLite, code: string) => Promise<void> | void
25
+ onRevoke?: (claim: DomainClaimLite) => Promise<void> | void
26
+ classNames?: DomainClaimCardClassNames
27
+ /** Override the DNS-record name hint shown to the user. */
28
+ dnsRecordName?: string
29
+ }
30
+
31
+ export function DomainClaimCard({
32
+ claim,
33
+ onVerifyDns,
34
+ onInitiateEmail,
35
+ onVerifyEmail,
36
+ onRevoke,
37
+ classNames,
38
+ dnsRecordName,
39
+ }: DomainClaimCardProps) {
40
+ const cls = { ...DOMAIN_CLAIM_DEFAULTS, ...(classNames ?? {}) }
41
+ const [busy, setBusy] = React.useState<'dns' | 'email-initiate' | 'email-code' | 'revoke' | null>(null)
42
+ const [error, setError] = React.useState('')
43
+ const [emailCode, setEmailCode] = React.useState('')
44
+ const [emailInitiated, setEmailInitiated] = React.useState(false)
45
+
46
+ function recordName() {
47
+ return dnsRecordName ?? `_startsim-verify.${claim.domain}`
48
+ }
49
+
50
+ async function run<T>(label: typeof busy, fn: () => Promise<T> | T) {
51
+ setError('')
52
+ setBusy(label)
53
+ try {
54
+ await fn()
55
+ } catch (err) {
56
+ setError(err instanceof Error ? err.message : 'Verification failed')
57
+ } finally {
58
+ setBusy(null)
59
+ }
60
+ }
61
+
62
+ return (
63
+ <article className={cls.root}>
64
+ <div className={cls.header}>
65
+ <span className={cls.domain}>{claim.domain}</span>
66
+ <span
67
+ className={[
68
+ cls.statusBadge,
69
+ claim.verified ? cls.statusVerified : cls.statusUnverified,
70
+ ].join(' ')}
71
+ >
72
+ {claim.verified ? 'Verified' : 'Pending verification'}
73
+ </span>
74
+ </div>
75
+
76
+ {!claim.verified && (
77
+ <div className={cls.body}>
78
+ {claim.verificationToken && (
79
+ <div>
80
+ <p className={cls.sectionLabel}>DNS TXT verification</p>
81
+ <p className={cls.helpText}>
82
+ Add a TXT record at{' '}
83
+ <code>{recordName()}</code> with this value, then click
84
+ &quot;Verify DNS&quot;.
85
+ </p>
86
+ <div className={cls.tokenRow}>
87
+ <code className={cls.tokenCode}>{claim.verificationToken}</code>
88
+ </div>
89
+ </div>
90
+ )}
91
+
92
+ <div>
93
+ <p className={cls.sectionLabel}>Email attestation</p>
94
+ <p className={cls.helpText}>
95
+ We&apos;ll email <code>postmaster@{claim.domain}</code> a 6-digit
96
+ code. Anyone at that mailbox can confirm the claim.
97
+ </p>
98
+ {emailInitiated && (
99
+ <div className={cls.tokenRow}>
100
+ <input
101
+ type="text"
102
+ inputMode="numeric"
103
+ maxLength={6}
104
+ value={emailCode}
105
+ onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, ''))}
106
+ placeholder="123456"
107
+ aria-label="Attestation code"
108
+ className={cls.codeInput}
109
+ />
110
+ <button
111
+ type="button"
112
+ className={cls.primaryButton}
113
+ disabled={busy !== null || emailCode.length < 6}
114
+ onClick={() =>
115
+ run('email-code', async () => {
116
+ await onVerifyEmail?.(claim, emailCode)
117
+ setEmailCode('')
118
+ })
119
+ }
120
+ >
121
+ {busy === 'email-code' ? 'Verifying…' : 'Submit code'}
122
+ </button>
123
+ </div>
124
+ )}
125
+ </div>
126
+
127
+ {error && <p className={cls.errorText} role="alert">{error}</p>}
128
+ </div>
129
+ )}
130
+
131
+ <div className={cls.actions}>
132
+ {!claim.verified && (
133
+ <>
134
+ <button
135
+ type="button"
136
+ className={cls.primaryButton}
137
+ disabled={busy !== null}
138
+ onClick={() => run('dns', () => onVerifyDns?.(claim))}
139
+ >
140
+ {busy === 'dns' ? 'Checking…' : 'Verify DNS'}
141
+ </button>
142
+ <button
143
+ type="button"
144
+ className={cls.secondaryButton}
145
+ disabled={busy !== null}
146
+ onClick={() =>
147
+ run('email-initiate', async () => {
148
+ await onInitiateEmail?.(claim)
149
+ setEmailInitiated(true)
150
+ })
151
+ }
152
+ >
153
+ {busy === 'email-initiate' ? 'Sending…' : 'Email attestation'}
154
+ </button>
155
+ </>
156
+ )}
157
+ {onRevoke && (
158
+ <button
159
+ type="button"
160
+ className={cls.revokeButton}
161
+ disabled={busy !== null}
162
+ onClick={() => run('revoke', () => onRevoke(claim))}
163
+ >
164
+ {busy === 'revoke' ? 'Revoking…' : 'Revoke'}
165
+ </button>
166
+ )}
167
+ </div>
168
+ </article>
169
+ )
170
+ }