@incodetech/web 2.0.0-alpha.1

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 (176) hide show
  1. package/dev/README.md +163 -0
  2. package/dev/getToken.ts +36 -0
  3. package/dev/headless.html +875 -0
  4. package/dev/index.html +366 -0
  5. package/dev/main-headless.tsx +1332 -0
  6. package/dev/main-orchestrated-flow.tsx +1158 -0
  7. package/dev/main-preact.tsx +323 -0
  8. package/dev/main-simplified.tsx +123 -0
  9. package/dev/main-web-component.tsx +256 -0
  10. package/dev/main.tsx +332 -0
  11. package/dev/manual.html +27 -0
  12. package/dev/orchestrated-flow.html +64 -0
  13. package/dev/simplified.html +64 -0
  14. package/dev/tiktok-logo.svg +7 -0
  15. package/package.json +85 -0
  16. package/src/defineCustomElement.tsx +30 -0
  17. package/src/email/email.test.tsx +368 -0
  18. package/src/email/email.tsx +255 -0
  19. package/src/email/emailInput.test.tsx +264 -0
  20. package/src/email/emailInput.tsx +85 -0
  21. package/src/email/styles.css +59 -0
  22. package/src/flow/flow.test.tsx +796 -0
  23. package/src/flow/flow.tsx +292 -0
  24. package/src/flow/flowCompleted.css +30 -0
  25. package/src/flow/flowCompleted.test.tsx +331 -0
  26. package/src/flow/flowCompleted.tsx +121 -0
  27. package/src/flow/flowInit.test.ts +264 -0
  28. package/src/flow/flowInit.ts +94 -0
  29. package/src/flow/flowStart.css +58 -0
  30. package/src/flow/flowStart.test.tsx +49 -0
  31. package/src/flow/flowStart.tsx +41 -0
  32. package/src/flow/incode-logo.svg +8 -0
  33. package/src/flow/index.ts +7 -0
  34. package/src/flow/preloadFlow.test.ts +421 -0
  35. package/src/flow/preloadFlow.ts +171 -0
  36. package/src/flow/styles.css +9 -0
  37. package/src/flow/unsupportedModule.css +21 -0
  38. package/src/flow/unsupportedModule.tsx +39 -0
  39. package/src/flow/useFlowInitialization.test.tsx +292 -0
  40. package/src/flow/useFlowInitialization.ts +128 -0
  41. package/src/flow/useModuleLoader.test.tsx +212 -0
  42. package/src/flow/useModuleLoader.ts +92 -0
  43. package/src/hooks/index.ts +1 -0
  44. package/src/hooks/useManager.test.ts +91 -0
  45. package/src/hooks/useManager.ts +40 -0
  46. package/src/i18n/index.ts +3 -0
  47. package/src/i18n/instance.ts +16 -0
  48. package/src/i18n/setup.ts +184 -0
  49. package/src/i18n/useTranslation.ts +42 -0
  50. package/src/index.ts +27 -0
  51. package/src/permissions/assets/android-dots-icon.svg +7 -0
  52. package/src/permissions/assets/android-settings-icon.svg +16 -0
  53. package/src/permissions/assets/android-toggle-icon.svg +20 -0
  54. package/src/permissions/assets/bank-card-icon.svg +14 -0
  55. package/src/permissions/assets/camera-icon.svg +12 -0
  56. package/src/permissions/assets/camera-ios.svg +53 -0
  57. package/src/permissions/assets/check-icon.svg +8 -0
  58. package/src/permissions/assets/chrome-icon.svg +43 -0
  59. package/src/permissions/assets/password-icon.svg +11 -0
  60. package/src/permissions/assets/permissions-img.svg +51 -0
  61. package/src/permissions/assets/safari-icon.svg +37 -0
  62. package/src/permissions/assets/settings-icon.svg +33 -0
  63. package/src/permissions/assets/toggle-icon.svg +19 -0
  64. package/src/permissions/assets/warning-icon.svg +6 -0
  65. package/src/permissions/boldWithArrow.css +9 -0
  66. package/src/permissions/boldWithArrow.tsx +41 -0
  67. package/src/permissions/denied.css +37 -0
  68. package/src/permissions/denied.tsx +29 -0
  69. package/src/permissions/deniedAndroid.tsx +56 -0
  70. package/src/permissions/deniedDesktop.css +9 -0
  71. package/src/permissions/deniedDesktop.tsx +64 -0
  72. package/src/permissions/deniedIOS.tsx +73 -0
  73. package/src/permissions/deniedInstructions.tsx +19 -0
  74. package/src/permissions/iconWrapper.css +9 -0
  75. package/src/permissions/iconWrapper.tsx +15 -0
  76. package/src/permissions/learnMore.css +37 -0
  77. package/src/permissions/learnMore.tsx +85 -0
  78. package/src/permissions/numberedStep.css +13 -0
  79. package/src/permissions/numberedStep.tsx +14 -0
  80. package/src/permissions/permissions.css +13 -0
  81. package/src/permissions/permissions.tsx +68 -0
  82. package/src/phone/phone.tsx +246 -0
  83. package/src/phone/phoneInput.test.tsx +275 -0
  84. package/src/phone/phoneInput.tsx +249 -0
  85. package/src/phone/styles.css +158 -0
  86. package/src/selfie/cameraButton.css +13 -0
  87. package/src/selfie/cameraButton.tsx +35 -0
  88. package/src/selfie/capture.css +57 -0
  89. package/src/selfie/capture.tsx +232 -0
  90. package/src/selfie/errorModal.tsx +218 -0
  91. package/src/selfie/errorModalContent.css +33 -0
  92. package/src/selfie/errorModalContent.tsx +44 -0
  93. package/src/selfie/faceOutline.css +5 -0
  94. package/src/selfie/faceOutline.tsx +22 -0
  95. package/src/selfie/loadingBorder.css +12 -0
  96. package/src/selfie/loadingBorder.tsx +77 -0
  97. package/src/selfie/manualCaptureButton.css +13 -0
  98. package/src/selfie/manualCaptureButton.tsx +35 -0
  99. package/src/selfie/noMoreAttemptsModal.tsx +44 -0
  100. package/src/selfie/notification.css +9 -0
  101. package/src/selfie/notification.tsx +36 -0
  102. package/src/selfie/retryErrorModal.tsx +56 -0
  103. package/src/selfie/selfie.test.tsx +458 -0
  104. package/src/selfie/selfie.tsx +83 -0
  105. package/src/selfie/selfieTutorial.json +2626 -0
  106. package/src/selfie/styles.css +1 -0
  107. package/src/selfie/tutorial.test.tsx +200 -0
  108. package/src/selfie/tutorial.tsx +43 -0
  109. package/src/setup.ts +33 -0
  110. package/src/shared/baseTutorial/baseTutorial.css +21 -0
  111. package/src/shared/baseTutorial/baseTutorial.test.tsx +184 -0
  112. package/src/shared/baseTutorial/baseTutorial.tsx +55 -0
  113. package/src/shared/baseTutorial/replaceBaseTutorial.test.ts +267 -0
  114. package/src/shared/baseTutorial/replaceBaseTutorial.ts +68 -0
  115. package/src/shared/button/button.css +55 -0
  116. package/src/shared/button/button.test.tsx +101 -0
  117. package/src/shared/button/button.tsx +47 -0
  118. package/src/shared/componentRoot/incodeComponent.tsx +12 -0
  119. package/src/shared/countries/countries.test.ts +75 -0
  120. package/src/shared/countries/countries.ts +139 -0
  121. package/src/shared/countries/index.ts +6 -0
  122. package/src/shared/icons/chevronDown.tsx +22 -0
  123. package/src/shared/icons/index.ts +2 -0
  124. package/src/shared/icons/successIcon.css +5 -0
  125. package/src/shared/icons/successIcon.test.tsx +40 -0
  126. package/src/shared/icons/successIcon.tsx +26 -0
  127. package/src/shared/loader/loadingIcon.css +28 -0
  128. package/src/shared/loader/loadingIcon.tsx +67 -0
  129. package/src/shared/lottie/lottie.tsx +108 -0
  130. package/src/shared/otpInput/otpInput.css +85 -0
  131. package/src/shared/otpInput/otpInput.test.tsx +356 -0
  132. package/src/shared/otpInput/otpInput.tsx +241 -0
  133. package/src/shared/page/incode-logo.svg +3 -0
  134. package/src/shared/page/page.css +47 -0
  135. package/src/shared/page/page.test.tsx +97 -0
  136. package/src/shared/page/page.tsx +91 -0
  137. package/src/shared/page/pageUiConfig.test.ts +112 -0
  138. package/src/shared/page/pageUiConfig.ts +64 -0
  139. package/src/shared/page/verifiedByIncode.css +5 -0
  140. package/src/shared/page/verifiedByIncode.tsx +75 -0
  141. package/src/shared/spacer/spacer.css +149 -0
  142. package/src/shared/spacer/spacer.test.tsx +143 -0
  143. package/src/shared/spacer/spacer.tsx +88 -0
  144. package/src/shared/spinner/index.ts +2 -0
  145. package/src/shared/spinner/spinner.css +28 -0
  146. package/src/shared/spinner/spinner.test.tsx +82 -0
  147. package/src/shared/spinner/spinner.tsx +65 -0
  148. package/src/shared/title/title.css +7 -0
  149. package/src/shared/title/title.tsx +12 -0
  150. package/src/shared/uiConfig/uiConfig.ts +36 -0
  151. package/src/shared/webComponent/incodeModule.ts +29 -0
  152. package/src/shared/webComponent/registerIncodeElement.ts +15 -0
  153. package/src/styles/__mocks__/fetchTheme.ts +19 -0
  154. package/src/styles/applyTheme.ts +37 -0
  155. package/src/styles/cn.test.tsx +57 -0
  156. package/src/styles/cn.tsx +21 -0
  157. package/src/styles/core.css +12 -0
  158. package/src/styles/fetchTheme.test.ts +390 -0
  159. package/src/styles/fetchTheme.ts +88 -0
  160. package/src/styles/generatePalette.ts +111 -0
  161. package/src/styles/reset.css +65 -0
  162. package/src/styles/resolveCssVariableToHex.ts +28 -0
  163. package/src/styles/tailwind.css +291 -0
  164. package/src/styles/themeTypes.ts +18 -0
  165. package/src/styles/tokens/colors.css +190 -0
  166. package/src/styles/tokens/components.css +174 -0
  167. package/src/styles/tokens/index.css +4 -0
  168. package/src/styles/tokens/primitives.css +129 -0
  169. package/src/styles/tokens/semantic.css +51 -0
  170. package/src/svg.d.ts +4 -0
  171. package/src/types/assets.d.ts +1 -0
  172. package/src/types/custom-elements.d.ts +104 -0
  173. package/tsconfig.json +22 -0
  174. package/vite.config.ts +260 -0
  175. package/vitest.config.ts +40 -0
  176. package/vitest.setup.ts +16 -0
@@ -0,0 +1,1332 @@
1
+ /**
2
+ * Headless SDK Demo
3
+ *
4
+ * This demonstrates using the Incode Core SDK without ANY pre-built UI components.
5
+ * The left panel shows YOUR CUSTOM UI - built however you want.
6
+ * The right panel shows the SDK STATE - the logic engine driving everything.
7
+ *
8
+ * Flow:
9
+ * 1. User clicks "Start Session" → Creates session via API
10
+ * 2. Flow config is loaded → Determines module order (e.g., Phone → Email → Selfie)
11
+ * 3. Each module renders with YOUR custom UI
12
+ * 4. SDK state machine handles all business logic
13
+ */
14
+
15
+ import type { WasmConfig } from '@incodetech/core';
16
+ import { createSession, setup } from '@incodetech/core';
17
+ import {
18
+ createEmailManager,
19
+ type EmailConfig,
20
+ type EmailState,
21
+ emailMachine,
22
+ } from '@incodetech/core/email';
23
+ import {
24
+ createOrchestratedFlowManager,
25
+ type FlowModuleConfig,
26
+ type OrchestratedFlowState,
27
+ } from '@incodetech/core/flow';
28
+ import {
29
+ createPhoneManager,
30
+ type PhoneState,
31
+ phoneMachine,
32
+ } from '@incodetech/core/phone';
33
+ import {
34
+ createSelfieManager,
35
+ type SelfieState,
36
+ selfieMachine,
37
+ } from '@incodetech/core/selfie';
38
+ import type { JSX } from 'preact';
39
+ import { render } from 'preact';
40
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
41
+ import { getConfigIdFromUrl } from './getToken';
42
+
43
+ const API_URL = 'https://user-service-k8s.stage.incodetest.com/0';
44
+ const API_KEY = '5fbc0ab5a652b7808fbec42fffd8b4c9ec248413';
45
+
46
+ const WASM_CONFIG: WasmConfig = {
47
+ wasmPath:
48
+ 'https://d3vv997wtl2myz.cloudfront.net/webcamera/onnx-backend-wasm/v2.12.47/webLib.wasm',
49
+ wasmSimdPath:
50
+ 'https://d3vv997wtl2myz.cloudfront.net/webcamera/onnx-backend-wasm/v2.12.47/webLibSimd.wasm',
51
+ glueCodePath:
52
+ 'https://d3vv997wtl2myz.cloudfront.net/webcamera/onnx-backend-wasm/v2.12.47/webLib.js',
53
+ modelsBasePath:
54
+ 'https://d3vv997wtl2myz.cloudfront.net/webcamera/onnx-backend-wasm/v2.12.47/models',
55
+ pipelines: ['selfie'],
56
+ };
57
+
58
+ // ============================================================================
59
+ // Types
60
+ // ============================================================================
61
+
62
+ type LogEntry = {
63
+ time: string;
64
+ type: 'action' | 'state' | 'init' | 'error' | 'flow';
65
+ message: string;
66
+ };
67
+
68
+ type FlowManager = ReturnType<typeof createOrchestratedFlowManager>;
69
+ type PhoneManager = ReturnType<typeof createPhoneManager>;
70
+ type EmailManager = ReturnType<typeof createEmailManager>;
71
+ type SelfieManager = ReturnType<typeof createSelfieManager>;
72
+
73
+ // ============================================================================
74
+ // JSON Syntax Highlighter
75
+ // ============================================================================
76
+
77
+ function highlightJSON(obj: unknown, indent = 0): JSX.Element[] {
78
+ const spaces = ' '.repeat(indent);
79
+ const elements: JSX.Element[] = [];
80
+
81
+ if (obj === null) {
82
+ elements.push(<span class="json-null">null</span>);
83
+ } else if (typeof obj === 'boolean') {
84
+ elements.push(<span class="json-boolean">{String(obj)}</span>);
85
+ } else if (typeof obj === 'number') {
86
+ elements.push(<span class="json-number">{obj}</span>);
87
+ } else if (typeof obj === 'string') {
88
+ const display = obj.length > 50 ? `${obj.slice(0, 50)}...` : obj;
89
+ elements.push(<span class="json-string">"{display}"</span>);
90
+ } else if (Array.isArray(obj)) {
91
+ if (obj.length === 0) {
92
+ elements.push(<span class="json-bracket">[]</span>);
93
+ } else {
94
+ elements.push(<span class="json-bracket">[{'\n'}</span>);
95
+ obj.forEach((item, i) => {
96
+ elements.push(<span>{spaces} </span>);
97
+ elements.push(...highlightJSON(item, indent + 1));
98
+ if (i < obj.length - 1) elements.push(<span>,</span>);
99
+ elements.push(<span>{'\n'}</span>);
100
+ });
101
+ elements.push(<span>{spaces}</span>);
102
+ elements.push(<span class="json-bracket">]</span>);
103
+ }
104
+ } else if (typeof obj === 'object') {
105
+ const filteredObj: Record<string, unknown> = {};
106
+ for (const [key, value] of Object.entries(obj)) {
107
+ if (value instanceof MediaStream) {
108
+ filteredObj[key] = '[MediaStream]';
109
+ } else if (value instanceof HTMLCanvasElement) {
110
+ filteredObj[key] = '[Canvas]';
111
+ } else {
112
+ filteredObj[key] = value;
113
+ }
114
+ }
115
+ const entries = Object.entries(filteredObj);
116
+ if (entries.length === 0) {
117
+ elements.push(<span class="json-bracket">{'{}'}</span>);
118
+ } else {
119
+ elements.push(<span class="json-bracket">{'{\n'}</span>);
120
+ entries.forEach(([key, value], i) => {
121
+ elements.push(<span>{spaces} </span>);
122
+ elements.push(<span class="json-key">"{key}"</span>);
123
+ elements.push(<span class="json-bracket">: </span>);
124
+ elements.push(...highlightJSON(value, indent + 1));
125
+ if (i < entries.length - 1) elements.push(<span>,</span>);
126
+ elements.push(<span>{'\n'}</span>);
127
+ });
128
+ elements.push(<span>{spaces}</span>);
129
+ elements.push(<span class="json-bracket">{'}'}</span>);
130
+ }
131
+ }
132
+
133
+ return elements;
134
+ }
135
+
136
+ // ============================================================================
137
+ // SDK Panel Components
138
+ // ============================================================================
139
+
140
+ function StateViewer({ state, label }: { state: unknown; label: string }) {
141
+ return (
142
+ <div class="state-viewer">
143
+ <div class="state-viewer-header">
144
+ <span>{label}</span>
145
+ </div>
146
+ <div class="state-viewer-content">
147
+ <pre>{highlightJSON(state)}</pre>
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ function EventLog({ entries }: { entries: LogEntry[] }) {
154
+ return (
155
+ <div class="event-log">
156
+ <div class="event-log-title">Event Stream</div>
157
+ <div class="event-log-content">
158
+ {entries.slice(0, 50).map((entry, i) => (
159
+ <div key={i} class="log-entry">
160
+ <span class="log-time">{entry.time}</span>
161
+ <span class={`log-event log-${entry.type}`}>
162
+ [{entry.type.toUpperCase()}]
163
+ </span>
164
+ <span class="log-data"> {entry.message}</span>
165
+ </div>
166
+ ))}
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function FlowProgress({
173
+ steps,
174
+ currentIndex,
175
+ }: {
176
+ steps: string[];
177
+ currentIndex: number;
178
+ }) {
179
+ return (
180
+ <div class="flow-progress">
181
+ {steps.map((step, i) => (
182
+ <div
183
+ key={step}
184
+ class={`flow-step ${i < currentIndex ? 'done' : ''} ${i === currentIndex ? 'active' : ''}`}
185
+ >
186
+ <span class="step-number">{i + 1}</span>
187
+ <span class="step-name">{step}</span>
188
+ </div>
189
+ ))}
190
+ </div>
191
+ );
192
+ }
193
+
194
+ // ============================================================================
195
+ // Welcome / Session Start UI
196
+ // ============================================================================
197
+
198
+ function WelcomeUI({
199
+ onStartSession,
200
+ isStarting,
201
+ configId,
202
+ onConfigIdChange,
203
+ }: {
204
+ onStartSession: () => void;
205
+ isStarting: boolean;
206
+ configId: string;
207
+ onConfigIdChange: (id: string) => void;
208
+ }) {
209
+ return (
210
+ <div class="welcome-container">
211
+ <div class="welcome-icon">🔐</div>
212
+ <h1 class="welcome-title">Headless SDK Demo</h1>
213
+ <p class="welcome-description">
214
+ Build your own UI while the SDK handles all business logic, API calls,
215
+ and state management.
216
+ </p>
217
+
218
+ <div class="config-input-group">
219
+ <label class="config-label">
220
+ Configuration ID
221
+ <input
222
+ type="text"
223
+ class="config-input"
224
+ value={configId}
225
+ onInput={(e) =>
226
+ onConfigIdChange((e.target as HTMLInputElement).value)
227
+ }
228
+ placeholder="Enter your flow configuration ID"
229
+ />
230
+ </label>
231
+ </div>
232
+
233
+ <div class="feature-list">
234
+ <div class="feature">
235
+ <span class="feature-icon">📱</span>
236
+ <span>Phone verification with OTP</span>
237
+ </div>
238
+ <div class="feature">
239
+ <span class="feature-icon">✉️</span>
240
+ <span>Email verification</span>
241
+ </div>
242
+ <div class="feature">
243
+ <span class="feature-icon">📸</span>
244
+ <span>Selfie capture with WASM face detection</span>
245
+ </div>
246
+ </div>
247
+
248
+ <button
249
+ type="button"
250
+ class="btn btn-primary btn-large"
251
+ onClick={onStartSession}
252
+ disabled={isStarting || !configId.trim()}
253
+ >
254
+ {isStarting ? (
255
+ <>
256
+ <span class="spinner-small" /> Creating Session...
257
+ </>
258
+ ) : (
259
+ 'Start Session →'
260
+ )}
261
+ </button>
262
+
263
+ <div class="code-comment">
264
+ {`// Step 1: Create session
265
+ const session = await createSession(apiKey, { configurationId });
266
+
267
+ // Step 2: Setup SDK with token
268
+ await setup({ apiURL, token: session.token, wasm: wasmConfig });
269
+
270
+ // Step 3: Load flow to get module order
271
+ flowManager.load();`}
272
+ </div>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ // ============================================================================
278
+ // Phone UI Components
279
+ // ============================================================================
280
+
281
+ function PhoneInputUI({
282
+ state,
283
+ manager,
284
+ log,
285
+ }: {
286
+ state: PhoneState & { status: 'inputting' };
287
+ manager: PhoneManager;
288
+ log: (type: LogEntry['type'], msg: string) => void;
289
+ }) {
290
+ const [phone, setPhone] = useState(state.prefilledPhone || '');
291
+ const [isValid, setIsValid] = useState(false);
292
+ const inputRef = useRef<HTMLInputElement>(null);
293
+
294
+ useEffect(() => {
295
+ inputRef.current?.focus();
296
+ }, []);
297
+
298
+ const handleInput = (e: Event) => {
299
+ const value = (e.target as HTMLInputElement).value;
300
+ setPhone(value);
301
+ const valid = /^\+\d{10,}$/.test(value.replace(/\s/g, ''));
302
+ setIsValid(valid);
303
+ log('action', `setPhoneNumber("${value}", ${valid})`);
304
+ manager.setPhoneNumber(value, valid);
305
+ };
306
+
307
+ return (
308
+ <div class="form-container">
309
+ <div class="form-header">
310
+ <div class="form-icon">📱</div>
311
+ <h2 class="form-title">Enter Your Phone</h2>
312
+ <p class="form-description">Include country code (e.g., +1...)</p>
313
+ </div>
314
+
315
+ <div class="input-group">
316
+ <label class="input-label">
317
+ Phone Number ({state.countryCode})
318
+ <input
319
+ ref={inputRef}
320
+ type="tel"
321
+ class="input-field"
322
+ value={phone}
323
+ onInput={handleInput}
324
+ placeholder={`${state.phonePrefix} 555 123 4567`}
325
+ />
326
+ </label>
327
+ <div class={`validation-status ${isValid ? 'valid' : 'invalid'}`}>
328
+ {isValid ? '✓ Valid format' : '○ Enter full number with country code'}
329
+ </div>
330
+ </div>
331
+
332
+ <button
333
+ type="button"
334
+ class="btn btn-primary"
335
+ disabled={!isValid}
336
+ onClick={() => {
337
+ log('action', 'submit()');
338
+ manager.submit();
339
+ }}
340
+ >
341
+ Send Verification Code
342
+ </button>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ function PhoneOtpUI({
348
+ state,
349
+ manager,
350
+ log,
351
+ }: {
352
+ state: PhoneState & { status: 'awaitingOtp' };
353
+ manager: PhoneManager;
354
+ log: (type: LogEntry['type'], msg: string) => void;
355
+ }) {
356
+ const inputRef = useRef<HTMLInputElement>(null);
357
+ const [canSubmit, setCanSubmit] = useState(false);
358
+
359
+ useEffect(() => {
360
+ inputRef.current?.focus();
361
+ }, []);
362
+
363
+ const handleSubmit = () => {
364
+ const otp = inputRef.current?.value ?? '';
365
+ if (otp.length === 6) {
366
+ log('action', `submitOtp("${otp}")`);
367
+ manager.submitOtp(otp);
368
+ }
369
+ };
370
+
371
+ return (
372
+ <div class="form-container">
373
+ <div class="form-header">
374
+ <div class="form-icon">🔐</div>
375
+ <h2 class="form-title">Enter Verification Code</h2>
376
+ <p class="form-description">6-digit code sent to your phone</p>
377
+ </div>
378
+
379
+ <div class="input-group">
380
+ <input
381
+ ref={inputRef}
382
+ type="text"
383
+ class="input-field"
384
+ style={{
385
+ textAlign: 'center',
386
+ fontSize: '1.5rem',
387
+ letterSpacing: '0.3em',
388
+ }}
389
+ placeholder="ABC123"
390
+ onInput={(e) => {
391
+ const input = e.target as HTMLInputElement;
392
+ // Allow alphanumeric, limit to 6
393
+ const cleaned = input.value
394
+ .replace(/[^a-zA-Z0-9]/g, '')
395
+ .toUpperCase()
396
+ .slice(0, 6);
397
+ if (cleaned !== input.value) {
398
+ input.value = cleaned;
399
+ }
400
+ setCanSubmit(cleaned.length === 6);
401
+ }}
402
+ onKeyDown={(e) => {
403
+ if (e.key === 'Enter' && canSubmit) {
404
+ handleSubmit();
405
+ }
406
+ }}
407
+ maxLength={6}
408
+ />
409
+ </div>
410
+
411
+ <button
412
+ type="button"
413
+ class="btn btn-primary"
414
+ onClick={handleSubmit}
415
+ disabled={!canSubmit}
416
+ >
417
+ Verify Code
418
+ </button>
419
+
420
+ <div class="timer">
421
+ {state.canResend ? (
422
+ <button
423
+ type="button"
424
+ class="btn btn-ghost"
425
+ onClick={() => {
426
+ log('action', 'resendOtp()');
427
+ manager.resendOtp();
428
+ }}
429
+ >
430
+ Resend Code
431
+ </button>
432
+ ) : (
433
+ <span>
434
+ Resend in <span class="timer-value">{state.resendTimer}s</span>
435
+ </span>
436
+ )}
437
+ </div>
438
+ </div>
439
+ );
440
+ }
441
+
442
+ // ============================================================================
443
+ // Email UI Components
444
+ // ============================================================================
445
+
446
+ function EmailInputUI({
447
+ state,
448
+ manager,
449
+ log,
450
+ }: {
451
+ state: EmailState & { status: 'inputting' };
452
+ manager: EmailManager;
453
+ log: (type: LogEntry['type'], msg: string) => void;
454
+ }) {
455
+ const [email, setEmail] = useState(state.prefilledEmail || '');
456
+ const [isValid, setIsValid] = useState(false);
457
+ const inputRef = useRef<HTMLInputElement>(null);
458
+
459
+ useEffect(() => {
460
+ inputRef.current?.focus();
461
+ }, []);
462
+
463
+ const handleInput = (e: Event) => {
464
+ const value = (e.target as HTMLInputElement).value;
465
+ setEmail(value);
466
+ const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
467
+ setIsValid(valid);
468
+ log('action', `setEmail("${value}", ${valid})`);
469
+ manager.setEmail(value, valid);
470
+ };
471
+
472
+ return (
473
+ <div class="form-container">
474
+ <div class="form-header">
475
+ <div class="form-icon">✉️</div>
476
+ <h2 class="form-title">Enter Your Email</h2>
477
+ <p class="form-description">We'll send a verification code</p>
478
+ </div>
479
+
480
+ <div class="input-group">
481
+ <label class="input-label">
482
+ Email Address
483
+ <input
484
+ ref={inputRef}
485
+ type="email"
486
+ class="input-field"
487
+ value={email}
488
+ onInput={handleInput}
489
+ placeholder="you@example.com"
490
+ />
491
+ </label>
492
+ <div class={`validation-status ${isValid ? 'valid' : 'invalid'}`}>
493
+ {isValid ? '✓ Valid email' : '○ Enter a valid email'}
494
+ </div>
495
+ </div>
496
+
497
+ <button
498
+ type="button"
499
+ class="btn btn-primary"
500
+ disabled={!isValid}
501
+ onClick={() => {
502
+ log('action', 'submit()');
503
+ manager.submit();
504
+ }}
505
+ >
506
+ Send Verification Code
507
+ </button>
508
+ </div>
509
+ );
510
+ }
511
+
512
+ function EmailOtpUI({
513
+ state,
514
+ manager,
515
+ log,
516
+ }: {
517
+ state: EmailState & { status: 'awaitingOtp' };
518
+ manager: EmailManager;
519
+ log: (type: LogEntry['type'], msg: string) => void;
520
+ }) {
521
+ const inputRef = useRef<HTMLInputElement>(null);
522
+ const [canSubmit, setCanSubmit] = useState(false);
523
+
524
+ useEffect(() => {
525
+ inputRef.current?.focus();
526
+ }, []);
527
+
528
+ const handleSubmit = () => {
529
+ const otp = inputRef.current?.value ?? '';
530
+ if (otp.length === 6) {
531
+ log('action', `submitOtp("${otp}")`);
532
+ manager.submitOtp(otp);
533
+ }
534
+ };
535
+
536
+ return (
537
+ <div class="form-container">
538
+ <div class="form-header">
539
+ <div class="form-icon">📧</div>
540
+ <h2 class="form-title">Check Your Email</h2>
541
+ <p class="form-description">Enter the 6-digit code we sent</p>
542
+ </div>
543
+
544
+ <div class="input-group">
545
+ <input
546
+ ref={inputRef}
547
+ type="text"
548
+ class="input-field"
549
+ style={{
550
+ textAlign: 'center',
551
+ fontSize: '1.5rem',
552
+ letterSpacing: '0.3em',
553
+ }}
554
+ placeholder="ABC123"
555
+ onInput={(e) => {
556
+ const input = e.target as HTMLInputElement;
557
+ // Allow alphanumeric, limit to 6
558
+ const cleaned = input.value
559
+ .replace(/[^a-zA-Z0-9]/g, '')
560
+ .toUpperCase()
561
+ .slice(0, 6);
562
+ if (cleaned !== input.value) {
563
+ input.value = cleaned;
564
+ }
565
+ setCanSubmit(cleaned.length === 6);
566
+ }}
567
+ onKeyDown={(e) => {
568
+ if (e.key === 'Enter' && canSubmit) {
569
+ handleSubmit();
570
+ }
571
+ }}
572
+ maxLength={6}
573
+ />
574
+ </div>
575
+
576
+ <button
577
+ type="button"
578
+ class="btn btn-primary"
579
+ onClick={handleSubmit}
580
+ disabled={!canSubmit}
581
+ >
582
+ Verify Code
583
+ </button>
584
+
585
+ <div class="timer">
586
+ {state.canResend ? (
587
+ <button
588
+ type="button"
589
+ class="btn btn-ghost"
590
+ onClick={() => {
591
+ log('action', 'resendOtp()');
592
+ manager.resendOtp();
593
+ }}
594
+ >
595
+ Resend Code
596
+ </button>
597
+ ) : (
598
+ <span>
599
+ Resend in <span class="timer-value">{state.resendTimer}s</span>
600
+ </span>
601
+ )}
602
+ </div>
603
+ </div>
604
+ );
605
+ }
606
+
607
+ // ============================================================================
608
+ // Selfie UI Components
609
+ // ============================================================================
610
+
611
+ function SelfieTutorialUI({
612
+ manager,
613
+ log,
614
+ }: {
615
+ manager: SelfieManager;
616
+ log: (type: LogEntry['type'], msg: string) => void;
617
+ }) {
618
+ return (
619
+ <div class="form-container">
620
+ <div class="form-header">
621
+ <div class="form-icon">💡</div>
622
+ <h2 class="form-title">Selfie Tips</h2>
623
+ </div>
624
+
625
+ <ul class="tips-list">
626
+ <li>✓ Good lighting on your face</li>
627
+ <li>✓ Remove glasses if possible</li>
628
+ <li>✓ Look directly at the camera</li>
629
+ <li>✓ Keep a neutral expression</li>
630
+ </ul>
631
+
632
+ <button
633
+ type="button"
634
+ class="btn btn-primary"
635
+ onClick={() => {
636
+ log('action', 'nextStep()');
637
+ manager.nextStep();
638
+ }}
639
+ >
640
+ Continue →
641
+ </button>
642
+ </div>
643
+ );
644
+ }
645
+
646
+ function SelfiePermissionsUI({
647
+ state,
648
+ manager,
649
+ log,
650
+ }: {
651
+ state: SelfieState & { status: 'permissions' };
652
+ manager: SelfieManager;
653
+ log: (type: LogEntry['type'], msg: string) => void;
654
+ }) {
655
+ const isDenied = state.permissionStatus === 'denied';
656
+
657
+ return (
658
+ <div class="form-container">
659
+ <div class="form-header">
660
+ <div class="form-icon">{isDenied ? '🚫' : '📷'}</div>
661
+ <h2 class="form-title">
662
+ {isDenied ? 'Camera Blocked' : 'Camera Access'}
663
+ </h2>
664
+ <p class="form-description">
665
+ {isDenied
666
+ ? 'Enable camera in browser settings'
667
+ : 'We need camera access for selfie capture'}
668
+ </p>
669
+ </div>
670
+
671
+ {!isDenied && (
672
+ <button
673
+ type="button"
674
+ class="btn btn-primary"
675
+ onClick={() => {
676
+ log('action', 'requestPermission()');
677
+ manager.requestPermission();
678
+ }}
679
+ >
680
+ Allow Camera
681
+ </button>
682
+ )}
683
+ </div>
684
+ );
685
+ }
686
+
687
+ function SelfieCaptureUI({
688
+ state,
689
+ manager,
690
+ log,
691
+ onContinue,
692
+ }: {
693
+ state: SelfieState & { status: 'capture' };
694
+ manager: SelfieManager;
695
+ log: (type: LogEntry['type'], msg: string) => void;
696
+ onContinue: () => void;
697
+ }) {
698
+ const videoRef = useRef<HTMLVideoElement>(null);
699
+
700
+ // Attach stream to video element
701
+ useEffect(() => {
702
+ const video = videoRef.current;
703
+ if (!video) return;
704
+
705
+ if (state.stream) {
706
+ log('state', `[SELFIE] Attaching stream to video`);
707
+ video.srcObject = state.stream;
708
+ video.play().catch((err) => {
709
+ log('error', `Video play failed: ${err.message}`);
710
+ });
711
+ } else {
712
+ log('state', `[SELFIE] No stream available yet`);
713
+ }
714
+ }, [state.stream, log]);
715
+
716
+ const getStatusMessage = () => {
717
+ switch (state.detectionStatus) {
718
+ case 'noFace':
719
+ return { icon: '👤', text: 'No face detected' };
720
+ case 'tooClose':
721
+ return { icon: '↔️', text: 'Move back a bit' };
722
+ case 'tooFar':
723
+ return { icon: '↔️', text: 'Move closer' };
724
+ case 'blur':
725
+ return { icon: '🔍', text: 'Hold steady' };
726
+ case 'dark':
727
+ return { icon: '💡', text: 'Need more light' };
728
+ case 'faceAngle':
729
+ return { icon: '🔄', text: 'Face the camera' };
730
+ case 'tooManyFaces':
731
+ return { icon: '👥', text: 'Only one face' };
732
+ case 'centerFace':
733
+ return { icon: '⭕', text: 'Center your face' };
734
+ case 'getReady':
735
+ return { icon: '✨', text: 'Hold still...' };
736
+ case 'success':
737
+ return { icon: '✅', text: 'Perfect!' };
738
+ case 'manualCapture':
739
+ return { icon: '📸', text: 'Tap to capture' };
740
+ case 'capturing':
741
+ return { icon: '📷', text: 'Capturing...' };
742
+ default:
743
+ return { icon: '🔍', text: 'Detecting...' };
744
+ }
745
+ };
746
+
747
+ const status = getStatusMessage();
748
+ const isManualMode = state.detectionStatus === 'manualCapture';
749
+ const isSuccess =
750
+ state.detectionStatus === 'success' || state.detectionStatus === 'getReady';
751
+
752
+ // Show loading if no stream yet
753
+ if (!state.stream) {
754
+ return (
755
+ <div class="capture-container">
756
+ <div
757
+ class="camera-wrapper"
758
+ style={{
759
+ display: 'flex',
760
+ alignItems: 'center',
761
+ justifyContent: 'center',
762
+ }}
763
+ >
764
+ <div style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>
765
+ <div class="spinner" style={{ margin: '0 auto 1rem' }} />
766
+ <p>Starting camera...</p>
767
+ <p style={{ fontSize: '0.75rem', marginTop: '0.5rem' }}>
768
+ captureStatus: {state.captureStatus}
769
+ </p>
770
+ </div>
771
+ </div>
772
+ </div>
773
+ );
774
+ }
775
+
776
+ return (
777
+ <div class="capture-container">
778
+ <div class="camera-wrapper">
779
+ <video ref={videoRef} autoPlay playsInline muted class="camera-feed" />
780
+
781
+ <div class="face-guide">
782
+ <svg viewBox="0 0 200 260" class="face-oval" aria-label="Face guide">
783
+ <title>Face positioning guide</title>
784
+ <ellipse
785
+ cx="100"
786
+ cy="130"
787
+ rx="75"
788
+ ry="100"
789
+ fill="none"
790
+ stroke={isSuccess ? '#00ff88' : '#ffffff'}
791
+ stroke-width="3"
792
+ stroke-dasharray={isSuccess ? 'none' : '8 4'}
793
+ />
794
+ </svg>
795
+ </div>
796
+
797
+ <div class={`capture-status ${isSuccess ? 'success' : ''}`}>
798
+ <span class="status-icon">{status.icon}</span>
799
+ <span class="status-text">{status.text}</span>
800
+ </div>
801
+ </div>
802
+
803
+ {isManualMode && (
804
+ <button
805
+ type="button"
806
+ class="btn btn-primary capture-btn"
807
+ onClick={() => {
808
+ log('action', 'capture()');
809
+ manager.capture();
810
+ }}
811
+ >
812
+ 📸 Capture Now
813
+ </button>
814
+ )}
815
+
816
+ {state.captureStatus === 'uploading' && (
817
+ <div class="upload-status">
818
+ <span class="spinner-small" />
819
+ <span>Uploading...</span>
820
+ </div>
821
+ )}
822
+
823
+ {state.captureStatus === 'uploadError' && (
824
+ <div class="upload-error">
825
+ <div class="error-icon">⚠️</div>
826
+ <p class="error-message">{state.uploadError ?? 'Upload failed'}</p>
827
+ <p class="attempts-remaining">
828
+ Attempts remaining: {state.attemptsRemaining}
829
+ </p>
830
+ {state.attemptsRemaining > 0 ? (
831
+ <button
832
+ type="button"
833
+ class="btn btn-primary"
834
+ onClick={() => {
835
+ log('action', 'retryCapture()');
836
+ manager.retryCapture();
837
+ }}
838
+ >
839
+ 🔄 Retry Capture
840
+ </button>
841
+ ) : (
842
+ <>
843
+ <p class="no-attempts">No attempts remaining</p>
844
+ <button
845
+ type="button"
846
+ class="btn btn-secondary"
847
+ style={{ marginTop: '1rem' }}
848
+ onClick={() => {
849
+ log('flow', 'Continuing after max attempts reached');
850
+ onContinue();
851
+ }}
852
+ >
853
+ Continue to Next Step →
854
+ </button>
855
+ </>
856
+ )}
857
+ </div>
858
+ )}
859
+ </div>
860
+ );
861
+ }
862
+
863
+ // ============================================================================
864
+ // Shared UI Components
865
+ // ============================================================================
866
+
867
+ function LoadingUI({ message }: { message?: string }) {
868
+ return (
869
+ <div class="loading-container">
870
+ <div class="spinner" />
871
+ <p class="loading-text">{message ?? 'Loading...'}</p>
872
+ </div>
873
+ );
874
+ }
875
+
876
+ function SuccessUI({ message }: { message: string }) {
877
+ return (
878
+ <div class="result-container">
879
+ <div class="result-icon">✅</div>
880
+ <h2 class="result-title success">{message}</h2>
881
+ <p class="result-description">Moving to next step...</p>
882
+ </div>
883
+ );
884
+ }
885
+
886
+ function FlowCompleteUI({
887
+ finishStatus,
888
+ onReset,
889
+ }: {
890
+ finishStatus: OrchestratedFlowState & { status: 'finished' };
891
+ onReset: () => void;
892
+ }) {
893
+ return (
894
+ <div class="result-container">
895
+ <div class="result-icon">🎉</div>
896
+ <h2 class="result-title success">Flow Complete!</h2>
897
+ <p class="result-description">All modules completed successfully.</p>
898
+
899
+ <div class="finish-data">
900
+ <pre>{JSON.stringify(finishStatus.finishStatus, null, 2)}</pre>
901
+ </div>
902
+
903
+ <button type="button" class="btn btn-primary" onClick={onReset}>
904
+ Start Over
905
+ </button>
906
+ </div>
907
+ );
908
+ }
909
+
910
+ function ErrorUI({ error, onRetry }: { error: string; onRetry: () => void }) {
911
+ return (
912
+ <div class="result-container">
913
+ <div class="result-icon">❌</div>
914
+ <h2 class="result-title error">Error</h2>
915
+ <p class="result-description">{error}</p>
916
+ <button type="button" class="btn btn-primary" onClick={onRetry}>
917
+ Try Again
918
+ </button>
919
+ </div>
920
+ );
921
+ }
922
+
923
+ // ============================================================================
924
+ // Module Renderer - Uses its own managers, not the orchestrated flow's internal state
925
+ // ============================================================================
926
+
927
+ function ModuleUI({
928
+ currentStep,
929
+ config,
930
+ flowManager,
931
+ log,
932
+ }: {
933
+ currentStep: string;
934
+ config: FlowModuleConfig[keyof FlowModuleConfig];
935
+ flowManager: FlowManager;
936
+ log: (type: LogEntry['type'], msg: string) => void;
937
+ }) {
938
+ // Local state from our managers
939
+ const [phoneState, setPhoneState] = useState<PhoneState | null>(null);
940
+ const [emailState, setEmailState] = useState<EmailState | null>(null);
941
+ const [selfieState, setSelfieState] = useState<SelfieState | null>(null);
942
+
943
+ // Create module managers on demand
944
+ const [phoneManager] = useState(() =>
945
+ currentStep === 'PHONE'
946
+ ? createPhoneManager({ config: config as FlowModuleConfig['PHONE'] })
947
+ : null,
948
+ );
949
+ const [emailManager] = useState(() =>
950
+ currentStep === 'EMAIL'
951
+ ? createEmailManager({
952
+ config: {
953
+ ...(config as FlowModuleConfig['EMAIL']),
954
+ prefill: (config as EmailConfig).prefill ?? false,
955
+ },
956
+ })
957
+ : null,
958
+ );
959
+ const [selfieManager] = useState(() =>
960
+ currentStep === 'SELFIE'
961
+ ? createSelfieManager({ config: config as FlowModuleConfig['SELFIE'] })
962
+ : null,
963
+ );
964
+
965
+ // Subscribe to module state and auto-complete
966
+ useEffect(() => {
967
+ if (phoneManager) {
968
+ setPhoneState(phoneManager.getState());
969
+ const unsub = phoneManager.subscribe((state) => {
970
+ setPhoneState(state);
971
+ log('state', `[PHONE] ${state.status}`);
972
+ if (state.status === 'success') {
973
+ setTimeout(() => {
974
+ log('flow', 'Module PHONE complete');
975
+ flowManager.completeModule();
976
+ }, 1000);
977
+ }
978
+ });
979
+ phoneManager.load();
980
+ log('action', '[PHONE] load()');
981
+ return unsub;
982
+ }
983
+
984
+ if (emailManager) {
985
+ setEmailState(emailManager.getState());
986
+ const unsub = emailManager.subscribe((state) => {
987
+ setEmailState(state);
988
+ log('state', `[EMAIL] ${state.status}`);
989
+ if (state.status === 'success') {
990
+ setTimeout(() => {
991
+ log('flow', 'Module EMAIL complete');
992
+ flowManager.completeModule();
993
+ }, 1000);
994
+ }
995
+ });
996
+ emailManager.load();
997
+ log('action', '[EMAIL] load()');
998
+ return unsub;
999
+ }
1000
+
1001
+ if (selfieManager) {
1002
+ setSelfieState(selfieManager.getState());
1003
+ const unsub = selfieManager.subscribe((state) => {
1004
+ setSelfieState(state);
1005
+ const extra =
1006
+ state.status === 'capture' ? ` (${state.detectionStatus})` : '';
1007
+ log('state', `[SELFIE] ${state.status}${extra}`);
1008
+ if (state.status === 'finished') {
1009
+ setTimeout(() => {
1010
+ log('flow', 'Module SELFIE complete');
1011
+ flowManager.completeModule();
1012
+ }, 1000);
1013
+ }
1014
+ });
1015
+ selfieManager.load();
1016
+ log('action', '[SELFIE] load()');
1017
+ return unsub;
1018
+ }
1019
+ }, [phoneManager, emailManager, selfieManager, flowManager, log]);
1020
+
1021
+ // Render Phone module
1022
+ if (currentStep === 'PHONE' && phoneManager && phoneState) {
1023
+ switch (phoneState.status) {
1024
+ case 'idle':
1025
+ case 'loadingPrefill':
1026
+ return <LoadingUI message="Loading phone module..." />;
1027
+ case 'inputting':
1028
+ return (
1029
+ <PhoneInputUI state={phoneState} manager={phoneManager} log={log} />
1030
+ );
1031
+ case 'submitting':
1032
+ case 'sendingOtp':
1033
+ return <LoadingUI message="Sending code..." />;
1034
+ case 'awaitingOtp':
1035
+ return (
1036
+ <PhoneOtpUI state={phoneState} manager={phoneManager} log={log} />
1037
+ );
1038
+ case 'verifyingOtp':
1039
+ return <LoadingUI message="Verifying..." />;
1040
+ case 'success':
1041
+ return <SuccessUI message="Phone Verified!" />;
1042
+ case 'error':
1043
+ case 'otpError':
1044
+ return (
1045
+ <ErrorUI
1046
+ error={phoneState.error ?? 'Unknown error'}
1047
+ onRetry={() => phoneManager.reset()}
1048
+ />
1049
+ );
1050
+ }
1051
+ }
1052
+
1053
+ // Render Email module
1054
+ if (currentStep === 'EMAIL' && emailManager && emailState) {
1055
+ switch (emailState.status) {
1056
+ case 'idle':
1057
+ case 'loadingPrefill':
1058
+ return <LoadingUI message="Loading email module..." />;
1059
+ case 'inputting':
1060
+ return (
1061
+ <EmailInputUI state={emailState} manager={emailManager} log={log} />
1062
+ );
1063
+ case 'submitting':
1064
+ case 'sendingOtp':
1065
+ return <LoadingUI message="Sending code..." />;
1066
+ case 'awaitingOtp':
1067
+ return (
1068
+ <EmailOtpUI state={emailState} manager={emailManager} log={log} />
1069
+ );
1070
+ case 'verifyingOtp':
1071
+ return <LoadingUI message="Verifying..." />;
1072
+ case 'success':
1073
+ return <SuccessUI message="Email Verified!" />;
1074
+ case 'error':
1075
+ case 'otpError':
1076
+ return (
1077
+ <ErrorUI
1078
+ error={emailState.error ?? 'Unknown error'}
1079
+ onRetry={() => emailManager.reset()}
1080
+ />
1081
+ );
1082
+ }
1083
+ }
1084
+
1085
+ // Render Selfie module
1086
+ if (currentStep === 'SELFIE' && selfieManager && selfieState) {
1087
+ switch (selfieState.status) {
1088
+ case 'idle':
1089
+ case 'loading':
1090
+ return <LoadingUI message="Loading WASM models..." />;
1091
+ case 'tutorial':
1092
+ return <SelfieTutorialUI manager={selfieManager} log={log} />;
1093
+ case 'permissions':
1094
+ return (
1095
+ <SelfiePermissionsUI
1096
+ state={selfieState}
1097
+ manager={selfieManager}
1098
+ log={log}
1099
+ />
1100
+ );
1101
+ case 'capture':
1102
+ return (
1103
+ <SelfieCaptureUI
1104
+ state={selfieState}
1105
+ manager={selfieManager}
1106
+ log={log}
1107
+ onContinue={() => {
1108
+ log('flow', 'Module SELFIE skipped (max attempts)');
1109
+ flowManager.completeModule();
1110
+ }}
1111
+ />
1112
+ );
1113
+ case 'finished':
1114
+ return <SuccessUI message="Selfie Captured!" />;
1115
+ case 'error':
1116
+ return (
1117
+ <ErrorUI
1118
+ error={selfieState.error ?? 'Unknown error'}
1119
+ onRetry={() => selfieManager.reset()}
1120
+ />
1121
+ );
1122
+ }
1123
+ }
1124
+
1125
+ return <LoadingUI message={`Loading ${currentStep}...`} />;
1126
+ }
1127
+
1128
+ // ============================================================================
1129
+ // Main App
1130
+ // ============================================================================
1131
+
1132
+ function App() {
1133
+ const [sessionStarted, setSessionStarted] = useState(false);
1134
+ const [isStartingSession, setIsStartingSession] = useState(false);
1135
+ const [configId, setConfigId] = useState(() => getConfigIdFromUrl());
1136
+ const [flowState, setFlowState] = useState<OrchestratedFlowState | null>(
1137
+ null,
1138
+ );
1139
+ const [flowManager, setFlowManager] = useState<FlowManager | null>(null);
1140
+ const [logs, setLogs] = useState<LogEntry[]>([]);
1141
+ const [initError, setInitError] = useState<string | null>(null);
1142
+
1143
+ const log = useCallback((type: LogEntry['type'], message: string) => {
1144
+ const time = new Date().toLocaleTimeString('en-US', { hour12: false });
1145
+ setLogs((prev) => [{ time, type, message }, ...prev]);
1146
+ }, []);
1147
+
1148
+ const startSession = async () => {
1149
+ setIsStartingSession(true);
1150
+ try {
1151
+ log('init', 'Creating session...');
1152
+
1153
+ // Setup SDK (no token yet)
1154
+ await setup({
1155
+ apiURL: API_URL,
1156
+ token: '',
1157
+ wasm: WASM_CONFIG,
1158
+ });
1159
+
1160
+ // Create session with the configId from state
1161
+ log('init', `Config ID: ${configId}`);
1162
+
1163
+ const session = await createSession(API_KEY, {
1164
+ configurationId: configId,
1165
+ language: 'en-US',
1166
+ });
1167
+ log('init', `Session created: ${session.interviewId}`);
1168
+
1169
+ // Re-setup with token
1170
+ await setup({
1171
+ apiURL: API_URL,
1172
+ token: session.token,
1173
+ wasm: WASM_CONFIG,
1174
+ });
1175
+ log('init', 'SDK configured with session token');
1176
+
1177
+ // Create orchestrated flow manager
1178
+ log('init', 'Creating flow manager...');
1179
+ const manager = createOrchestratedFlowManager({
1180
+ modules: {
1181
+ PHONE: phoneMachine,
1182
+ EMAIL: emailMachine,
1183
+ SELFIE: selfieMachine,
1184
+ },
1185
+ });
1186
+
1187
+ manager.subscribe((state) => {
1188
+ log('flow', `Flow status: ${state.status}`);
1189
+ setFlowState(state);
1190
+ });
1191
+
1192
+ setFlowManager(manager);
1193
+ setFlowState(manager.getState());
1194
+ setSessionStarted(true);
1195
+
1196
+ // Load the flow
1197
+ log('action', 'flowManager.load()');
1198
+ manager.load();
1199
+ } catch (err) {
1200
+ const message = err instanceof Error ? err.message : String(err);
1201
+ log('error', message);
1202
+ setInitError(message);
1203
+ } finally {
1204
+ setIsStartingSession(false);
1205
+ }
1206
+ };
1207
+
1208
+ const resetFlow = () => {
1209
+ setSessionStarted(false);
1210
+ setFlowState(null);
1211
+ setFlowManager(null);
1212
+ setLogs([]);
1213
+ setInitError(null);
1214
+ };
1215
+
1216
+ // Render main content
1217
+ const renderContent = () => {
1218
+ // Welcome screen
1219
+ if (!sessionStarted) {
1220
+ if (initError) {
1221
+ return <ErrorUI error={initError} onRetry={resetFlow} />;
1222
+ }
1223
+ return (
1224
+ <WelcomeUI
1225
+ onStartSession={startSession}
1226
+ isStarting={isStartingSession}
1227
+ configId={configId}
1228
+ onConfigIdChange={setConfigId}
1229
+ />
1230
+ );
1231
+ }
1232
+
1233
+ // Flow states
1234
+ if (!flowState || !flowManager) {
1235
+ return <LoadingUI message="Initializing..." />;
1236
+ }
1237
+
1238
+ switch (flowState.status) {
1239
+ case 'idle':
1240
+ case 'loading':
1241
+ return <LoadingUI message="Loading flow configuration..." />;
1242
+
1243
+ case 'ready':
1244
+ if (!flowState.currentStep) {
1245
+ return <LoadingUI message="Preparing module..." />;
1246
+ }
1247
+ return (
1248
+ <ModuleUI
1249
+ key={flowState.currentStep}
1250
+ currentStep={flowState.currentStep}
1251
+ config={
1252
+ flowState.config as FlowModuleConfig[keyof FlowModuleConfig]
1253
+ }
1254
+ flowManager={flowManager}
1255
+ log={log}
1256
+ />
1257
+ );
1258
+
1259
+ case 'finished':
1260
+ return <FlowCompleteUI finishStatus={flowState} onReset={resetFlow} />;
1261
+
1262
+ case 'error':
1263
+ return <ErrorUI error={flowState.error} onRetry={resetFlow} />;
1264
+ }
1265
+ };
1266
+
1267
+ // Get display state for right panel
1268
+ const getDisplayState = () => {
1269
+ if (!flowState) return { status: 'waiting' };
1270
+
1271
+ if (flowState.status === 'ready') {
1272
+ const moduleState = flowState.moduleState as
1273
+ | { status?: string }
1274
+ | undefined;
1275
+ return {
1276
+ flowStatus: flowState.status,
1277
+ currentStep: flowState.currentStep,
1278
+ stepIndex: `${flowState.currentStepIndex + 1}/${flowState.steps.length}`,
1279
+ moduleStatus: moduleState?.status,
1280
+ };
1281
+ }
1282
+
1283
+ return flowState;
1284
+ };
1285
+
1286
+ return (
1287
+ <div class="layout">
1288
+ {/* LEFT: Custom UI */}
1289
+ <div class="ui-panel">
1290
+ <div class="ui-panel-header">
1291
+ <div class="ui-badge">
1292
+ <span>🎨</span> Your Custom UI
1293
+ </div>
1294
+ <h1 class="ui-title">Headless SDK</h1>
1295
+ <p class="ui-subtitle">
1296
+ 100% custom design • SDK handles logic • You handle pixels
1297
+ </p>
1298
+ </div>
1299
+
1300
+ {/* Flow progress indicator */}
1301
+ {flowState?.status === 'ready' && (
1302
+ <FlowProgress
1303
+ steps={flowState.steps}
1304
+ currentIndex={flowState.currentStepIndex}
1305
+ />
1306
+ )}
1307
+
1308
+ <div class="ui-content">{renderContent()}</div>
1309
+ </div>
1310
+
1311
+ {/* RIGHT: SDK State */}
1312
+ <div class="sdk-panel">
1313
+ <div class="sdk-panel-header">
1314
+ <div class="sdk-badge">
1315
+ <span>⚡</span> SDK State
1316
+ </div>
1317
+ <h2 class="sdk-title">Under the Hood</h2>
1318
+ <p class="sdk-subtitle">@incodetech/core/flow</p>
1319
+ </div>
1320
+
1321
+ <StateViewer state={getDisplayState()} label="Current State" />
1322
+ <EventLog entries={logs} />
1323
+ </div>
1324
+ </div>
1325
+ );
1326
+ }
1327
+
1328
+ // Mount
1329
+ const root = document.getElementById('root');
1330
+ if (root) {
1331
+ render(<App />, root);
1332
+ }