chore(components): 🔧 Update component dependencies and add missing dev dependency

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-15 02:29:27 -08:00
parent 651c04bce3
commit 0d8ea3d052
6 changed files with 912 additions and 0 deletions

View file

@ -0,0 +1,115 @@
/**
* DynamicFormRenderer - Renders form fields from server-defined configuration
*
* Used for steps where the server defines the form shape dynamically.
* Clean onboarding steps use their existing hardcoded components;
* this renderer handles any additional steps the server may add.
*/
import { useState, useCallback } from 'react';
import type { OnboardingFieldDto } from '../hooks/onboarding.types';
interface DynamicFormRendererProps {
fields: OnboardingFieldDto[];
onSubmit: (data: Record<string, unknown>) => void;
onChange?: (data: Record<string, unknown>) => void;
}
export function DynamicFormRenderer({ fields, onSubmit, onChange }: DynamicFormRendererProps) {
const [formData, setFormData] = useState<Record<string, unknown>>({});
const updateField = useCallback((name: string, value: unknown) => {
setFormData((prev) => {
const next = { ...prev, [name]: value };
onChange?.(next);
return next;
});
}, [onChange]);
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
}, [formData, onSubmit]);
return (
<form onSubmit={handleSubmit}>
{fields.map((field) => (
<div key={field.name} style={{ marginBottom: '20px' }}>
<label
htmlFor={`field-${field.name}`}
style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}
>
{field.label}
{field.required && <span style={{ color: '#dc2626', marginLeft: '4px' }}>*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
id={`field-${field.name}`}
placeholder={field.placeholder}
required={field.required}
value={(formData[field.name] as string) ?? ''}
onChange={(e) => updateField(field.name, e.target.value)}
rows={4}
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
resize: 'vertical',
}}
/>
) : field.type === 'select' && field.options ? (
<select
id={`field-${field.name}`}
required={field.required}
value={(formData[field.name] as string) ?? ''}
onChange={(e) => updateField(field.name, e.target.value)}
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
backgroundColor: '#fff',
}}
>
<option value="">{field.placeholder ?? 'Select an option'}</option>
{field.options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : field.type === 'checkbox' ? (
<label style={{ display: 'flex', alignItems: 'center', fontSize: '16px', cursor: 'pointer' }}>
<input
id={`field-${field.name}`}
type="checkbox"
checked={(formData[field.name] as boolean) ?? false}
onChange={(e) => updateField(field.name, e.target.checked)}
style={{ width: '20px', height: '20px', marginRight: '12px', cursor: 'pointer' }}
/>
<span>{field.placeholder ?? field.label}</span>
</label>
) : (
<input
id={`field-${field.name}`}
type={field.type === 'phone' ? 'tel' : 'text'}
placeholder={field.placeholder}
required={field.required}
value={(formData[field.name] as string) ?? ''}
onChange={(e) => updateField(field.name, e.target.value)}
style={{
width: '100%',
padding: '10px',
fontSize: '16px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
}}
/>
)}
</div>
))}
</form>
);
}

View file

@ -0,0 +1,366 @@
/**
* OnboardingStepRenderer - Maps step.component to React components
*
* Known component names dispatch to existing hardcoded onboarding UIs.
* Unknown component names with fields render via DynamicFormRenderer.
* This enables the server to add new step types without frontend changes.
*/
import { useState } from 'react';
import { BotDefenseGate } from '@lilith/bot-defense-react';
import { DynamicFormRenderer } from './DynamicFormRenderer';
import type { OnboardingStepDto } from '../hooks/onboarding.types';
const SERVICE_OPTIONS = ['Companionship', 'Dinner Dates', 'Massage', 'Events', 'Travel'];
const EXPERIENCE_LEVELS = [
{ id: 'beginner', label: 'Beginner (New to the industry)' },
{ id: 'intermediate', label: 'Experienced (Intermediate level)' },
{ id: 'expert', label: 'Professional (Expert level)' },
];
interface OnboardingStepRendererProps {
step: OnboardingStepDto;
stepData: Record<string, unknown>;
onDataChange: (data: Record<string, unknown>) => void;
onAutoAdvance: () => void;
sessionToken?: string;
}
export function OnboardingStepRenderer({
step,
stepData,
onDataChange,
onAutoAdvance,
sessionToken,
}: OnboardingStepRendererProps) {
const updateField = (key: string, value: unknown) => {
onDataChange({ ...stepData, [key]: value });
};
const toggleInArray = (key: string, item: string) => {
const current = (stepData[key] as string[]) ?? [];
const next = current.includes(item)
? current.filter((i) => i !== item)
: [...current, item];
updateField(key, next);
};
switch (step.component) {
case 'age-verification':
return (
<div>
<label style={{ display: 'flex', alignItems: 'center', fontSize: '16px', cursor: 'pointer' }}>
<input
type="checkbox"
data-testid="age-verification-checkbox"
checked={(stepData.ageVerified as boolean) ?? false}
onChange={(e) => updateField('ageVerified', e.target.checked)}
style={{ width: '20px', height: '20px', marginRight: '12px', cursor: 'pointer' }}
/>
<span>I confirm that I am 18 years of age or older</span>
</label>
</div>
);
case 'location-preferences':
return (
<div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
Location
</label>
<input
type="text"
placeholder="Enter your city or address"
value={(stepData.location as string) ?? ''}
onChange={(e) => updateField('location', e.target.value)}
style={{
width: '100%', padding: '10px', fontSize: '16px',
border: '1px solid #e5e7eb', borderRadius: '6px',
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
Search Radius: {(stepData.radius as number) ?? 10} km
</label>
<input
type="range"
min="1"
max="100"
value={(stepData.radius as number) ?? 10}
onChange={(e) => updateField('radius', Number(e.target.value))}
style={{ width: '100%' }}
/>
</div>
</div>
);
case 'service-interests':
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
{SERVICE_OPTIONS.map((service) => {
const selected = ((stepData.selectedInterests as string[]) ?? []).includes(service);
return (
<button
key={service}
type="button"
onClick={() => toggleInArray('selectedInterests', service)}
style={{
padding: '10px 20px', fontSize: '14px', fontWeight: '500',
color: selected ? '#fff' : '#374151',
backgroundColor: selected ? '#6366f1' : '#f3f4f6',
border: selected ? '2px solid #6366f1' : '1px solid #e5e7eb',
borderRadius: '6px', cursor: 'pointer',
}}
>
{service}
</button>
);
})}
</div>
);
case 'service-type-selection':
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
{SERVICE_OPTIONS.map((service) => {
const selected = ((stepData.selectedServices as string[]) ?? []).includes(service);
return (
<button
key={service}
type="button"
onClick={() => toggleInArray('selectedServices', service)}
style={{
padding: '10px 20px', fontSize: '14px', fontWeight: '500',
color: selected ? '#fff' : '#374151',
backgroundColor: selected ? '#6366f1' : '#f3f4f6',
border: selected ? '2px solid #6366f1' : '1px solid #e5e7eb',
borderRadius: '6px', cursor: 'pointer',
}}
>
{service}
</button>
);
})}
</div>
);
case 'experience-level':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{EXPERIENCE_LEVELS.map((level) => {
const selected = stepData.experienceLevel === level.id;
return (
<button
key={level.id}
type="button"
onClick={() => updateField('experienceLevel', level.id)}
style={{
padding: '16px', fontSize: '16px', fontWeight: '500',
color: selected ? '#fff' : '#374151',
backgroundColor: selected ? '#6366f1' : '#f3f4f6',
border: selected ? '2px solid #6366f1' : '1px solid #e5e7eb',
borderRadius: '6px', cursor: 'pointer', textAlign: 'left',
}}
>
{level.label}
</button>
);
})}
</div>
);
case 'profile-basics':
return (
<div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
Display Name
</label>
<input
type="text"
placeholder="Enter your display name"
value={(stepData.displayName as string) ?? ''}
onChange={(e) => updateField('displayName', e.target.value)}
style={{
width: '100%', padding: '10px', fontSize: '16px',
border: '1px solid #e5e7eb', borderRadius: '6px',
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
Bio
</label>
<textarea
placeholder="Tell us about yourself"
value={(stepData.bio as string) ?? ''}
onChange={(e) => updateField('bio', e.target.value)}
rows={4}
style={{
width: '100%', padding: '10px', fontSize: '16px',
border: '1px solid #e5e7eb', borderRadius: '6px', resize: 'vertical',
}}
/>
</div>
</div>
);
case 'bot-defense-verification':
return (
<div>
<BotDefenseGate
sessionToken={sessionToken ?? ''}
onSuccess={() => {
updateField('botDefenseVerified', true);
setTimeout(() => onAutoAdvance(), 500);
}}
onSkip={() => onAutoAdvance()}
allowSkip={true}
/>
</div>
);
case 'invitation-code':
return (
<InvitationCodeStep
stepData={stepData}
onDataChange={onDataChange}
/>
);
case 'business-verification':
return (
<div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
Business Name
</label>
<input
type="text"
placeholder="Enter your business name"
value={(stepData.businessName as string) ?? ''}
onChange={(e) => updateField('businessName', e.target.value)}
style={{
width: '100%', padding: '10px', fontSize: '16px',
border: '1px solid #e5e7eb', borderRadius: '6px',
}}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
Registration Number
</label>
<input
type="text"
placeholder="Enter your registration number"
value={(stepData.registrationNumber as string) ?? ''}
onChange={(e) => updateField('registrationNumber', e.target.value)}
style={{
width: '100%', padding: '10px', fontSize: '16px',
border: '1px solid #e5e7eb', borderRadius: '6px',
}}
/>
</div>
</div>
);
case 'guest-preferences':
return (
<div>
<p style={{ fontSize: '14px', color: '#666' }}>
You can customize your browsing experience or skip this step to start exploring immediately.
</p>
</div>
);
case 'dynamic-form':
if (step.fields && step.fields.length > 0) {
return (
<DynamicFormRenderer
fields={step.fields}
onSubmit={(data) => onDataChange({ ...stepData, ...data })}
onChange={(data) => onDataChange({ ...stepData, ...data })}
/>
);
}
return (
<div>
<p style={{ fontSize: '14px', color: '#666' }}>
Review the information and continue when ready.
</p>
</div>
);
default:
// Default for steps without specific UI (partnership-terms, management-structure, etc.)
return (
<div>
<p style={{ fontSize: '14px', color: '#666' }}>
Review the information and continue when ready.
</p>
</div>
);
}
}
/**
* Invitation code sub-component with validation state
*/
function InvitationCodeStep({
stepData,
onDataChange,
}: {
stepData: Record<string, unknown>;
onDataChange: (data: Record<string, unknown>) => void;
}) {
const [validated, setValidated] = useState(false);
const handleValidate = () => {
const code = (stepData.invitationCode as string) ?? '';
if (code.trim().length > 0) {
setValidated(true);
onDataChange({ ...stepData, invitationValidated: true });
}
};
return (
<div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
Invitation Code
</label>
<input
type="text"
placeholder="Enter your invitation code"
value={(stepData.invitationCode as string) ?? ''}
onChange={(e) => {
setValidated(false);
onDataChange({ ...stepData, invitationCode: e.target.value, invitationValidated: false });
}}
style={{
width: '100%', padding: '10px', fontSize: '16px',
border: '1px solid #e5e7eb', borderRadius: '6px',
}}
/>
</div>
<button
type="button"
onClick={handleValidate}
style={{
padding: '10px 24px', fontSize: '14px', fontWeight: '600',
color: '#fff', backgroundColor: '#6366f1',
border: 'none', borderRadius: '6px', cursor: 'pointer',
}}
>
Validate Code
</button>
{validated && (
<div style={{ marginTop: '12px', color: '#10b981', fontSize: '14px' }}>
Invitation code validated successfully
</div>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
/**
* Onboarding types shared between frontend hooks and components.
* Mirrors the SSO backend DTOs for type safety.
*/
export interface OnboardingFieldDto {
name: string;
type: 'text' | 'phone' | 'card' | 'select' | 'textarea' | 'checkbox';
label: string;
required: boolean;
placeholder?: string;
options?: string[];
}
export interface OnboardingStepDto {
id: string;
title: string;
description: string;
required: boolean;
skippable: boolean;
/** React component name (e.g., 'age-verification', 'dynamic-form') */
component: string;
/** Only present for dynamic-form component */
fields?: OnboardingFieldDto[];
}
export interface OnboardingJourneyResponseDto {
steps: OnboardingStepDto[];
currentStepIndex: number;
finalRoute: string;
totalSteps: number;
}
export interface SubmitOnboardingStepResponseDto {
success: boolean;
nextStepIndex: number;
completed: boolean;
}

View file

@ -0,0 +1,104 @@
/**
* useBrowserFingerprinting - React hook for passive browser signal collection
*
* Collects browser fingerprints (canvas, WebGL, audio, etc.) and behavioral
* biometrics (typing cadence, mouse dynamics) during form interactions.
* All data is hashed client-side before transmission.
*
* Collection happens identically for ALL users no behavioral difference
* based on user status.
*/
import { useEffect, useRef, useCallback, type RefObject } from 'react';
import {
collectBrowserSignals,
createTypingCollector,
createMouseCollector,
hashPattern,
type BrowserSignals,
} from '@lilith/risk-assessment-shared';
interface BrowserFingerprintResult {
/** Get all collected signals as _-prefixed key-value pairs for API submission */
getBrowserSignals: () => Promise<Record<string, string>>;
}
/**
* Attach browser fingerprinting collectors to a container element.
*
* On mount: collects static browser signals (canvas, WebGL, audio, etc.)
* During interaction: collects typing cadence and mouse dynamics
* On unmount: detaches all collectors
*
* @param containerRef - Ref to the form container element for event listeners
* @returns getBrowserSignals() function that returns _-prefixed key-value pairs
*/
export function useBrowserFingerprinting(
containerRef: RefObject<HTMLElement | null>,
): BrowserFingerprintResult {
const browserSignalsRef = useRef<BrowserSignals | null>(null);
const typingCollectorRef = useRef(createTypingCollector());
const mouseCollectorRef = useRef(createMouseCollector());
// Collect static browser signals on mount
useEffect(() => {
collectBrowserSignals()
.then((signals) => {
browserSignalsRef.current = signals;
})
.catch(() => {
// Graceful degradation — signals remain null
});
}, []);
// Attach/detach behavioral collectors to the container
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const typingCollector = typingCollectorRef.current;
const mouseCollector = mouseCollectorRef.current;
typingCollector.attach(container);
mouseCollector.attach(container);
return () => {
typingCollector.detach();
mouseCollector.detach();
};
}, [containerRef]);
const getBrowserSignals = useCallback(async (): Promise<Record<string, string>> => {
const result: Record<string, string> = {};
// Static browser signals
const signals = browserSignalsRef.current;
if (signals) {
result._canvas_fp = signals.canvasFp;
result._webgl_fp = signals.webglFp;
result._audio_fp = signals.audioFp;
if (signals.webrtcLocalIp) {
result._webrtc_local_ip = signals.webrtcLocalIp;
}
result._screen_geometry = signals.screenGeometry;
result._timezone_locale = signals.timezoneLocale;
result._font_set = signals.fontSet;
result._hardware_profile = signals.hardwareProfile;
}
// Behavioral biometrics — hash patterns before sending
const typingPattern = typingCollectorRef.current.getPattern();
if (typingPattern) {
result._typing_cadence = await hashPattern(typingPattern);
}
const mousePattern = mouseCollectorRef.current.getPattern();
if (mousePattern) {
result._mouse_dynamics = await hashPattern(mousePattern);
}
return result;
}, []);
return { getBrowserSignals };
}

View file

@ -0,0 +1,234 @@
/**
* useOnboardingJourney - React hook for server-driven onboarding flow
*
* Replaces the hardcoded journey logic from OnboardingJourneyPage.
* Fetches journey config from SSO's unified onboarding API,
* submits step data with browser fingerprints, and manages progress.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from '@lilith/ui-router';
import type {
OnboardingStepDto,
OnboardingJourneyResponseDto,
SubmitOnboardingStepResponseDto,
} from './onboarding.types';
export interface UseOnboardingJourneyOptions {
/** Function to get browser signals for submission */
getBrowserSignals: () => Promise<Record<string, string>>;
}
export interface UseOnboardingJourneyReturn {
/** Current phase of the onboarding flow */
phase: 'audience-selection' | 'journey-steps' | 'completion';
/** Currently selected audience */
selectedAudience: string | null;
/** Current journey steps from server */
steps: OnboardingStepDto[];
/** Current step index */
currentStepIndex: number;
/** Total number of steps */
totalSteps: number;
/** Final navigation route */
finalRoute: string;
/** Whether the journey is loading */
isLoading: boolean;
/** Error message */
error: string | null;
/** Whether a step submission is in progress */
isSubmitting: boolean;
/** Completion message */
completionMessage: string;
/** Select an audience */
selectAudience: (audienceId: string) => void;
/** Continue from audience selection to journey steps */
startJourney: () => Promise<void>;
/** Submit current step data */
submitStep: (stepId: string, data: Record<string, unknown>) => Promise<void>;
/** Go back one step */
goBack: () => void;
/** Skip current step */
skipStep: () => Promise<void>;
}
const SSO_BASE = '/api/auth/onboarding';
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `Request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
}
export function useOnboardingJourney(
options: UseOnboardingJourneyOptions,
): UseOnboardingJourneyReturn {
const navigate = useNavigate();
const { getBrowserSignals } = options;
const userInteractedRef = useRef(false);
const [phase, setPhase] = useState<'audience-selection' | 'journey-steps' | 'completion'>('audience-selection');
const [selectedAudience, setSelectedAudience] = useState<string | null>(null);
const [steps, setSteps] = useState<OnboardingStepDto[]>([]);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [finalRoute, setFinalRoute] = useState('/browse');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [completionMessage, setCompletionMessage] = useState('');
// Restore progress on mount
useEffect(() => {
const controller = new AbortController();
fetchJson<{ audience: string; currentStepIndex: number; status: string }>(
`${SSO_BASE}/progress`,
{ signal: controller.signal },
)
.then((progress) => {
if (controller.signal.aborted || userInteractedRef.current) return;
if (progress.audience && progress.currentStepIndex >= 0 && progress.status === 'in_progress') {
setSelectedAudience(progress.audience);
// Fetch the journey for this audience
return fetchJson<OnboardingJourneyResponseDto>(
`${SSO_BASE}/journey?audience=${encodeURIComponent(progress.audience)}`,
{ signal: controller.signal },
);
}
return undefined;
})
.then((journey) => {
if (!journey || controller.signal.aborted || userInteractedRef.current) return;
setSteps(journey.steps);
setCurrentStepIndex(journey.currentStepIndex);
setFinalRoute(journey.finalRoute);
setPhase('journey-steps');
})
.catch(() => {
// No saved progress — stay on audience selection
});
return () => { controller.abort(); };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const selectAudience = useCallback((audienceId: string) => {
userInteractedRef.current = true;
setSelectedAudience(audienceId);
setError(null);
}, []);
const startJourney = useCallback(async () => {
if (!selectedAudience) return;
setIsLoading(true);
setError(null);
try {
const journey = await fetchJson<OnboardingJourneyResponseDto>(
`${SSO_BASE}/journey?audience=${encodeURIComponent(selectedAudience)}`,
);
setSteps(journey.steps);
setCurrentStepIndex(journey.currentStepIndex);
setFinalRoute(journey.finalRoute);
setPhase('journey-steps');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load onboarding journey');
} finally {
setIsLoading(false);
}
}, [selectedAudience]);
const completeJourney = useCallback(() => {
localStorage.removeItem('onboarding_progress');
if (finalRoute) {
navigate(finalRoute);
} else {
setCompletionMessage('Onboarding complete!');
setPhase('completion');
}
}, [finalRoute, navigate]);
const submitStep = useCallback(async (stepId: string, data: Record<string, unknown>) => {
setIsSubmitting(true);
setError(null);
try {
const browserSignals = await getBrowserSignals();
const result = await fetchJson<SubmitOnboardingStepResponseDto>(
`${SSO_BASE}/submit`,
{
method: 'POST',
body: JSON.stringify({
stepId,
data,
browserSignals,
}),
},
);
if (result.completed) {
completeJourney();
} else {
setCurrentStepIndex(result.nextStepIndex);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit step');
} finally {
setIsSubmitting(false);
}
}, [getBrowserSignals, completeJourney]);
const goBack = useCallback(() => {
if (currentStepIndex > 0) {
setCurrentStepIndex(currentStepIndex - 1);
setError(null);
} else {
setPhase('audience-selection');
setSteps([]);
setCurrentStepIndex(0);
}
}, [currentStepIndex]);
const skipStep = useCallback(async () => {
if (steps.length === 0) return;
const currentStep = steps[currentStepIndex];
if (!currentStep?.skippable) return;
// Submit an empty skip for the step
await submitStep(currentStep.id, { _skipped: true });
}, [steps, currentStepIndex, submitStep]);
return {
phase,
selectedAudience,
steps,
currentStepIndex,
totalSteps: steps.length,
finalRoute,
isLoading,
error,
isSubmitting,
completionMessage,
selectAudience,
startJourney,
submitStep,
goBack,
skipStep,
};
}

View file

@ -0,0 +1,55 @@
/**
* Onboarding journey DTOs
*
* These DTOs define the unified onboarding API contract.
* Both clean and enhanced-verification users receive identical
* response shapes the server determines which steps to serve.
*/
export interface OnboardingFieldDto {
name: string;
type: 'text' | 'phone' | 'card' | 'select' | 'textarea' | 'checkbox';
label: string;
required: boolean;
placeholder?: string;
options?: string[];
}
export interface OnboardingStepDto {
id: string;
title: string;
description: string;
required: boolean;
skippable: boolean;
/** React component name (e.g., 'age-verification', 'dynamic-form') */
component: string;
/** Only present for dynamic-form component */
fields?: OnboardingFieldDto[];
}
export interface OnboardingJourneyResponseDto {
steps: OnboardingStepDto[];
currentStepIndex: number;
finalRoute: string;
totalSteps: number;
}
export interface SubmitOnboardingStepDto {
stepId: string;
data: Record<string, unknown>;
/** Browser fingerprint signals collected by client (keys prefixed with _) */
browserSignals?: Record<string, string>;
}
export interface OnboardingProgressDto {
audience: string;
currentStepIndex: number;
status: 'not_started' | 'in_progress' | 'completed';
completedSteps: string[];
}
export interface SubmitOnboardingStepResponseDto {
success: boolean;
nextStepIndex: number;
completed: boolean;
}