feat(admin): make onboarding schema fully runtime-configurable
This commit is contained in:
parent
562ad4f8de
commit
9cf8af5311
4 changed files with 151 additions and 55 deletions
14
src/app.css
14
src/app.css
|
|
@ -229,3 +229,17 @@ body {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.json-input {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-note {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,46 @@ export type RuntimeDashboardConfig = {
|
||||||
widgets: Array<{ key: string; title: string; enabled: boolean }>;
|
widgets: Array<{ key: string; title: string; enabled: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RuntimeOnboardingFieldOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeOnboardingValidation = {
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
pattern?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeOnboardingField = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'textarea' | 'number' | 'email' | 'tel' | 'date' | 'select' | 'url' | 'file' | 'checkbox';
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
helperText?: string;
|
||||||
|
defaultValue?: string | number | boolean | string[];
|
||||||
|
readOnly?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
options?: RuntimeOnboardingFieldOption[];
|
||||||
|
accept?: string;
|
||||||
|
maxFiles?: number;
|
||||||
|
maxSizeMB?: number;
|
||||||
|
validation?: RuntimeOnboardingValidation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeOnboardingStep = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
fields: RuntimeOnboardingField[];
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeOnboardingConfig = {
|
export type RuntimeOnboardingConfig = {
|
||||||
schemaId: string;
|
schemaId: string;
|
||||||
roleKey: string;
|
roleKey: string;
|
||||||
version: number;
|
version: number;
|
||||||
steps: Array<{
|
steps: RuntimeOnboardingStep[];
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
fields: Array<{ id: string; label: string; type: string; required?: boolean }>;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,60 @@ import { useParams } from '@solidjs/router';
|
||||||
import { createSignal, onMount } from 'solid-js';
|
import { createSignal, onMount } from 'solid-js';
|
||||||
import AdminShell from '~/components/AdminShell';
|
import AdminShell from '~/components/AdminShell';
|
||||||
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
|
import { getRuntimeConfig, saveRuntimeConfig } from '~/lib/runtime/storage';
|
||||||
import type { RuntimeOnboardingConfig } from '~/lib/runtime/types';
|
import type { RuntimeOnboardingConfig, RuntimeOnboardingStep } from '~/lib/runtime/types';
|
||||||
|
|
||||||
|
function stringifySteps(steps: RuntimeOnboardingStep[]): string {
|
||||||
|
return JSON.stringify(steps, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
export default function EditOnboardingPage() {
|
export default function EditOnboardingPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [config, setConfig] = createSignal<RuntimeOnboardingConfig | null>(null);
|
const [config, setConfig] = createSignal<RuntimeOnboardingConfig | null>(null);
|
||||||
|
const [stepsJson, setStepsJson] = createSignal('[]');
|
||||||
const [statusMessage, setStatusMessage] = createSignal('');
|
const [statusMessage, setStatusMessage] = createSignal('');
|
||||||
|
const [stepsError, setStepsError] = createSignal('');
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const existing = getRuntimeConfig<RuntimeOnboardingConfig>('onboarding', params.schemaId);
|
const existing = getRuntimeConfig<RuntimeOnboardingConfig>('onboarding', params.schemaId);
|
||||||
setConfig(existing?.payload || null);
|
if (!existing?.payload) return;
|
||||||
|
setConfig(existing.payload);
|
||||||
|
setStepsJson(stringifySteps(existing.payload.steps));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncSteps = (raw: string) => {
|
||||||
|
setStepsJson(raw);
|
||||||
|
const current = config();
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as RuntimeOnboardingStep[];
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
setStepsError('Steps JSON must be an array.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfig({ ...current, steps: parsed });
|
||||||
|
setStepsError('');
|
||||||
|
} catch {
|
||||||
|
setStepsError('Invalid JSON. Fix syntax before saving.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const persist = (status: 'draft' | 'published') => {
|
const persist = (status: 'draft' | 'published') => {
|
||||||
const payload = config();
|
const payload = config();
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
if (!payload.schemaId.trim()) {
|
||||||
|
setStatusMessage('Schema ID is required before saving.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stepsError()) {
|
||||||
|
setStatusMessage('Fix steps JSON errors before saving.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.steps.length === 0) {
|
||||||
|
setStatusMessage('Add at least one onboarding step before saving.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
|
saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
|
||||||
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Onboarding schema published in runtime storage.');
|
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Onboarding schema published in runtime storage.');
|
||||||
};
|
};
|
||||||
|
|
@ -24,7 +63,7 @@ export default function EditOnboardingPage() {
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<h1 class="page-title">Edit Onboarding Flow</h1>
|
<h1 class="page-title">Edit Onboarding Flow</h1>
|
||||||
<p class="page-subtitle">Update this runtime onboarding schema without changing source code.</p>
|
<p class="page-subtitle">All onboarding fields, validations, upload rules, and select behaviors are runtime schema-driven.</p>
|
||||||
{!config() ? (
|
{!config() ? (
|
||||||
<section class="card"><p class="notice">Onboarding schema not found in local runtime storage.</p></section>
|
<section class="card"><p class="notice">Onboarding schema not found in local runtime storage.</p></section>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -44,25 +83,9 @@ export default function EditOnboardingPage() {
|
||||||
<input type="number" value={config()!.version} onInput={(e) => setConfig({ ...config()!, version: Number(e.currentTarget.value || 1) })} />
|
<input type="number" value={config()!.version} onInput={(e) => setConfig({ ...config()!, version: Number(e.currentTarget.value || 1) })} />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Steps (one title per line, quick mode)</label>
|
<label>Steps JSON (full runtime schema)</label>
|
||||||
<textarea
|
<textarea class="json-input" rows={18} value={stepsJson()} onInput={(e) => syncSteps(e.currentTarget.value)} />
|
||||||
rows={8}
|
{stepsError() && <p class="error-note">{stepsError()}</p>}
|
||||||
value={config()!.steps.map((s) => s.title).join('\n')}
|
|
||||||
onInput={(e) =>
|
|
||||||
setConfig({
|
|
||||||
...config()!,
|
|
||||||
steps: e.currentTarget.value
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((title, index) => ({
|
|
||||||
id: `step_${index + 1}`,
|
|
||||||
title,
|
|
||||||
fields: [{ id: `field_${index + 1}`, label: 'Sample Field', type: 'text', required: true }],
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
|
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,58 @@
|
||||||
import { createSignal } from 'solid-js';
|
import { createSignal } from 'solid-js';
|
||||||
import AdminShell from '~/components/AdminShell';
|
import AdminShell from '~/components/AdminShell';
|
||||||
import { saveRuntimeConfig } from '~/lib/runtime/storage';
|
import { saveRuntimeConfig } from '~/lib/runtime/storage';
|
||||||
import type { RuntimeOnboardingConfig } from '~/lib/runtime/types';
|
import type { RuntimeOnboardingConfig, RuntimeOnboardingStep } from '~/lib/runtime/types';
|
||||||
|
|
||||||
export default function CreateOnboardingSchemaPage() {
|
function buildDefaultConfig(): RuntimeOnboardingConfig {
|
||||||
const [config, setConfig] = createSignal<RuntimeOnboardingConfig>({
|
return {
|
||||||
schemaId: 'photographer_onboarding_v1',
|
schemaId: 'photographer_onboarding_v1',
|
||||||
roleKey: 'PHOTOGRAPHER',
|
roleKey: 'PHOTOGRAPHER',
|
||||||
version: 1,
|
version: 1,
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
id: 'profile',
|
id: 'step_1_service',
|
||||||
title: 'Profile Details',
|
title: 'Select Service Category',
|
||||||
fields: [
|
fields: [
|
||||||
{ id: 'full_name', label: 'Full Name', type: 'text', required: true },
|
{
|
||||||
{ id: 'experience', label: 'Experience', type: 'number', required: true },
|
id: 'profession',
|
||||||
|
label: 'Service Category',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ label: 'Photographer', value: 'Photographer' },
|
||||||
|
{ label: 'Makeup Artist', value: 'Makeup Artist' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySteps(steps: RuntimeOnboardingStep[]): string {
|
||||||
|
return JSON.stringify(steps, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateOnboardingSchemaPage() {
|
||||||
|
const [config, setConfig] = createSignal<RuntimeOnboardingConfig>(buildDefaultConfig());
|
||||||
|
const [stepsJson, setStepsJson] = createSignal(stringifySteps(buildDefaultConfig().steps));
|
||||||
const [statusMessage, setStatusMessage] = createSignal('');
|
const [statusMessage, setStatusMessage] = createSignal('');
|
||||||
|
const [stepsError, setStepsError] = createSignal('');
|
||||||
|
|
||||||
|
const syncSteps = (raw: string) => {
|
||||||
|
setStepsJson(raw);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as RuntimeOnboardingStep[];
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
setStepsError('Steps JSON must be an array.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfig({ ...config(), steps: parsed });
|
||||||
|
setStepsError('');
|
||||||
|
} catch {
|
||||||
|
setStepsError('Invalid JSON. Fix syntax before saving.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const persist = (status: 'draft' | 'published') => {
|
const persist = (status: 'draft' | 'published') => {
|
||||||
const payload = config();
|
const payload = config();
|
||||||
|
|
@ -27,6 +60,15 @@ export default function CreateOnboardingSchemaPage() {
|
||||||
setStatusMessage('Schema ID is required before saving.');
|
setStatusMessage('Schema ID is required before saving.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (stepsError()) {
|
||||||
|
setStatusMessage('Fix steps JSON errors before saving.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.steps.length === 0) {
|
||||||
|
setStatusMessage('Add at least one onboarding step before saving.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
|
saveRuntimeConfig('onboarding', payload.schemaId, payload, status);
|
||||||
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Onboarding schema published in runtime storage.');
|
setStatusMessage(status === 'draft' ? 'Draft saved in runtime storage.' : 'Onboarding schema published in runtime storage.');
|
||||||
};
|
};
|
||||||
|
|
@ -34,7 +76,7 @@ export default function CreateOnboardingSchemaPage() {
|
||||||
return (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<h1 class="page-title">Create Onboarding Flow</h1>
|
<h1 class="page-title">Create Onboarding Flow</h1>
|
||||||
<p class="page-subtitle">Build onboarding in the same place with a simple step editor and visible runtime JSON.</p>
|
<p class="page-subtitle">All onboarding fields, validations, upload rules, and select behaviors are runtime schema-driven.</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Onboarding Builder</h2>
|
<h2>Onboarding Builder</h2>
|
||||||
|
|
@ -51,25 +93,9 @@ export default function CreateOnboardingSchemaPage() {
|
||||||
<input type="number" value={config().version} onInput={(e) => setConfig({ ...config(), version: Number(e.currentTarget.value || 1) })} />
|
<input type="number" value={config().version} onInput={(e) => setConfig({ ...config(), version: Number(e.currentTarget.value || 1) })} />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Steps (one title per line, quick mode)</label>
|
<label>Steps JSON (full runtime schema)</label>
|
||||||
<textarea
|
<textarea class="json-input" rows={18} value={stepsJson()} onInput={(e) => syncSteps(e.currentTarget.value)} />
|
||||||
rows={8}
|
{stepsError() && <p class="error-note">{stepsError()}</p>}
|
||||||
value={config().steps.map((s) => s.title).join('\n')}
|
|
||||||
onInput={(e) =>
|
|
||||||
setConfig({
|
|
||||||
...config(),
|
|
||||||
steps: e.currentTarget.value
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((title, index) => ({
|
|
||||||
id: `step_${index + 1}`,
|
|
||||||
title,
|
|
||||||
fields: [{ id: `field_${index + 1}`, label: 'Sample Field', type: 'text', required: true }],
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
|
<button class="btn" onClick={() => persist('draft')}>Save Draft</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue