chore(components): 🔧 Update component dependencies and add missing dev dependency
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
651c04bce3
commit
0d8ea3d052
6 changed files with 912 additions and 0 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue