@startsimpli/ui 0.4.14 → 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 (71) hide show
  1. package/README.md +457 -398
  2. package/package.json +18 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/chat.test.tsx +129 -0
  5. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  6. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  7. package/src/components/__tests__/workspace.test.tsx +106 -0
  8. package/src/components/account/__tests__/account.test.tsx +5 -32
  9. package/src/components/account/change-password-form.tsx +1 -28
  10. package/src/components/calendar/calendar-view.tsx +31 -0
  11. package/src/components/calendar/index.ts +7 -0
  12. package/src/components/calendar/meetings-list.tsx +202 -0
  13. package/src/components/calendar/upcoming-meetings.tsx +5 -5
  14. package/src/components/chat/ChatComposer.tsx +113 -0
  15. package/src/components/chat/ChatMessage.tsx +81 -0
  16. package/src/components/chat/ChatThread.tsx +57 -0
  17. package/src/components/chat/index.ts +12 -0
  18. package/src/components/chat/types.ts +20 -0
  19. package/src/components/index.ts +13 -0
  20. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  21. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  22. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  23. package/src/components/slide-deck/index.ts +7 -0
  24. package/src/components/slide-deck/types.ts +18 -0
  25. package/src/components/team/DomainClaimCard.tsx +170 -0
  26. package/src/components/team/InviteMemberDialog.tsx +182 -0
  27. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  28. package/src/components/team/MembersTable.tsx +138 -0
  29. package/src/components/team/OrgSwitcher.tsx +68 -0
  30. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  31. package/src/components/team/RoleSelector.tsx +68 -0
  32. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  33. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  34. package/src/components/team/index.ts +57 -0
  35. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  36. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  37. package/src/components/team/members-table-default-class-names.ts +39 -0
  38. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  39. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  40. package/src/components/team/role-selector-default-class-names.ts +11 -0
  41. package/src/components/team/types.ts +97 -0
  42. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  43. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  44. package/src/components/workflows/NodeInspector.tsx +257 -0
  45. package/src/components/workflows/NodePalette.tsx +119 -0
  46. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  47. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  48. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  49. package/src/components/workflows/WorkflowNode.tsx +198 -0
  50. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  51. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  52. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  53. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  54. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  55. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  56. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  57. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  58. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  59. package/src/components/workflows/exec-status.ts +90 -0
  60. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  61. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  62. package/src/components/workflows/index.ts +78 -0
  63. package/src/components/workflows/layout/auto-layout.ts +142 -0
  64. package/src/components/workflows/node-icons.ts +31 -0
  65. package/src/components/workflows/serialization.ts +171 -0
  66. package/src/components/workflows/theme/categories.ts +96 -0
  67. package/src/components/workflows/types.ts +231 -0
  68. package/src/components/workflows/workflows.css +29 -0
  69. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  70. package/src/components/workspace/SplitPane.tsx +174 -0
  71. package/src/components/workspace/index.ts +4 -0
@@ -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
+ }
@@ -0,0 +1,182 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * InviteMemberDialog — collects email + role, calls the supplied submit
5
+ * handler (defaults to a no-op so the caller MUST wire it; in practice the
6
+ * caller passes api.teamInvitations.create or useInvitations().bulkInvite).
7
+ *
8
+ * Self-contained modal — uses a fixed overlay, doesn't pull Radix Dialog,
9
+ * so the test harness can render the open state straightforwardly. startsim-o7s.
10
+ */
11
+
12
+ import * as React from 'react'
13
+ import { RoleSelector } from './RoleSelector'
14
+ import type { InvitationLite, TeamRole } from './types'
15
+ import {
16
+ INVITE_DIALOG_DEFAULTS,
17
+ type InviteMemberDialogClassNames,
18
+ } from './invite-member-dialog-default-class-names'
19
+
20
+ export interface InviteMemberDialogProps {
21
+ /** Team the invitation will be scoped to. */
22
+ teamId: string
23
+ /**
24
+ * Submit hook. When undefined the dialog won't send anything — useful for
25
+ * Storybook-style harnesses. Apps typically pass
26
+ * (input) => api.teamInvitations.create(input)
27
+ */
28
+ onSubmit?: (input: { email: string; teamId: string; role: TeamRole }) => Promise<InvitationLite | void>
29
+ /** Notification when an invitation has been created. */
30
+ onInvited?: (invitation: InvitationLite | void) => void
31
+ /** Trigger button label. */
32
+ triggerLabel?: string
33
+ /** Title in the modal header. */
34
+ title?: string
35
+ /** Default role pre-selected. */
36
+ defaultRole?: TeamRole
37
+ /** Available roles in the dropdown. Defaults to admin/member/viewer (no owner). */
38
+ availableRoles?: TeamRole[]
39
+ classNames?: InviteMemberDialogClassNames
40
+ /** External open-state control. When omitted the trigger button manages it. */
41
+ open?: boolean
42
+ onOpenChange?: (open: boolean) => void
43
+ }
44
+
45
+ export function InviteMemberDialog({
46
+ teamId,
47
+ onSubmit,
48
+ onInvited,
49
+ triggerLabel = 'Invite member',
50
+ title = 'Invite a teammate',
51
+ defaultRole = 'member',
52
+ availableRoles = ['admin', 'member', 'viewer'],
53
+ classNames,
54
+ open: controlledOpen,
55
+ onOpenChange,
56
+ }: InviteMemberDialogProps) {
57
+ const cls = { ...INVITE_DIALOG_DEFAULTS, ...(classNames ?? {}) }
58
+ const [internalOpen, setInternalOpen] = React.useState(false)
59
+ const open = controlledOpen ?? internalOpen
60
+ const setOpen = (next: boolean) => {
61
+ if (controlledOpen === undefined) setInternalOpen(next)
62
+ onOpenChange?.(next)
63
+ }
64
+
65
+ const [email, setEmail] = React.useState('')
66
+ const [role, setRole] = React.useState<TeamRole>(defaultRole)
67
+ const [error, setError] = React.useState('')
68
+ const [success, setSuccess] = React.useState('')
69
+ const [submitting, setSubmitting] = React.useState(false)
70
+
71
+ function reset() {
72
+ setEmail('')
73
+ setRole(defaultRole)
74
+ setError('')
75
+ setSuccess('')
76
+ setSubmitting(false)
77
+ }
78
+
79
+ function close() {
80
+ setOpen(false)
81
+ reset()
82
+ }
83
+
84
+ async function handleSubmit(e: React.FormEvent) {
85
+ e.preventDefault()
86
+ setError('')
87
+ setSuccess('')
88
+ if (!email.trim()) {
89
+ setError('Email is required')
90
+ return
91
+ }
92
+ setSubmitting(true)
93
+ try {
94
+ let invited: InvitationLite | void = undefined
95
+ if (onSubmit) {
96
+ invited = await onSubmit({ email: email.trim(), teamId, role })
97
+ }
98
+ setSuccess(`Invitation sent to ${email.trim()}`)
99
+ setEmail('')
100
+ onInvited?.(invited)
101
+ } catch (err) {
102
+ setError(err instanceof Error ? err.message : 'Could not send invitation')
103
+ } finally {
104
+ setSubmitting(false)
105
+ }
106
+ }
107
+
108
+ return (
109
+ <>
110
+ <button
111
+ type="button"
112
+ className={cls.trigger}
113
+ onClick={() => setOpen(true)}
114
+ aria-haspopup="dialog"
115
+ aria-expanded={open}
116
+ >
117
+ {triggerLabel}
118
+ </button>
119
+ {open && (
120
+ <div className={cls.overlay} role="presentation" onClick={close}>
121
+ <div
122
+ className={cls.panel}
123
+ role="dialog"
124
+ aria-modal="true"
125
+ aria-label={title}
126
+ onClick={(e) => e.stopPropagation()}
127
+ >
128
+ <div className={cls.header}>
129
+ <h2 className={cls.title}>{title}</h2>
130
+ </div>
131
+ <form onSubmit={handleSubmit}>
132
+ <div className={cls.body}>
133
+ <div className={cls.fieldRow}>
134
+ <label htmlFor="invite-email" className={cls.label}>Email</label>
135
+ <input
136
+ id="invite-email"
137
+ type="email"
138
+ value={email}
139
+ onChange={(e) => setEmail(e.target.value)}
140
+ required
141
+ autoComplete="email"
142
+ className={cls.input}
143
+ disabled={submitting}
144
+ />
145
+ </div>
146
+ <div className={cls.fieldRow}>
147
+ <label htmlFor="invite-role" className={cls.label}>Role</label>
148
+ <RoleSelector
149
+ value={role}
150
+ onChange={setRole}
151
+ availableRoles={availableRoles}
152
+ ariaLabel="Invite role"
153
+ disabled={submitting}
154
+ />
155
+ </div>
156
+ {error && <p className={cls.errorText} role="alert">{error}</p>}
157
+ {success && <p className={cls.successText}>{success}</p>}
158
+ </div>
159
+ <div className={cls.footer}>
160
+ <button
161
+ type="button"
162
+ className={cls.cancelButton}
163
+ onClick={close}
164
+ disabled={submitting}
165
+ >
166
+ Close
167
+ </button>
168
+ <button
169
+ type="submit"
170
+ className={cls.submitButton}
171
+ disabled={submitting}
172
+ >
173
+ {submitting ? 'Sending…' : 'Send invitation'}
174
+ </button>
175
+ </div>
176
+ </form>
177
+ </div>
178
+ </div>
179
+ )}
180
+ </>
181
+ )
182
+ }