feat(onboarding-builder): add tabbed flow builder with library and preview

This commit is contained in:
Ashwin Kumar 2026-03-19 21:05:06 +01:00
parent 54cd1a156c
commit 0cff46ec82
3 changed files with 502 additions and 463 deletions

View file

@ -0,0 +1,339 @@
import { createMemo, createSignal, For, Show } from 'solid-js';
export type OnboardingFieldType = 'text' | 'textarea' | 'number' | 'email' | 'tel' | 'date' | 'select' | 'url' | 'file' | 'checkbox';
export type OnboardingField = {
id: string;
label: string;
type: OnboardingFieldType;
required: boolean;
placeholder?: string;
options?: { label: string; value: string }[];
};
export type OnboardingStep = {
id: string;
title: string;
description?: string;
fields: OnboardingField[];
};
type QuestionCategory = 'business' | 'documents' | 'experience' | 'contact' | 'profile';
type OnboardingQuestion = {
key: string;
label: string;
type: OnboardingFieldType;
required: boolean;
category: QuestionCategory;
options?: { label: string; value: string }[];
};
type Props = {
title: string;
roleKey: string;
description: string;
finalSubmissionMessage: string;
stepCount: number;
selectedFields: OnboardingField[];
saving?: boolean;
error?: string;
primaryLabel: string;
onChange: (next: {
title?: string;
roleKey?: string;
description?: string;
finalSubmissionMessage?: string;
stepCount?: number;
selectedFields?: OnboardingField[];
}) => void;
onSubmit: () => void;
};
const ROLE_OPTIONS = ['company', 'job_seeker', 'customer', 'photographer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services', 'makeup_artist', 'tutor', 'developer'];
const DEFAULT_LIBRARY: OnboardingQuestion[] = [
{ key: 'full_name', label: 'Full Name', type: 'text', required: true, category: 'profile' },
{ key: 'email', label: 'Email', type: 'email', required: true, category: 'contact' },
{ key: 'phone', label: 'Phone', type: 'tel', required: true, category: 'contact' },
{ key: 'city', label: 'City', type: 'text', required: true, category: 'contact' },
{ key: 'experience_years', label: 'Experience (Years)', type: 'number', required: false, category: 'experience' },
{ key: 'portfolio_url', label: 'Portfolio URL', type: 'url', required: false, category: 'experience' },
{ key: 'gst_number', label: 'GST Number', type: 'text', required: false, category: 'business' },
{ key: 'pan_number', label: 'PAN Number', type: 'text', required: false, category: 'business' },
{ key: 'identity_document', label: 'Identity Document', type: 'file', required: true, category: 'documents' },
{ key: 'address_proof', label: 'Address Proof', type: 'file', required: true, category: 'documents' },
];
const FIELD_TYPES: OnboardingFieldType[] = ['text', 'textarea', 'number', 'select', 'file', 'email', 'tel', 'date', 'url', 'checkbox'];
function makeId(prefix: string): string {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function toSlug(input: string): string {
return input.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
function createFieldFromQuestion(question: OnboardingQuestion): OnboardingField {
return {
id: question.key || makeId('fld'),
label: question.label,
type: question.type,
required: question.required,
placeholder: question.type === 'file' ? 'Upload file' : `Enter ${question.label}`,
options: question.options,
};
}
function categoryLabel(category: QuestionCategory): string {
if (category === 'business') return 'Business';
if (category === 'documents') return 'Documents';
if (category === 'experience') return 'Experience';
if (category === 'contact') return 'Contact';
return 'Profile';
}
export function inferStepCount(steps: OnboardingStep[]): number {
return Math.max(1, steps.length || 1);
}
export function buildStepsFromFields(selectedFields: OnboardingField[], stepCount: number): OnboardingStep[] {
const safeStepCount = Math.max(1, stepCount || 1);
const buckets: OnboardingField[][] = Array.from({ length: safeStepCount }, () => []);
selectedFields.forEach((field, index) => {
buckets[index % safeStepCount].push(field);
});
return buckets.map((fields, index) => ({
id: `step_${index + 1}`,
title: `Step ${index + 1}`,
description: '',
fields,
}));
}
export function createDefaultFields(roleKey: string): OnboardingField[] {
if (roleKey === 'company') {
return DEFAULT_LIBRARY.filter((q) => ['full_name', 'email', 'phone', 'gst_number', 'pan_number', 'identity_document'].includes(q.key)).map(createFieldFromQuestion);
}
if (roleKey === 'photographer') {
return DEFAULT_LIBRARY.filter((q) => ['full_name', 'email', 'phone', 'city', 'experience_years', 'portfolio_url', 'identity_document'].includes(q.key)).map(createFieldFromQuestion);
}
return DEFAULT_LIBRARY.filter((q) => ['full_name', 'email', 'phone', 'city', 'identity_document'].includes(q.key)).map(createFieldFromQuestion);
}
export default function OnboardingFlowBuilder(props: Props) {
const [activeTab, setActiveTab] = createSignal<'overview' | 'library' | 'selected' | 'preview'>('overview');
const [customLabel, setCustomLabel] = createSignal('');
const [customType, setCustomType] = createSignal<OnboardingFieldType>('text');
const [customRequired, setCustomRequired] = createSignal(true);
const [customOptions, setCustomOptions] = createSignal('');
const groupedQuestions = createMemo(() => {
const groups = new Map<QuestionCategory, OnboardingQuestion[]>();
DEFAULT_LIBRARY.forEach((question) => {
if (!groups.has(question.category)) groups.set(question.category, []);
groups.get(question.category)!.push(question);
});
return Array.from(groups.entries());
});
const selectedKeySet = createMemo(() => new Set(props.selectedFields.map((field) => field.id)));
const previewSteps = createMemo(() => buildStepsFromFields(props.selectedFields, props.stepCount));
const toggleQuestion = (question: OnboardingQuestion) => {
const checked = selectedKeySet().has(question.key);
if (checked) {
props.onChange({ selectedFields: props.selectedFields.filter((field) => field.id !== question.key) });
return;
}
props.onChange({ selectedFields: [...props.selectedFields, createFieldFromQuestion(question)] });
};
const updateField = (id: string, patch: Partial<OnboardingField>) => {
props.onChange({
selectedFields: props.selectedFields.map((field) => (field.id === id ? { ...field, ...patch } : field)),
});
};
const removeField = (id: string) => {
props.onChange({ selectedFields: props.selectedFields.filter((field) => field.id !== id) });
};
const addCustomField = () => {
const label = customLabel().trim();
if (!label) return;
const key = toSlug(label);
if (!key || selectedKeySet().has(key)) return;
const field: OnboardingField = {
id: key,
label,
type: customType(),
required: customRequired(),
options: customType() === 'select'
? customOptions().split(',').map((item) => item.trim()).filter(Boolean).map((value) => ({ label: value, value }))
: undefined,
placeholder: customType() === 'file' ? 'Upload file' : `Enter ${label}`,
};
props.onChange({ selectedFields: [...props.selectedFields, field] });
setCustomLabel('');
setCustomType('text');
setCustomRequired(true);
setCustomOptions('');
};
return (
<section class="card">
<h2 style="margin-bottom:4px">Flow Builder</h2>
<p class="notice" style="margin:0 0 10px">
Choose the role, number of steps, questions, and final submission message.
</p>
<Show when={props.error}>
<div class="error-box">{props.error}</div>
</Show>
<div class="admin-segmented">
<button class={`admin-segment ${activeTab() === 'overview' ? 'active' : ''}`} onClick={() => setActiveTab('overview')}>Overview</button>
<button class={`admin-segment ${activeTab() === 'library' ? 'active' : ''}`} onClick={() => setActiveTab('library')}>Question Library</button>
<button class={`admin-segment ${activeTab() === 'selected' ? 'active' : ''}`} onClick={() => setActiveTab('selected')}>Selected Questions</button>
<button class={`admin-segment ${activeTab() === 'preview' ? 'active' : ''}`} onClick={() => setActiveTab('preview')}>Preview</button>
</div>
<Show when={activeTab() === 'overview'}>
<div class="field-grid-2">
<div class="field">
<label>Flow title</label>
<input value={props.title} onInput={(event) => props.onChange({ title: event.currentTarget.value })} />
</div>
<div class="field">
<label>External role</label>
<select value={props.roleKey} onChange={(event) => props.onChange({ roleKey: event.currentTarget.value })}>
<option value="">Select a role</option>
<For each={ROLE_OPTIONS}>{(option) => <option value={option}>{option.replace(/_/g, ' ')}</option>}</For>
</select>
</div>
<div class="field">
<label>Flow description</label>
<textarea rows="3" value={props.description} onInput={(event) => props.onChange({ description: event.currentTarget.value })} />
</div>
<div class="field">
<label>Number of steps</label>
<input type="number" min={1} max={8} value={props.stepCount} onInput={(event) => props.onChange({ stepCount: Number(event.currentTarget.value) || 1 })} />
</div>
<div class="field" style="grid-column:1 / -1">
<label>Final submission message</label>
<textarea rows="3" value={props.finalSubmissionMessage} onInput={(event) => props.onChange({ finalSubmissionMessage: event.currentTarget.value })} />
</div>
</div>
</Show>
<Show when={activeTab() === 'library'}>
<div class="space-y-3">
<For each={groupedQuestions()}>
{([category, questions]) => (
<section class="sub-card">
<h4>{categoryLabel(category)}</h4>
<div class="field-grid-2">
<For each={questions}>
{(question) => {
const checked = () => selectedKeySet().has(question.key);
return (
<label class="nested-card" style={checked() ? 'border-color:#fd6216;background:#fff7ed' : ''}>
<div style="display:flex;gap:10px">
<input type="checkbox" checked={checked()} onChange={() => toggleQuestion(question)} />
<div>
<p style="margin:0;font-weight:700">{question.label}</p>
<p class="notice" style="margin-top:2px">{question.type}{question.required ? ' • required' : ' • optional'}</p>
</div>
</div>
</label>
);
}}
</For>
</div>
</section>
)}
</For>
<section class="sub-card">
<h4>Add Custom Question</h4>
<div class="field-grid-2">
<input value={customLabel()} onInput={(event) => setCustomLabel(event.currentTarget.value)} placeholder="Question label" />
<select value={customType()} onChange={(event) => setCustomType(event.currentTarget.value as OnboardingFieldType)}>
<For each={FIELD_TYPES}>{(type) => <option value={type}>{type}</option>}</For>
</select>
<label class="checkbox-label"><input type="checkbox" checked={customRequired()} onChange={(event) => setCustomRequired(event.currentTarget.checked)} />Required</label>
<Show when={customType() === 'select'} fallback={<p class="notice">No extra options needed</p>}>
<input value={customOptions()} onInput={(event) => setCustomOptions(event.currentTarget.value)} placeholder="Option 1, Option 2" />
</Show>
</div>
<div class="actions"><button class="btn navy" type="button" onClick={addCustomField}>Add Custom Question</button></div>
</section>
</div>
</Show>
<Show when={activeTab() === 'selected'}>
<div class="space-y-3">
<Show when={props.selectedFields.length === 0}>
<p class="notice">No questions selected yet.</p>
</Show>
<For each={props.selectedFields}>
{(field) => (
<div class="nested-card">
<div class="field-grid-2">
<input value={field.label} onInput={(event) => updateField(field.id, { label: event.currentTarget.value })} />
<select value={field.type} onChange={(event) => updateField(field.id, { type: event.currentTarget.value as OnboardingFieldType })}>
<For each={FIELD_TYPES}>{(type) => <option value={type}>{type}</option>}</For>
</select>
<label class="checkbox-label"><input type="checkbox" checked={field.required} onChange={(event) => updateField(field.id, { required: event.currentTarget.checked })} />Required</label>
<button class="btn danger" type="button" onClick={() => removeField(field.id)}>Remove Question</button>
</div>
<Show when={field.type === 'select'}>
<input
style="margin-top:8px"
value={(field.options || []).map((item) => item.value).join(', ')}
onInput={(event) => updateField(field.id, {
options: event.currentTarget.value.split(',').map((item) => item.trim()).filter(Boolean).map((value) => ({ label: value, value })),
})}
placeholder="Option 1, Option 2"
/>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={activeTab() === 'preview'}>
<div class="space-y-3">
<p class="notice">Preview uses the same question and step distribution before saving.</p>
<For each={previewSteps()}>
{(step, index) => (
<section class="sub-card">
<h4 style="margin-bottom:6px">Step {index() + 1}</h4>
<Show when={step.fields.length === 0}>
<p class="notice">No fields in this step.</p>
</Show>
<For each={step.fields}>
{(field) => (
<div class="kv-item" style="margin-bottom:8px">
<p class="kv-label">{field.label}{field.required ? ' *' : ''}</p>
<p class="kv-value">{field.type}</p>
</div>
)}
</For>
</section>
)}
</For>
</div>
</Show>
<div class="actions" style="justify-content:flex-end">
<button class="btn primary" type="button" disabled={props.saving} onClick={props.onSubmit}>
{props.saving ? 'Saving...' : props.primaryLabel}
</button>
</div>
</section>
);
}

View file

@ -1,35 +1,30 @@
import { A, useNavigate, useParams } from '@solidjs/router'; import { A, useParams } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, For, Show } from 'solid-js'; import { createEffect, createResource, createSignal, Show } from 'solid-js';
import AdminShell from '~/components/AdminShell'; import AdminShell from '~/components/AdminShell';
import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs'; import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs';
import OnboardingFlowBuilder, {
buildStepsFromFields,
inferStepCount,
type OnboardingField,
type OnboardingStep,
} from '~/components/admin/OnboardingFlowBuilder';
const API = '/api/gateway'; const API = '/api/gateway';
const EXTERNAL_ROLES = ['company', 'job_seeker', 'customer', 'photographer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services', 'makeup_artist', 'tutor', 'developer']; type OnboardingSchemaPayload = {
const FIELD_TYPES = ['text', 'textarea', 'number', 'email', 'tel', 'date', 'select', 'url', 'file', 'checkbox'] as const;
type FieldType = typeof FIELD_TYPES[number];
type OnboardingField = {
id: string; id: string;
label: string; schema_json?: {
type: FieldType; title?: string;
required: boolean; roleKey?: string;
placeholder?: string;
helperText?: string;
options?: { label: string; value: string }[];
};
type OnboardingStep = {
id: string;
title: string;
description?: string; description?: string;
fields: OnboardingField[]; finalSubmissionMessage?: string;
steps?: OnboardingStep[];
version?: number;
};
is_active?: boolean;
}; };
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`; async function loadSchema(schemaId: string): Promise<OnboardingSchemaPayload | null> {
async function loadSchema(schemaId: string) {
try { try {
const res = await fetch(`${API}/api/admin/onboarding-config/${schemaId}`); const res = await fetch(`${API}/api/admin/onboarding-config/${schemaId}`);
if (!res.ok) return null; if (!res.ok) return null;
@ -39,101 +34,82 @@ async function loadSchema(schemaId: string) {
} }
} }
export default function EditOnboardingFlowPage() { function flattenSteps(steps: OnboardingStep[]): OnboardingField[] {
return steps.flatMap((step) => step.fields || []);
}
export default function OnboardingSchemaDetailPage() {
const params = useParams(); const params = useParams();
const navigate = useNavigate(); const [schema] = createResource(() => params.schemaId, loadSchema);
const [data] = createResource(() => params.schemaId, loadSchema);
const [title, setTitle] = createSignal(''); const [title, setTitle] = createSignal('');
const [roleKey, setRoleKey] = createSignal('company'); const [roleKey, setRoleKey] = createSignal('');
const [description, setDescription] = createSignal(''); const [description, setDescription] = createSignal('');
const [finalMessage, setFinalMessage] = createSignal('Your onboarding has been submitted for review. We will notify you once it is approved.'); const [finalSubmissionMessage, setFinalSubmissionMessage] = createSignal('');
const [steps, setSteps] = createSignal<OnboardingStep[]>([]); const [stepCount, setStepCount] = createSignal(1);
const [selectedFields, setSelectedFields] = createSignal<OnboardingField[]>([]);
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [loaded, setLoaded] = createSignal(false); const [loaded, setLoaded] = createSignal(false);
// Pre-populate from loaded schema
createEffect(() => { createEffect(() => {
const d = data(); const next = schema();
if (!d || loaded()) return; if (!next || loaded()) return;
const schema = d.schema_json || d; const payload = next.schema_json || {};
setTitle(schema.title || ''); const steps = payload.steps || [];
setRoleKey(schema.roleKey || 'company'); setTitle(payload.title || '');
setDescription(schema.description || ''); setRoleKey(payload.roleKey || 'company');
setFinalMessage(schema.finalSubmissionMessage || 'Your onboarding has been submitted for review. We will notify you once it is approved.'); setDescription(payload.description || '');
// Ensure each step and field has an id setFinalSubmissionMessage(payload.finalSubmissionMessage || 'Your onboarding has been submitted for review. We will notify you once it is approved.');
const rawSteps: OnboardingStep[] = (schema.steps || []).map((s: any) => ({ setStepCount(inferStepCount(steps));
id: s.id || makeId('step'), setSelectedFields(flattenSteps(steps));
title: s.title || '',
description: s.description || '',
fields: (s.fields || []).map((f: any) => ({
id: f.id || makeId('fld'),
label: f.label || '',
type: f.type || 'text',
required: f.required || false,
placeholder: f.placeholder || '',
helperText: f.helperText || '',
options: f.options || [],
})),
}));
setSteps(rawSteps);
setLoaded(true); setLoaded(true);
}); });
const totalFields = createMemo(() => steps().reduce((sum, s) => sum + s.fields.length, 0)); const handleChange = (next: {
title?: string;
const updateStep = (stepId: string, patch: Partial<OnboardingStep>) => roleKey?: string;
setSteps((prev) => prev.map((s) => s.id === stepId ? { ...s, ...patch } : s)); description?: string;
finalSubmissionMessage?: string;
const addStep = () => setSteps((prev) => [...prev, { id: makeId('step'), title: `Step ${prev.length + 1}`, description: '', fields: [] }]); stepCount?: number;
selectedFields?: OnboardingField[];
const removeStep = (stepId: string) => setSteps((prev) => prev.filter((s) => s.id !== stepId)); }) => {
if (typeof next.title === 'string') setTitle(next.title);
const addField = (stepId: string) => { if (typeof next.roleKey === 'string') setRoleKey(next.roleKey);
const step = steps().find((s) => s.id === stepId)!; if (typeof next.description === 'string') setDescription(next.description);
updateStep(stepId, { if (typeof next.finalSubmissionMessage === 'string') setFinalSubmissionMessage(next.finalSubmissionMessage);
fields: [...step.fields, { id: makeId('fld'), label: 'New Field', type: 'text', required: false, placeholder: '' }], if (typeof next.stepCount === 'number') setStepCount(next.stepCount);
}); if (Array.isArray(next.selectedFields)) setSelectedFields(next.selectedFields);
}; };
const removeField = (stepId: string, fieldId: string) => { const persist = async (publishToggle = false) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, { fields: step.fields.filter((f) => f.id !== fieldId) });
};
const updateField = (stepId: string, fieldId: string, patch: Partial<OnboardingField>) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, { fields: step.fields.map((f) => f.id === fieldId ? { ...f, ...patch } : f) });
};
const handleSubmit = async (status: 'draft' | 'published') => {
if (!title().trim()) { setError('Flow title is required'); return; }
if (steps().length === 0) { setError('Add at least one step'); return; }
try { try {
setSaving(true); setSaving(true);
setError(''); setError('');
const payload = { const current = schema();
title: title().trim(), const currentlyActive = Boolean(current?.is_active);
roleKey: roleKey(), const nextActive = publishToggle ? !currentlyActive : currentlyActive;
description: description().trim(), const response = await fetch(`${API}/api/admin/onboarding-config/${params.schemaId}`, {
finalSubmissionMessage: finalMessage(),
version: (data()?.schema_json?.version || data()?.version || 1),
steps: steps(),
is_active: status === 'published',
};
const res = await fetch(`${API}/api/admin/onboarding-config/${params.schemaId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema_json: payload, is_active: status === 'published' }), body: JSON.stringify({
schema_json: {
title: title(),
roleKey: roleKey(),
description: description(),
finalSubmissionMessage: finalSubmissionMessage(),
version: current?.schema_json?.version || 1,
steps: buildStepsFromFields(selectedFields(), stepCount()),
},
is_active: nextActive,
}),
}); });
if (!res.ok) { const payload = await response.json();
const body = await res.json().catch(() => ({})); if (!response.ok) throw new Error(payload?.message || 'Failed to save onboarding flow');
throw new Error(body.message || 'Failed to update onboarding flow'); setLoaded(false);
} await schema.refetch();
navigate('/admin/onboarding-schemas'); } catch (nextError: any) {
} catch (err: any) { setError(nextError?.message || 'Failed to save onboarding flow');
setError(err.message || 'Failed to update onboarding flow');
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -146,166 +122,47 @@ export default function EditOnboardingFlowPage() {
<div class="page-hero-card page-actions"> <div class="page-hero-card page-actions">
<div> <div>
<h1 class="page-title">Onboarding Management</h1> <h1 class="page-title">Onboarding Management</h1>
<p class="page-subtitle">Open one flow at a time, then update role mapping, steps, fields, and final submission message.</p> <p class="page-subtitle">Open one onboarding flow at a time, review publishing state, then update role, questions, and final message.</p>
</div> </div>
<div class="actions" style="margin-top:0"> <div class="page-actions-right">
<button class="btn" type="button" disabled={saving()} onClick={() => handleSubmit(data()?.is_active ? 'draft' : 'published')}> <button class="btn" type="button" disabled={saving()} onClick={() => void persist(true)}>
{data()?.is_active ? 'Unpublish' : 'Publish'} {schema()?.is_active ? 'Unpublish' : 'Publish'}
</button> </button>
<A class="btn" href="/admin/onboarding-schemas">Back to Onboarding Management</A> <A class="btn" href="/admin/onboarding-schemas">Back to Onboarding Management</A>
</div> </div>
</div> </div>
<Show when={data.loading}> <Show when={schema.loading}>
<div class="card"><p class="notice">Loading onboarding flow...</p></div> <div class="card"><p class="notice">Loading onboarding flow...</p></div>
</Show> </Show>
<Show when={data.error}> <Show when={!schema.loading && !schema()}>
<div class="error-box">Failed to load onboarding flow. Check that the backend is running.</div> <div class="card"><p class="notice">Onboarding flow not found.</p></div>
</Show> </Show>
<Show when={error()}> <Show when={schema() && loaded()}>
<div class="error-box">{error()}</div> <>
</Show> <div class="field-grid-2" style="margin-bottom:16px">
<div class="card"><p class="kv-label">Status</p><p class="kv-value">{schema()?.is_active ? 'PUBLISHED' : 'DRAFT'}</p></div>
<Show when={!data.loading && loaded()}> <div class="card"><p class="kv-label">Version</p><p class="kv-value">{schema()?.schema_json?.version || 1}</p></div>
{/* Info stats */} <div class="card"><p class="kv-label">Steps</p><p class="kv-value">{stepCount()}</p></div>
<div class="onboarding-info-grid"> <div class="card"><p class="kv-label">Questions</p><p class="kv-value">{selectedFields().length}</p></div>
<div class="onboarding-stat">
<div class="stat-label">Role</div>
<div class="stat-value" style="font-size:14px;margin-top:6px">{roleKey().replace(/_/g, ' ').toUpperCase()}</div>
</div>
<div class="onboarding-stat">
<div class="stat-label">Steps</div>
<div class="stat-value">{steps().length}</div>
</div>
<div class="onboarding-stat">
<div class="stat-label">Total Fields</div>
<div class="stat-value">{totalFields()}</div>
</div>
</div> </div>
{/* Flow metadata */} <OnboardingFlowBuilder
<div class="role-form-section"> title={title()}
<h3>Flow Details</h3> roleKey={roleKey()}
<p>Update the flow title, target role, and final submission message.</p> description={description()}
<div class="field-grid-2"> finalSubmissionMessage={finalSubmissionMessage()}
<div class="field"> stepCount={stepCount()}
<label>Flow Title <span style="color:#ef4444">*</span></label> selectedFields={selectedFields()}
<input value={title()} onInput={(e) => setTitle(e.currentTarget.value)} placeholder="e.g. Photographer Onboarding" /> saving={saving()}
</div> error={error()}
<div class="field"> primaryLabel="Save Onboarding Flow"
<label>Target Role</label> onChange={handleChange}
<select value={roleKey()} onChange={(e) => setRoleKey(e.currentTarget.value)}> onSubmit={() => void persist(false)}
{EXTERNAL_ROLES.map((r) => <option value={r}>{r.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}</option>)}
</select>
</div>
<div class="field">
<label>Description</label>
<input value={description()} onInput={(e) => setDescription(e.currentTarget.value)} placeholder="Short description of this onboarding flow" />
</div>
<div class="field">
<label>Final Submission Message</label>
<textarea rows={2} value={finalMessage()} onInput={(e) => setFinalMessage(e.currentTarget.value)} />
</div>
</div>
</div>
{/* Steps */}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h2 style="margin:0;font-size:18px;font-weight:700">Steps & Fields</h2>
<button class="btn orange" onClick={addStep}>Add Step</button>
</div>
<For each={steps()}>
{(step, idx) => (
<div class="step-builder">
<div class="step-header">
<div class="step-num">{idx() + 1}</div>
<input
class="step-title-input"
value={step.title}
onInput={(e) => updateStep(step.id, { title: e.currentTarget.value })}
placeholder={`Step ${idx() + 1} title`}
/> />
<input </>
value={step.description || ''}
onInput={(e) => updateStep(step.id, { description: e.currentTarget.value })}
placeholder="Step description (optional)"
style="border:1px solid #cbd5e1;border-radius:12px;padding:8px 12px;font-size:13px;outline:none;flex:1"
/>
<button class="btn danger" style="font-size:12px;padding:6px 10px" onClick={() => removeStep(step.id)}>Remove Step</button>
</div>
{/* Fields */}
<div style="margin-top:4px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<p style="margin:0;font-size:13px;font-weight:600;color:#334155">{step.fields.length} field{step.fields.length !== 1 ? 's' : ''}</p>
<button class="btn orange" style="font-size:12px;padding:5px 10px" onClick={() => addField(step.id)}>Add Field</button>
</div>
<For each={step.fields}>
{(field) => (
<div class="field-row">
<div style="display:flex;flex-direction:column;gap:4px">
<input
value={field.label}
onInput={(e) => updateField(step.id, field.id, { label: e.currentTarget.value })}
placeholder="Field label"
style="width:100%"
/>
<input
value={field.placeholder || ''}
onInput={(e) => updateField(step.id, field.id, { placeholder: e.currentTarget.value })}
placeholder="Placeholder text"
style="width:100%;font-size:12px"
/>
</div>
<select
class="field-type-select"
value={field.type}
onChange={(e) => updateField(step.id, field.id, { type: e.currentTarget.value as FieldType })}
>
{FIELD_TYPES.map((t) => <option value={t}>{t}</option>)}
</select>
<label class="checkbox-label" style="justify-content:center">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(step.id, field.id, { required: e.currentTarget.checked })}
/>
Required
</label>
<button class="btn danger" style="font-size:12px;padding:5px 10px" onClick={() => removeField(step.id, field.id)}></button>
</div>
)}
</For>
<Show when={step.fields.length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:10px;padding:16px;text-align:center;font-size:13px;color:#94a3b8">
No fields in this step. Click "Add Field" to add the first field.
</div>
</Show>
</div>
</div>
)}
</For>
<Show when={steps().length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:12px;padding:32px;text-align:center;font-size:13px;color:#94a3b8">
No steps yet. Click "Add Step" above to get started.
</div>
</Show>
{/* Save actions */}
<div class="actions" style="justify-content:flex-end;margin-top:16px">
<button class="btn" onClick={() => handleSubmit('draft')} disabled={saving()}>
{saving() ? 'Saving...' : 'Save as Draft'}
</button>
<button class="btn primary" onClick={() => handleSubmit('published')} disabled={saving()}>
{saving() ? 'Publishing...' : 'Publish Flow'}
</button>
</div>
</Show> </Show>
</AdminShell> </AdminShell>
); );

View file

@ -1,102 +1,67 @@
import { A, useNavigate } from '@solidjs/router'; import { A, useNavigate } from '@solidjs/router';
import { createMemo, createSignal, For, Show } from 'solid-js'; import { createMemo, createSignal } from 'solid-js';
import AdminShell from '~/components/AdminShell'; import AdminShell from '~/components/AdminShell';
import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs'; import OnboardingManagementTabs from '~/components/admin/OnboardingManagementTabs';
import OnboardingFlowBuilder, {
buildStepsFromFields,
createDefaultFields,
type OnboardingField,
} from '~/components/admin/OnboardingFlowBuilder';
const API = '/api/gateway'; const API = '/api/gateway';
const EXTERNAL_ROLES = ['company', 'job_seeker', 'customer', 'photographer', 'video_editor', 'graphic_designer', 'social_media_manager', 'fitness_trainer', 'catering_services', 'makeup_artist', 'tutor', 'developer']; export default function NewOnboardingSchemaPage() {
const FIELD_TYPES = ['text', 'textarea', 'number', 'email', 'tel', 'date', 'select', 'url', 'file', 'checkbox'] as const;
type FieldType = typeof FIELD_TYPES[number];
type OnboardingField = {
id: string;
label: string;
type: FieldType;
required: boolean;
placeholder?: string;
helperText?: string;
options?: { label: string; value: string }[];
};
type OnboardingStep = {
id: string;
title: string;
description?: string;
fields: OnboardingField[];
};
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
function makeDefaultStep(idx: number): OnboardingStep {
return { id: makeId('step'), title: `Step ${idx + 1}`, description: '', fields: [] };
}
export default function CreateOnboardingFlowPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [title, setTitle] = createSignal(''); const [title, setTitle] = createSignal('');
const [roleKey, setRoleKey] = createSignal('company'); const [roleKey, setRoleKey] = createSignal('company');
const [description, setDescription] = createSignal(''); const [description, setDescription] = createSignal('');
const [finalMessage, setFinalMessage] = createSignal('Your onboarding has been submitted for review. We will notify you once it is approved.'); const [finalSubmissionMessage, setFinalSubmissionMessage] = createSignal('Your onboarding has been submitted for review. We will notify you once it is approved.');
const [steps, setSteps] = createSignal<OnboardingStep[]>([makeDefaultStep(0), makeDefaultStep(1)]); const [stepCount, setStepCount] = createSignal(2);
const [selectedFields, setSelectedFields] = createSignal<OnboardingField[]>(createDefaultFields('company'));
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const totalFields = createMemo(() => steps().reduce((sum, s) => sum + s.fields.length, 0)); const payload = createMemo(() => ({
title: title(),
roleKey: roleKey(),
description: description(),
finalSubmissionMessage: finalSubmissionMessage(),
steps: buildStepsFromFields(selectedFields(), stepCount()),
}));
const updateStep = (stepId: string, patch: Partial<OnboardingStep>) => const handleChange = (next: {
setSteps((prev) => prev.map((s) => s.id === stepId ? { ...s, ...patch } : s)); title?: string;
roleKey?: string;
const addStep = () => setSteps((prev) => [...prev, makeDefaultStep(prev.length)]); description?: string;
finalSubmissionMessage?: string;
const removeStep = (stepId: string) => setSteps((prev) => prev.filter((s) => s.id !== stepId)); stepCount?: number;
selectedFields?: OnboardingField[];
const addField = (stepId: string) => { }) => {
const step = steps().find((s) => s.id === stepId)!; if (typeof next.title === 'string') setTitle(next.title);
updateStep(stepId, { if (typeof next.description === 'string') setDescription(next.description);
fields: [...step.fields, { id: makeId('fld'), label: 'New Field', type: 'text', required: false, placeholder: '' }], if (typeof next.finalSubmissionMessage === 'string') setFinalSubmissionMessage(next.finalSubmissionMessage);
}); if (typeof next.stepCount === 'number') setStepCount(next.stepCount);
if (Array.isArray(next.selectedFields)) setSelectedFields(next.selectedFields);
if (typeof next.roleKey === 'string') {
setRoleKey(next.roleKey);
setSelectedFields(createDefaultFields(next.roleKey));
}
}; };
const removeField = (stepId: string, fieldId: string) => { const handleSubmit = async () => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, { fields: step.fields.filter((f) => f.id !== fieldId) });
};
const updateField = (stepId: string, fieldId: string, patch: Partial<OnboardingField>) => {
const step = steps().find((s) => s.id === stepId)!;
updateStep(stepId, { fields: step.fields.map((f) => f.id === fieldId ? { ...f, ...patch } : f) });
};
const handleSubmit = async (status: 'draft' | 'published') => {
if (!title().trim()) { setError('Flow title is required'); return; }
if (steps().length === 0) { setError('Add at least one step'); return; }
try { try {
setSaving(true); setSaving(true);
setError(''); setError('');
const payload = { const response = await fetch(`${API}/api/admin/onboarding-config`, {
title: title().trim(),
roleKey: roleKey(),
description: description().trim(),
finalSubmissionMessage: finalMessage(),
version: 1,
steps: steps(),
is_active: status === 'published',
};
const res = await fetch(`${API}/api/admin/onboarding-config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema_json: payload, is_active: status === 'published' }), body: JSON.stringify({ schema_json: payload(), is_active: false }),
}); });
if (!res.ok) { const body = await response.json();
const body = await res.json().catch(() => ({})); if (!response.ok) throw new Error(body?.message || 'Failed to create onboarding flow');
throw new Error(body.message || 'Failed to create onboarding flow'); navigate(`/admin/onboarding-schemas/${body.id || body.schema?.id}`);
} } catch (nextError: any) {
navigate('/admin/onboarding-schemas'); setError(nextError?.message || 'Failed to create onboarding flow');
} catch (err: any) {
setError(err.message || 'Failed to create onboarding flow');
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -109,152 +74,30 @@ export default function CreateOnboardingFlowPage() {
<div class="page-hero-card page-actions"> <div class="page-hero-card page-actions">
<div> <div>
<h1 class="page-title">Create Onboarding Flow</h1> <h1 class="page-title">Create Onboarding Flow</h1>
<p class="page-subtitle">Build one onboarding flow at a time. Start with the role, choose questions, arrange steps, and finish with the final submission message.</p> <p class="page-subtitle">Build one onboarding flow at a time. Start with role, questions, and final submission message.</p>
</div> </div>
<A class="btn" href="/admin/onboarding-schemas">Back to Onboarding Management</A> <A class="btn" href="/admin/onboarding-schemas">Back to Onboarding Management</A>
</div> </div>
<Show when={error()}> <div class="field-grid-2" style="margin-bottom:16px">
<div class="error-box">{error()}</div> <div class="card"><p class="kv-label">Role</p><p class="kv-value">{roleKey().replace(/_/g, ' ').toUpperCase()}</p></div>
</Show> <div class="card"><p class="kv-label">Steps</p><p class="kv-value">{stepCount()}</p></div>
<div class="card" style="grid-column:1 / -1"><p class="kv-label">Questions</p><p class="kv-value">{selectedFields().length}</p></div>
{/* Info stats */}
<div class="onboarding-info-grid">
<div class="onboarding-stat">
<div class="stat-label">Role</div>
<div class="stat-value" style="font-size:14px;margin-top:6px">{roleKey().replace(/_/g, ' ').toUpperCase()}</div>
</div>
<div class="onboarding-stat">
<div class="stat-label">Steps</div>
<div class="stat-value">{steps().length}</div>
</div>
<div class="onboarding-stat">
<div class="stat-label">Total Fields</div>
<div class="stat-value">{totalFields()}</div>
</div>
</div> </div>
{/* Flow metadata */} <OnboardingFlowBuilder
<div class="role-form-section"> title={title()}
<h3>Flow Details</h3> roleKey={roleKey()}
<p>Configure the flow title, target role, and final submission message.</p> description={description()}
<div class="field-grid-2"> finalSubmissionMessage={finalSubmissionMessage()}
<div class="field"> stepCount={stepCount()}
<label>Flow Title <span style="color:#ef4444">*</span></label> selectedFields={selectedFields()}
<input value={title()} onInput={(e) => setTitle(e.currentTarget.value)} placeholder="e.g. Photographer Onboarding" /> saving={saving()}
</div> error={error()}
<div class="field"> primaryLabel="Create Onboarding Flow"
<label>Target Role</label> onChange={handleChange}
<select value={roleKey()} onChange={(e) => setRoleKey(e.currentTarget.value)}> onSubmit={handleSubmit}
{EXTERNAL_ROLES.map((r) => <option value={r}>{r.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}</option>)}
</select>
</div>
<div class="field">
<label>Description</label>
<input value={description()} onInput={(e) => setDescription(e.currentTarget.value)} placeholder="Short description of this onboarding flow" />
</div>
<div class="field">
<label>Final Submission Message</label>
<textarea rows={2} value={finalMessage()} onInput={(e) => setFinalMessage(e.currentTarget.value)} />
</div>
</div>
</div>
{/* Steps */}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h2 style="margin:0;font-size:18px;font-weight:700">Steps & Fields</h2>
<button class="btn orange" onClick={addStep}>Add Step</button>
</div>
<For each={steps()}>
{(step, idx) => (
<div class="step-builder">
<div class="step-header">
<div class="step-num">{idx() + 1}</div>
<input
class="step-title-input"
value={step.title}
onInput={(e) => updateStep(step.id, { title: e.currentTarget.value })}
placeholder={`Step ${idx() + 1} title`}
/> />
<input
value={step.description || ''}
onInput={(e) => updateStep(step.id, { description: e.currentTarget.value })}
placeholder="Step description (optional)"
style="border:1px solid #cbd5e1;border-radius:12px;padding:8px 12px;font-size:13px;outline:none;flex:1"
/>
<button class="btn danger" style="font-size:12px;padding:6px 10px" onClick={() => removeStep(step.id)}>Remove Step</button>
</div>
{/* Fields */}
<div style="margin-top:4px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<p style="margin:0;font-size:13px;font-weight:600;color:#334155">{step.fields.length} field{step.fields.length !== 1 ? 's' : ''}</p>
<button class="btn orange" style="font-size:12px;padding:5px 10px" onClick={() => addField(step.id)}>Add Field</button>
</div>
<For each={step.fields}>
{(field) => (
<div class="field-row">
<div style="display:flex;flex-direction:column;gap:4px">
<input
value={field.label}
onInput={(e) => updateField(step.id, field.id, { label: e.currentTarget.value })}
placeholder="Field label"
style="width:100%"
/>
<input
value={field.placeholder || ''}
onInput={(e) => updateField(step.id, field.id, { placeholder: e.currentTarget.value })}
placeholder="Placeholder text"
style="width:100%;font-size:12px"
/>
</div>
<select
class="field-type-select"
value={field.type}
onChange={(e) => updateField(step.id, field.id, { type: e.currentTarget.value as FieldType })}
>
{FIELD_TYPES.map((t) => <option value={t}>{t}</option>)}
</select>
<label class="checkbox-label" style="justify-content:center">
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(step.id, field.id, { required: e.currentTarget.checked })}
/>
Required
</label>
<button class="btn danger" style="font-size:12px;padding:5px 10px" onClick={() => removeField(step.id, field.id)}></button>
</div>
)}
</For>
<Show when={step.fields.length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:10px;padding:16px;text-align:center;font-size:13px;color:#94a3b8">
No fields in this step. Click "Add Field" to add the first field.
</div>
</Show>
</div>
</div>
)}
</For>
<Show when={steps().length === 0}>
<div style="border:1px dashed #cbd5e1;border-radius:12px;padding:32px;text-align:center;font-size:13px;color:#94a3b8">
No steps yet. Click "Add Step" above to get started.
</div>
</Show>
{/* Save actions */}
<div class="actions" style="justify-content:flex-end;margin-top:16px">
<button class="btn" onClick={() => handleSubmit('draft')} disabled={saving()}>
{saving() ? 'Saving...' : 'Save as Draft'}
</button>
<button class="btn primary" onClick={() => handleSubmit('published')} disabled={saving()}>
{saving() ? 'Publishing...' : 'Publish Flow'}
</button>
</div>
</AdminShell> </AdminShell>
); );
} }