feat(onboarding-builder): add tabbed flow builder with library and preview
This commit is contained in:
parent
54cd1a156c
commit
0cff46ec82
3 changed files with 502 additions and 463 deletions
339
src/components/admin/OnboardingFlowBuilder.tsx
Normal file
339
src/components/admin/OnboardingFlowBuilder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
description?: string;
|
||||||
helperText?: string;
|
finalSubmissionMessage?: string;
|
||||||
options?: { label: string; value: string }[];
|
steps?: OnboardingStep[];
|
||||||
|
version?: number;
|
||||||
|
};
|
||||||
|
is_active?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OnboardingStep = {
|
async function loadSchema(schemaId: string): Promise<OnboardingSchemaPayload | null> {
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
fields: OnboardingField[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeId = (prefix: string) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
|
|
||||||
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>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue