feat(frontend): add solidstart runtime onboarding renderer
This commit is contained in:
parent
2cf3ff4d0b
commit
6083f0e0fa
17 changed files with 9978 additions and 3 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
node_modules
|
node_modules/
|
||||||
dist
|
dist/
|
||||||
.output
|
.vinxi/
|
||||||
|
.output/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
8981
package-lock.json
generated
Normal file
8981
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "nxtgauge-frontend-solid",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vinxi dev",
|
||||||
|
"build": "vinxi build",
|
||||||
|
"start": "vinxi start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@solidjs/meta": "^0.29.4",
|
||||||
|
"@solidjs/router": "^0.15.0",
|
||||||
|
"@solidjs/start": "^1.3.2",
|
||||||
|
"solid-js": "^1.9.5",
|
||||||
|
"vinxi": "^0.5.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 664 B |
144
src/app.css
Normal file
144
src/app.css
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
:root {
|
||||||
|
--brand-orange: #fd6216;
|
||||||
|
--brand-navy: #050026;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Exo 2', system-ui, sans-serif;
|
||||||
|
background: linear-gradient(140deg, #fff6f0 0%, #f7f8fc 42%, #eef2ff 100%);
|
||||||
|
color: var(--brand-navy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.25fr 0.75fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.select,
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
min-height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
border-color: var(--brand-orange);
|
||||||
|
background: var(--brand-orange);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
background: #fff7ed;
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
max-height: 560px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app.tsx
Normal file
20
src/app.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { MetaProvider, Title } from '@solidjs/meta';
|
||||||
|
import { Router } from '@solidjs/router';
|
||||||
|
import { FileRoutes } from '@solidjs/start/router';
|
||||||
|
import { Suspense } from 'solid-js';
|
||||||
|
import './app.css';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Router
|
||||||
|
root={(props) => (
|
||||||
|
<MetaProvider>
|
||||||
|
<Title>NXTGAUGE Frontend Solid</Title>
|
||||||
|
<Suspense>{props.children}</Suspense>
|
||||||
|
</MetaProvider>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileRoutes />
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/entry-client.tsx
Normal file
4
src/entry-client.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// @refresh reload
|
||||||
|
import { mount, StartClient } from '@solidjs/start/client';
|
||||||
|
|
||||||
|
mount(() => <StartClient />, document.getElementById('app')!);
|
||||||
21
src/entry-server.tsx
Normal file
21
src/entry-server.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// @refresh reload
|
||||||
|
import { createHandler, StartServer } from '@solidjs/start/server';
|
||||||
|
|
||||||
|
export default createHandler(() => (
|
||||||
|
<StartServer
|
||||||
|
document={({ assets, children, scripts }) => (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
{assets}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">{children}</div>
|
||||||
|
{scripts}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
2
src/global.d.ts
vendored
Normal file
2
src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="vinxi/types/client" />
|
||||||
|
/// <reference types="vinxi/types/server" />
|
||||||
216
src/lib/runtime/seed.ts
Normal file
216
src/lib/runtime/seed.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
import type { RuntimeOnboardingConfig } from '~/lib/runtime/types';
|
||||||
|
|
||||||
|
const PROFESSIONS = [
|
||||||
|
'Photographer',
|
||||||
|
'Makeup Artist',
|
||||||
|
'Tutor',
|
||||||
|
'Developer',
|
||||||
|
'Video Editor',
|
||||||
|
'Graphic Designer',
|
||||||
|
'Social Media Manager',
|
||||||
|
'Fitness Trainer',
|
||||||
|
'Catering Services',
|
||||||
|
];
|
||||||
|
|
||||||
|
function withProfessionVisibility(base: Omit<RuntimeOnboardingConfig['steps'][number]['fields'][number], 'visibleWhen'>, profession: string) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
visibleWhen: [{ field: 'profession', equals: profession }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEEDED_ONBOARDING_SCHEMAS: RuntimeOnboardingConfig[] = [
|
||||||
|
{
|
||||||
|
schemaId: 'customer_onboarding_v1',
|
||||||
|
roleKey: 'CUSTOMER',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'step_1_service',
|
||||||
|
title: 'Select Service Category',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'profession',
|
||||||
|
label: 'Service Category',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
options: PROFESSIONS.map((p) => ({ label: p, value: p })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_2_requirements',
|
||||||
|
title: 'Requirements',
|
||||||
|
fields: [
|
||||||
|
withProfessionVisibility({ id: 'event_type', label: 'Event Type', type: 'select', required: true, options: ['Wedding', 'Corporate Event', 'Birthday', 'Product Shoot', 'Portrait'].map((x) => ({ label: x, value: x })) }, 'Photographer'),
|
||||||
|
withProfessionVisibility({ id: 'coverage_hours', label: 'Coverage Hours', type: 'number', required: true, placeholder: 'e.g., 4', validation: { min: 1 } }, 'Photographer'),
|
||||||
|
withProfessionVisibility({ id: 'photo_style', label: 'Photo Style', type: 'select', required: true, options: ['Traditional', 'Candid', 'Cinematic', 'Documentary'].map((x) => ({ label: x, value: x })) }, 'Photographer'),
|
||||||
|
|
||||||
|
withProfessionVisibility({ id: 'occasion_type', label: 'Occasion Type', type: 'select', required: true, options: ['Bridal', 'Party/Guest', 'Photoshoot', 'Editorial'].map((x) => ({ label: x, value: x })) }, 'Makeup Artist'),
|
||||||
|
withProfessionVisibility({ id: 'people_count', label: 'People Count', type: 'number', required: true, placeholder: 'e.g., 2', validation: { min: 1 } }, 'Makeup Artist'),
|
||||||
|
withProfessionVisibility({ id: 'skin_preferences', label: 'Skin Preferences', type: 'textarea', placeholder: 'Any allergies or specific product requests?' }, 'Makeup Artist'),
|
||||||
|
|
||||||
|
withProfessionVisibility({ id: 'subject', label: 'Subject', type: 'text', required: true, placeholder: 'e.g., Mathematics, Spoken English' }, 'Tutor'),
|
||||||
|
withProfessionVisibility({ id: 'grade_level', label: 'Grade Level', type: 'select', required: true, options: ['Primary', 'Middle School', 'High School', 'College', 'Professional'].map((x) => ({ label: x, value: x })) }, 'Tutor'),
|
||||||
|
withProfessionVisibility({ id: 'sessions_per_week', label: 'Sessions Per Week', type: 'number', required: true, placeholder: 'e.g., 3', validation: { min: 1 } }, 'Tutor'),
|
||||||
|
|
||||||
|
withProfessionVisibility({ id: 'project_type', label: 'Project Type', type: 'select', required: true, options: ['Website', 'Mobile App', 'E-commerce', 'Custom Software'].map((x) => ({ label: x, value: x })) }, 'Developer'),
|
||||||
|
withProfessionVisibility({ id: 'platform', label: 'Platform', type: 'select', required: true, options: ['iOS', 'Android', 'Web', 'Cross-platform'].map((x) => ({ label: x, value: x })) }, 'Developer'),
|
||||||
|
withProfessionVisibility({ id: 'feature_summary', label: 'Feature Summary', type: 'textarea', required: true, placeholder: 'Briefly describe what the app/website should do' }, 'Developer'),
|
||||||
|
|
||||||
|
withProfessionVisibility({ id: 'video_type', label: 'Video Type', type: 'select', required: true, options: ['YouTube Video', 'Instagram Reel/Shorts', 'Wedding Highlights', 'Corporate Promo'].map((x) => ({ label: x, value: x })) }, 'Video Editor'),
|
||||||
|
withProfessionVisibility({ id: 'video_duration', label: 'Video Duration', type: 'select', required: true, options: ['Under 1 min', '1-5 mins', '5-15 mins', 'Over 15 mins'].map((x) => ({ label: x, value: x })) }, 'Video Editor'),
|
||||||
|
withProfessionVisibility({ id: 'editing_style', label: 'Editing Style', type: 'text', required: true, placeholder: 'e.g., Fast-paced, Cinematic, Vlog style' }, 'Video Editor'),
|
||||||
|
|
||||||
|
withProfessionVisibility({ id: 'design_type', label: 'Design Type', type: 'select', required: true, options: ['Logo/Branding', 'Social Media Posts', 'UI/UX', 'Print Media'].map((x) => ({ label: x, value: x })) }, 'Graphic Designer'),
|
||||||
|
withProfessionVisibility({ id: 'brand_guidelines', label: 'Brand Guidelines', type: 'select', required: true, options: ['Yes - I have them', 'No - Need to create them'].map((x) => ({ label: x, value: x })) }, 'Graphic Designer'),
|
||||||
|
withProfessionVisibility({ id: 'asset_count', label: 'Asset Count', type: 'number', required: true, placeholder: 'How many images/screens?', validation: { min: 1 } }, 'Graphic Designer'),
|
||||||
|
|
||||||
|
withProfessionVisibility({ id: 'platforms', label: 'Platforms', type: 'select', required: true, multiple: true, options: ['Instagram', 'LinkedIn', 'Facebook', 'X/Twitter', 'YouTube'].map((x) => ({ label: x, value: x })) }, 'Social Media Manager'),
|
||||||
|
withProfessionVisibility({ id: 'posting_frequency', label: 'Posting Frequency', type: 'select', required: true, options: ['1-2 times/week', '3-4 times/week', 'Daily'].map((x) => ({ label: x, value: x })) }, 'Social Media Manager'),
|
||||||
|
withProfessionVisibility({ id: 'goal', label: 'Goal', type: 'select', required: true, options: ['Brand Awareness', 'Lead Generation', 'Sales/Conversions', 'Community Building'].map((x) => ({ label: x, value: x })) }, 'Social Media Manager'),
|
||||||
|
|
||||||
|
withProfessionVisibility({ id: 'fitness_goal', label: 'Fitness Goal', type: 'select', required: true, options: ['Weight Loss', 'Muscle Gain', 'Flexibility/Yoga', 'General Fitness'].map((x) => ({ label: x, value: x })) }, 'Fitness Trainer'),
|
||||||
|
withProfessionVisibility({ id: 'sessions_per_week_fitness', label: 'Sessions Per Week', type: 'number', required: true, placeholder: 'e.g., 5', validation: { min: 1 } }, 'Fitness Trainer'),
|
||||||
|
withProfessionVisibility({ id: 'training_mode', label: 'Training Mode', type: 'select', required: true, options: ['Online/Virtual', 'In-person'].map((x) => ({ label: x, value: x })) }, 'Fitness Trainer'),
|
||||||
|
|
||||||
|
withProfessionVisibility({ id: 'event_size', label: 'Event Size', type: 'number', required: true, placeholder: 'Number of plates/guests', validation: { min: 1 } }, 'Catering Services'),
|
||||||
|
withProfessionVisibility({ id: 'menu_preference', label: 'Menu Preference', type: 'select', required: true, options: ['Pure Veg', 'Non-Veg', 'Mixed'].map((x) => ({ label: x, value: x })) }, 'Catering Services'),
|
||||||
|
withProfessionVisibility({ id: 'cuisine_type', label: 'Cuisine Type', type: 'text', required: true, placeholder: 'e.g., South Indian, North Indian, Continental' }, 'Catering Services'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_3_budget_timeline',
|
||||||
|
title: 'Budget and Timeline',
|
||||||
|
fields: [
|
||||||
|
{ id: 'budget_range', label: 'Budget Range', type: 'select', required: true, options: ['Under ₹5,000', '₹5,000 - ₹15,000', '₹15,000 - ₹50,000', '₹50,000 - ₹1,00,000', '₹1,00,000+'].map((x) => ({ label: x, value: x })) },
|
||||||
|
{ id: 'expected_start', label: 'Expected Start', type: 'date', required: true },
|
||||||
|
{ id: 'urgency', label: 'Urgency', type: 'select', required: true, options: ['Relaxed (No strict deadline)', 'Standard (Within a few weeks)', 'ASAP (Urgent)'].map((x) => ({ label: x, value: x })) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_4_location',
|
||||||
|
title: 'Location and Preference',
|
||||||
|
fields: [
|
||||||
|
{ id: 'service_mode', label: 'Service Mode', type: 'select', required: true, options: ['Onsite (In-person)', 'Remote (Online)', 'Hybrid (Mix of both)'].map((x) => ({ label: x, value: x })) },
|
||||||
|
{ id: 'address_line', label: 'Address Line', type: 'text', required: true, placeholder: 'Street address, Landmark' },
|
||||||
|
{ id: 'service_city', label: 'Service City', type: 'text', required: true, readOnly: true, defaultValue: 'Chennai, India' },
|
||||||
|
{ id: 'pin_code', label: 'PIN Code', type: 'text', required: true, placeholder: 'e.g., 600001', validation: { pattern: '^[0-9]{6}$', maxLength: 6, minLength: 6 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_5_review',
|
||||||
|
title: 'Final Review',
|
||||||
|
fields: [{ id: 'summary_note', label: 'Additional Instructions', type: 'textarea', placeholder: 'Any additional instructions or context?' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'step_6_verification',
|
||||||
|
title: 'Identity Verification',
|
||||||
|
fields: [
|
||||||
|
{ id: 'id_type', label: 'ID Type', type: 'select', required: true, options: ['Aadhaar Card', 'PAN Card', 'Driving License', 'Voter ID', 'Passport'].map((x) => ({ label: x, value: x })) },
|
||||||
|
{ id: 'id_number', label: 'ID Number', type: 'text', required: true, placeholder: 'Enter ID Number' },
|
||||||
|
{ id: 'id_document_upload', label: 'Upload ID (PDF only)', type: 'file', required: true, multiple: true, maxFiles: 2, accept: 'application/pdf', maxSizeMB: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaId: 'professional_onboarding_v1',
|
||||||
|
roleKey: 'PROFESSIONAL',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ id: 'step_1_role', title: 'Choose your professional role', fields: [{ id: 'profession', label: 'Profession', type: 'select', required: true, options: PROFESSIONS.map((p) => ({ label: p, value: p })) }] },
|
||||||
|
{ id: 'step_2_profile', title: 'Profile details', fields: [
|
||||||
|
{ id: 'full_name', label: 'Full Name', type: 'text', required: true, placeholder: 'Enter your full name' },
|
||||||
|
{ id: 'experience', label: 'Experience (Years)', type: 'number', required: true, validation: { min: 0 } },
|
||||||
|
{ id: 'bio', label: 'Bio', type: 'textarea', required: true, placeholder: "Hi, I'm a professional specializing in..." },
|
||||||
|
] },
|
||||||
|
{ id: 'step_3_contact', title: 'Contact and location', fields: [
|
||||||
|
{ id: 'email', label: 'Email', type: 'email', required: true },
|
||||||
|
{ id: 'phone', label: 'Phone', type: 'tel', required: true, validation: { pattern: '^[0-9]{10}$', minLength: 10, maxLength: 10 } },
|
||||||
|
{ id: 'city', label: 'City', type: 'text', required: true, readOnly: true, defaultValue: 'Chennai, India' },
|
||||||
|
] },
|
||||||
|
{ id: 'step_4_services', title: 'Services and pricing', fields: [
|
||||||
|
{ id: 'primary_service', label: 'Primary Service', type: 'text', required: true, placeholder: 'e.g., Wedding Photography' },
|
||||||
|
{ id: 'price_range', label: 'Price Range', type: 'select', required: true, options: ['Base rate under ₹1000/hr', '₹1000-₹3000/hr', 'Project-based pricing', 'Customizable pricing'].map((x) => ({ label: x, value: x })) },
|
||||||
|
{ id: 'availability', label: 'Availability', type: 'select', required: true, options: ['Weekdays', 'Weekends', 'All days'].map((x) => ({ label: x, value: x })) },
|
||||||
|
] },
|
||||||
|
{ id: 'step_5_portfolio', title: 'Portfolio and submission', fields: [
|
||||||
|
{ id: 'portfolio_images', label: 'Portfolio Images', type: 'file', required: true, multiple: true, maxFiles: 6, accept: 'image/*', maxSizeMB: 2, helperText: 'Upload up to 6 images, max 2MB each.' },
|
||||||
|
{ id: 'portfolio_url', label: 'Portfolio URL', type: 'url', placeholder: 'External portfolio link or Instagram' },
|
||||||
|
{ id: 'portfolio_note', label: 'Portfolio Note', type: 'textarea', placeholder: 'Tell us about your work' },
|
||||||
|
] },
|
||||||
|
{ id: 'step_6_verification', title: 'Identity Verification', fields: [
|
||||||
|
{ id: 'id_type', label: 'ID Type', type: 'select', required: true, options: ['Aadhaar Card', 'PAN Card', 'Driving License', 'Voter ID', 'Passport'].map((x) => ({ label: x, value: x })) },
|
||||||
|
{ id: 'id_number', label: 'ID Number', type: 'text', required: true, placeholder: 'Enter ID Number' },
|
||||||
|
{ id: 'id_document_upload', label: 'Upload ID (PDF only)', type: 'file', required: true, multiple: true, maxFiles: 2, accept: 'application/pdf', maxSizeMB: 2 },
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaId: 'company_onboarding_v1',
|
||||||
|
roleKey: 'COMPANY',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ id: 'step_1_identity', title: 'Company identity', fields: [
|
||||||
|
{ id: 'company_name', label: 'Company Name', type: 'text', required: true },
|
||||||
|
{ id: 'legal_name', label: 'Legal Name', type: 'text', required: true },
|
||||||
|
{ id: 'industry', label: 'Industry', type: 'select', required: true, options: ['IT/Software', 'Marketing/Advertising', 'EdTech', 'Media/Entertainment', 'Health/Wellness', 'Food/Beverage', 'Other'].map((x) => ({ label: x, value: x })) },
|
||||||
|
] },
|
||||||
|
{ id: 'step_2_contact', title: 'Contact details', fields: [
|
||||||
|
{ id: 'contact_name', label: 'Contact Name', type: 'text', required: true },
|
||||||
|
{ id: 'contact_email', label: 'Contact Email', type: 'email', required: true },
|
||||||
|
{ id: 'contact_phone', label: 'Contact Phone', type: 'tel', required: true, validation: { pattern: '^[0-9]{10}$', minLength: 10, maxLength: 10 } },
|
||||||
|
] },
|
||||||
|
{ id: 'step_3_presence', title: 'Company presence', fields: [
|
||||||
|
{ id: 'website', label: 'Website', type: 'url' },
|
||||||
|
{ id: 'hq_city', label: 'HQ City', type: 'text', required: true, readOnly: true, defaultValue: 'Chennai, India' },
|
||||||
|
{ id: 'team_size', label: 'Team Size', type: 'select', required: true, options: ['1-10', '11-50', '51-200', '200+'].map((x) => ({ label: x, value: x })) },
|
||||||
|
] },
|
||||||
|
{ id: 'step_4_hiring', title: 'Hiring preferences', fields: [
|
||||||
|
{ id: 'hiring_for', label: 'Hiring For', type: 'text', required: true },
|
||||||
|
{ id: 'work_mode', label: 'Work Mode', type: 'select', required: true, options: ['Onsite (Work from office)', 'Remote (Work from home)', 'Hybrid'].map((x) => ({ label: x, value: x })) },
|
||||||
|
{ id: 'monthly_openings', label: 'Monthly Openings', type: 'number', required: true, validation: { min: 1 } },
|
||||||
|
] },
|
||||||
|
{ id: 'step_5_compliance', title: 'Verification and compliance', fields: [
|
||||||
|
{ id: 'registration_number', label: 'Registration Number', type: 'text', required: true },
|
||||||
|
{ id: 'official_email', label: 'Official Email', type: 'email', required: true },
|
||||||
|
] },
|
||||||
|
{ id: 'step_6_business_verification', title: 'Business Verification', fields: [
|
||||||
|
{ id: 'company_doc_type', label: 'Company Document Type', type: 'select', required: true, options: ['GST Certificate', 'Certificate of Incorporation', 'MSME/Udyam Registration', 'Company PAN Card'].map((x) => ({ label: x, value: x })) },
|
||||||
|
{ id: 'company_doc_upload', label: 'Company Document Upload (PDF only)', type: 'file', required: true, accept: 'application/pdf', maxFiles: 1, maxSizeMB: 2 },
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schemaId: 'jobseeker_onboarding_v1',
|
||||||
|
roleKey: 'JOBSEEKER',
|
||||||
|
version: 1,
|
||||||
|
steps: [
|
||||||
|
{ id: 'step_1_basic', title: 'Basic profile', fields: [
|
||||||
|
{ id: 'full_name', label: 'Full Name', type: 'text', required: true },
|
||||||
|
{ id: 'city', label: 'City', type: 'text', required: true, readOnly: true, defaultValue: 'Chennai, India' },
|
||||||
|
{ id: 'skills', label: 'Skills', type: 'text', required: true },
|
||||||
|
] },
|
||||||
|
{ id: 'step_2_preferences', title: 'Job preferences', fields: [
|
||||||
|
{ id: 'preferred_role', label: 'Preferred Role', type: 'text', required: true },
|
||||||
|
{ id: 'expected_salary', label: 'Expected Salary (LPA)', type: 'number', required: true, validation: { min: 0 } },
|
||||||
|
] },
|
||||||
|
{ id: 'step_3_experience', title: 'Experience details', fields: [
|
||||||
|
{ id: 'experience_years', label: 'Experience Years', type: 'number', required: true, validation: { min: 0 } },
|
||||||
|
{ id: 'latest_company', label: 'Latest Company', type: 'text' },
|
||||||
|
{ id: 'notice_period', label: 'Notice Period', type: 'select', required: true, options: ['Immediate', '15 Days', '30 Days', '60 Days', '90 Days'].map((x) => ({ label: x, value: x })) },
|
||||||
|
] },
|
||||||
|
{ id: 'step_4_docs', title: 'Documents and links', fields: [
|
||||||
|
{ id: 'resume_url', label: 'Resume URL', type: 'url', required: true },
|
||||||
|
{ id: 'linkedin_url', label: 'LinkedIn URL', type: 'url' },
|
||||||
|
] },
|
||||||
|
{ id: 'step_5_review', title: 'Final review', fields: [{ id: 'about_me', label: 'About Me', type: 'textarea', required: true }] },
|
||||||
|
{ id: 'step_6_verification', title: 'Identity Verification', fields: [
|
||||||
|
{ id: 'id_type', label: 'ID Type', type: 'select', required: true, options: ['Aadhaar Card', 'PAN Card', 'Driving License', 'Voter ID', 'Passport'].map((x) => ({ label: x, value: x })) },
|
||||||
|
{ id: 'id_number', label: 'ID Number', type: 'text', required: true },
|
||||||
|
{ id: 'id_document_upload', label: 'Upload ID (PDF only)', type: 'file', required: true, multiple: true, maxFiles: 2, accept: 'application/pdf', maxSizeMB: 2 },
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
100
src/lib/runtime/storage.ts
Normal file
100
src/lib/runtime/storage.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { SEEDED_ONBOARDING_SCHEMAS } from '~/lib/runtime/seed';
|
||||||
|
import type { OnboardingSubmission, RuntimeOnboardingConfig } from '~/lib/runtime/types';
|
||||||
|
|
||||||
|
const FRONTEND_KEY = 'nxtgauge_frontend_runtime_v1';
|
||||||
|
const ADMIN_BUILDER_KEY = 'nxtgauge_admin_runtime_builder_v1';
|
||||||
|
const SUBMISSION_KEY = 'nxtgauge_frontend_onboarding_submissions_v1';
|
||||||
|
|
||||||
|
type FrontendStore = {
|
||||||
|
onboarding: Record<string, RuntimeOnboardingConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyStore(): FrontendStore {
|
||||||
|
return { onboarding: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFrontendStore(): FrontendStore {
|
||||||
|
if (typeof window === 'undefined') return emptyStore();
|
||||||
|
const raw = window.localStorage.getItem(FRONTEND_KEY);
|
||||||
|
if (!raw) return emptyStore();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<FrontendStore>;
|
||||||
|
return { onboarding: parsed.onboarding || {} };
|
||||||
|
} catch {
|
||||||
|
return emptyStore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFrontendStore(store: FrontendStore) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(FRONTEND_KEY, JSON.stringify(store));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSeededRuntimeConfig() {
|
||||||
|
const store = readFrontendStore();
|
||||||
|
if (Object.keys(store.onboarding).length > 0) return;
|
||||||
|
const seeded: Record<string, RuntimeOnboardingConfig> = {};
|
||||||
|
SEEDED_ONBOARDING_SCHEMAS.forEach((schema) => {
|
||||||
|
seeded[schema.schemaId] = schema;
|
||||||
|
});
|
||||||
|
writeFrontendStore({ onboarding: seeded });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAdminBuilderPublishedSchemas(): RuntimeOnboardingConfig[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
const raw = window.localStorage.getItem(ADMIN_BUILDER_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { onboarding?: Record<string, { status?: string; payload?: RuntimeOnboardingConfig }> };
|
||||||
|
const bucket = parsed.onboarding || {};
|
||||||
|
return Object.values(bucket)
|
||||||
|
.filter((item) => item?.status === 'published' && item.payload)
|
||||||
|
.map((item) => item.payload as RuntimeOnboardingConfig);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRuntimeOnboardingSchema(input: { schemaId?: string; roleKey?: string }): RuntimeOnboardingConfig | null {
|
||||||
|
const adminPublished = readAdminBuilderPublishedSchemas();
|
||||||
|
|
||||||
|
if (input.schemaId) {
|
||||||
|
const bySchemaId = adminPublished.find((schema) => schema.schemaId === input.schemaId);
|
||||||
|
if (bySchemaId) return bySchemaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.roleKey) {
|
||||||
|
const byRole = adminPublished.find((schema) => schema.roleKey.toUpperCase() === input.roleKey?.toUpperCase());
|
||||||
|
if (byRole) return byRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontendStore = readFrontendStore();
|
||||||
|
if (input.schemaId && frontendStore.onboarding[input.schemaId]) {
|
||||||
|
return frontendStore.onboarding[input.schemaId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.roleKey) {
|
||||||
|
const roleKey = input.roleKey.toUpperCase();
|
||||||
|
const match = Object.values(frontendStore.onboarding).find((schema) => schema.roleKey.toUpperCase() === roleKey);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveOnboardingSubmission(payload: Omit<OnboardingSubmission, 'id' | 'submittedAt'>): OnboardingSubmission {
|
||||||
|
const next: OnboardingSubmission = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
submittedAt: new Date().toISOString(),
|
||||||
|
...payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') return next;
|
||||||
|
|
||||||
|
const raw = window.localStorage.getItem(SUBMISSION_KEY);
|
||||||
|
const current = raw ? (JSON.parse(raw) as OnboardingSubmission[]) : [];
|
||||||
|
current.unshift(next);
|
||||||
|
window.localStorage.setItem(SUBMISSION_KEY, JSON.stringify(current));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
79
src/lib/runtime/types.ts
Normal file
79
src/lib/runtime/types.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
export type RuntimeFieldType =
|
||||||
|
| 'text'
|
||||||
|
| 'textarea'
|
||||||
|
| 'number'
|
||||||
|
| 'email'
|
||||||
|
| 'tel'
|
||||||
|
| 'date'
|
||||||
|
| 'select'
|
||||||
|
| 'url'
|
||||||
|
| 'file'
|
||||||
|
| 'checkbox';
|
||||||
|
|
||||||
|
export type RuntimeOption = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeVisibilityCondition = {
|
||||||
|
field: string;
|
||||||
|
equals?: string;
|
||||||
|
in?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeValidation = {
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
pattern?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeOnboardingField = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: RuntimeFieldType;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
helperText?: string;
|
||||||
|
defaultValue?: string | number | boolean | string[];
|
||||||
|
readOnly?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
options?: RuntimeOption[];
|
||||||
|
accept?: string;
|
||||||
|
maxFiles?: number;
|
||||||
|
maxSizeMB?: number;
|
||||||
|
validation?: RuntimeValidation;
|
||||||
|
visibleWhen?: RuntimeVisibilityCondition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeOnboardingStep = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
visibleWhen?: RuntimeVisibilityCondition[];
|
||||||
|
fields: RuntimeOnboardingField[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeOnboardingConfig = {
|
||||||
|
schemaId: string;
|
||||||
|
roleKey: string;
|
||||||
|
version: number;
|
||||||
|
steps: RuntimeOnboardingStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadedFileMeta = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
uploadedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OnboardingSubmission = {
|
||||||
|
id: string;
|
||||||
|
schemaId: string;
|
||||||
|
roleKey: string;
|
||||||
|
submittedAt: string;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
};
|
||||||
3
src/routes/[...404].tsx
Normal file
3
src/routes/[...404].tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function NotFound() {
|
||||||
|
return <div class="page"><h1>Not found</h1></div>;
|
||||||
|
}
|
||||||
19
src/routes/index.tsx
Normal file
19
src/routes/index.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { A } from '@solidjs/router';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main class="page">
|
||||||
|
<h1 class="title">NXTGAUGE Frontend (SolidStart)</h1>
|
||||||
|
<p class="subtitle">Runtime-only onboarding demo for customer, professional, company, and jobseeker.</p>
|
||||||
|
<section class="card" style={{ 'margin-top': '16px' }}>
|
||||||
|
<p>Open onboarding flows:</p>
|
||||||
|
<div class="actions">
|
||||||
|
<A class="btn" href="/onboarding?schemaId=customer_onboarding_v1">Customer</A>
|
||||||
|
<A class="btn" href="/onboarding?schemaId=professional_onboarding_v1">Professional</A>
|
||||||
|
<A class="btn" href="/onboarding?schemaId=company_onboarding_v1">Company</A>
|
||||||
|
<A class="btn" href="/onboarding?schemaId=jobseeker_onboarding_v1">Jobseeker</A>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
src/routes/onboarding.tsx
Normal file
344
src/routes/onboarding.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
|
||||||
|
import { useSearchParams } from '@solidjs/router';
|
||||||
|
import { ensureSeededRuntimeConfig, getRuntimeOnboardingSchema, saveOnboardingSubmission } from '~/lib/runtime/storage';
|
||||||
|
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeVisibilityCondition, UploadedFileMeta } from '~/lib/runtime/types';
|
||||||
|
|
||||||
|
function evaluateVisibility(conditions: RuntimeVisibilityCondition[] | undefined, values: Record<string, unknown>) {
|
||||||
|
if (!conditions || conditions.length === 0) return true;
|
||||||
|
return conditions.every((condition) => {
|
||||||
|
const value = values[condition.field];
|
||||||
|
if (typeof condition.equals === 'string') return String(value || '') === condition.equals;
|
||||||
|
if (Array.isArray(condition.in)) return condition.in.includes(String(value || ''));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyValue(value: unknown) {
|
||||||
|
if (value == null) return true;
|
||||||
|
if (Array.isArray(value)) return value.length === 0;
|
||||||
|
if (typeof value === 'string') return value.trim().length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateField(field: RuntimeOnboardingField, value: unknown): string | null {
|
||||||
|
if (field.required && isEmptyValue(value)) {
|
||||||
|
return `${field.label} is required.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmptyValue(value)) return null;
|
||||||
|
|
||||||
|
if (field.type === 'number') {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) return `${field.label} must be a number.`;
|
||||||
|
if (typeof field.validation?.min === 'number' && numeric < field.validation.min) return `${field.label} must be at least ${field.validation.min}.`;
|
||||||
|
if (typeof field.validation?.max === 'number' && numeric > field.validation.max) return `${field.label} must be at most ${field.validation.max}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (typeof field.validation?.minLength === 'number' && value.length < field.validation.minLength) {
|
||||||
|
return `${field.label} must be at least ${field.validation.minLength} characters.`;
|
||||||
|
}
|
||||||
|
if (typeof field.validation?.maxLength === 'number' && value.length > field.validation.maxLength) {
|
||||||
|
return `${field.label} must be at most ${field.validation.maxLength} characters.`;
|
||||||
|
}
|
||||||
|
if (field.validation?.pattern) {
|
||||||
|
const regex = new RegExp(field.validation.pattern);
|
||||||
|
if (!regex.test(value)) return `${field.label} format is invalid.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'file') {
|
||||||
|
const files = value as UploadedFileMeta[] | undefined;
|
||||||
|
const length = files?.length || 0;
|
||||||
|
if (field.required && length === 0) return `${field.label} is required.`;
|
||||||
|
if (typeof field.maxFiles === 'number' && length > field.maxFiles) return `${field.label} allows only ${field.maxFiles} file(s).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [schema, setSchema] = createSignal<RuntimeOnboardingConfig | null>(null);
|
||||||
|
const [values, setValues] = createSignal<Record<string, unknown>>({});
|
||||||
|
const [errors, setErrors] = createSignal<Record<string, string>>({});
|
||||||
|
const [stepIndex, setStepIndex] = createSignal(0);
|
||||||
|
const [statusMessage, setStatusMessage] = createSignal('');
|
||||||
|
const [submitted, setSubmitted] = createSignal(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
ensureSeededRuntimeConfig();
|
||||||
|
|
||||||
|
const schemaId = searchParams.schemaId;
|
||||||
|
const roleKey = searchParams.roleKey;
|
||||||
|
const loaded = getRuntimeOnboardingSchema({ schemaId, roleKey });
|
||||||
|
setSchema(loaded);
|
||||||
|
|
||||||
|
if (!loaded) return;
|
||||||
|
|
||||||
|
const initialValues: Record<string, unknown> = {};
|
||||||
|
loaded.steps.forEach((step) => {
|
||||||
|
step.fields.forEach((field) => {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
initialValues[field.id] = field.defaultValue;
|
||||||
|
} else if (field.multiple) {
|
||||||
|
initialValues[field.id] = field.type === 'file' ? [] : [];
|
||||||
|
} else {
|
||||||
|
initialValues[field.id] = field.type === 'checkbox' ? false : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setValues(initialValues);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleSteps = createMemo(() => {
|
||||||
|
const currentSchema = schema();
|
||||||
|
if (!currentSchema) return [];
|
||||||
|
return currentSchema.steps.filter((step) => evaluateVisibility(step.visibleWhen, values()));
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeStep = createMemo(() => visibleSteps()[stepIndex()] || null);
|
||||||
|
|
||||||
|
const visibleFields = createMemo(() => {
|
||||||
|
const step = activeStep();
|
||||||
|
if (!step) return [];
|
||||||
|
return step.fields.filter((field) => evaluateVisibility(field.visibleWhen, values()));
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressText = createMemo(() => {
|
||||||
|
const total = visibleSteps().length;
|
||||||
|
if (total === 0) return '0 / 0';
|
||||||
|
return `${Math.min(stepIndex() + 1, total)} / ${total}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setFieldValue = (fieldId: string, next: unknown) => {
|
||||||
|
setValues((prev) => ({ ...prev, [fieldId]: next }));
|
||||||
|
setErrors((prev) => {
|
||||||
|
const copy = { ...prev };
|
||||||
|
delete copy[fieldId];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateCurrentStep = () => {
|
||||||
|
const fieldList = visibleFields();
|
||||||
|
const nextErrors: Record<string, string> = {};
|
||||||
|
fieldList.forEach((field) => {
|
||||||
|
const message = validateField(field, values()[field.id]);
|
||||||
|
if (message) nextErrors[field.id] = message;
|
||||||
|
});
|
||||||
|
setErrors((prev) => ({ ...prev, ...nextErrors }));
|
||||||
|
return Object.keys(nextErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (!validateCurrentStep()) {
|
||||||
|
setStatusMessage('Please fix the highlighted fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total = visibleSteps().length;
|
||||||
|
if (stepIndex() < total - 1) {
|
||||||
|
setStepIndex(stepIndex() + 1);
|
||||||
|
setStatusMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
if (stepIndex() > 0) setStepIndex(stepIndex() - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!validateCurrentStep()) {
|
||||||
|
setStatusMessage('Please fix the highlighted fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentSchema = schema();
|
||||||
|
if (!currentSchema) return;
|
||||||
|
|
||||||
|
saveOnboardingSubmission({
|
||||||
|
schemaId: currentSchema.schemaId,
|
||||||
|
roleKey: currentSchema.roleKey,
|
||||||
|
values: values(),
|
||||||
|
});
|
||||||
|
setSubmitted(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderField = (field: RuntimeOnboardingField) => {
|
||||||
|
const value = values()[field.id];
|
||||||
|
const error = errors()[field.id];
|
||||||
|
|
||||||
|
if (field.type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
|
value={String(value || '')}
|
||||||
|
readOnly={field.readOnly}
|
||||||
|
placeholder={field.placeholder || field.label}
|
||||||
|
onInput={(e) => setFieldValue(field.id, e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'select') {
|
||||||
|
if (field.multiple) {
|
||||||
|
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
class="select"
|
||||||
|
multiple
|
||||||
|
value={selected}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
onInput={(e) => {
|
||||||
|
const selectedValues = Array.from(e.currentTarget.selectedOptions).map((opt) => opt.value);
|
||||||
|
setFieldValue(field.id, selectedValues);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={field.options || []}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
class="select"
|
||||||
|
value={String(value || '')}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
onInput={(e) => setFieldValue(field.id, e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="">Select {field.label}</option>
|
||||||
|
<For each={field.options || []}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
return (
|
||||||
|
<label class="inline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(value)}
|
||||||
|
disabled={field.readOnly}
|
||||||
|
onInput={(e) => setFieldValue(field.id, e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span>{field.helperText || field.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'file') {
|
||||||
|
const files = Array.isArray(value) ? (value as UploadedFileMeta[]) : [];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="file"
|
||||||
|
multiple={Boolean(field.multiple)}
|
||||||
|
accept={field.accept}
|
||||||
|
onInput={(e) => {
|
||||||
|
const selected = Array.from(e.currentTarget.files || []);
|
||||||
|
if (selected.length === 0) {
|
||||||
|
setFieldValue(field.id, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFiles = field.maxFiles || (field.multiple ? 10 : 1);
|
||||||
|
if (selected.length > maxFiles) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field.id]: `${field.label} allows only ${maxFiles} file(s).` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSizeBytes = (field.maxSizeMB || 2) * 1024 * 1024;
|
||||||
|
const oversized = selected.find((file) => file.size > maxSizeBytes);
|
||||||
|
if (oversized) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field.id]: `\"${oversized.name}\" exceeds ${(field.maxSizeMB || 2)}MB.` }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped: UploadedFileMeta[] = selected.map((file) => ({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setFieldValue(field.id, mapped);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Show when={files.length > 0}>
|
||||||
|
<p class="note ok">Uploaded {files.length} file(s) ✓</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputType =
|
||||||
|
field.type === 'number'
|
||||||
|
? 'number'
|
||||||
|
: field.type === 'email'
|
||||||
|
? 'email'
|
||||||
|
: field.type === 'tel'
|
||||||
|
? 'tel'
|
||||||
|
: field.type === 'date'
|
||||||
|
? 'date'
|
||||||
|
: field.type === 'url'
|
||||||
|
? 'url'
|
||||||
|
: 'text';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type={inputType}
|
||||||
|
value={typeof value === 'string' || typeof value === 'number' ? String(value) : ''}
|
||||||
|
readOnly={field.readOnly}
|
||||||
|
placeholder={field.placeholder || field.label}
|
||||||
|
onInput={(e) => setFieldValue(field.id, e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main class="page">
|
||||||
|
<h1 class="title">Runtime Onboarding</h1>
|
||||||
|
<p class="subtitle">Schema-driven form only. No hardcoded questions fallback.</p>
|
||||||
|
|
||||||
|
<Show when={schema()} fallback={<section class="card"><p>No onboarding schema found in runtime config. Publish one in admin builder or use `schemaId` query.</p></section>}>
|
||||||
|
<Show when={!submitted()} fallback={<section class="card"><h2>Verification in Progress</h2><p class="subtitle">Your documents have been submitted. Please wait for 24-48 hours for profile approval.</p></section>}>
|
||||||
|
<div class="grid">
|
||||||
|
<section class="card">
|
||||||
|
<span class="step-pill">Step {progressText()}</span>
|
||||||
|
<h2>{activeStep()?.title}</h2>
|
||||||
|
<p class="subtitle">{activeStep()?.description || 'Fill required details and continue.'}</p>
|
||||||
|
|
||||||
|
<For each={visibleFields()}>
|
||||||
|
{(field) => (
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{field.label}{field.required ? ' *' : ''}</label>
|
||||||
|
{renderField(field)}
|
||||||
|
<Show when={field.helperText}><p class="note">{field.helperText}</p></Show>
|
||||||
|
<Show when={errors()[field.id]}><p class="error">{errors()[field.id]}</p></Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" disabled={stepIndex() === 0} onClick={goBack}>Back</button>
|
||||||
|
<Show
|
||||||
|
when={stepIndex() < visibleSteps().length - 1}
|
||||||
|
fallback={<button class="btn primary" onClick={submit}>Submit</button>}
|
||||||
|
>
|
||||||
|
<button class="btn primary" onClick={goNext}>Next</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={statusMessage()}><p class="error">{statusMessage()}</p></Show>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>Runtime Schema</h3>
|
||||||
|
<pre class="preview">{JSON.stringify(schema(), null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "solid-js",
|
||||||
|
"allowJs": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"isolatedModules": true,
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
vite.config.ts
Normal file
3
vite.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { defineConfig } from '@solidjs/start/config';
|
||||||
|
|
||||||
|
export default defineConfig({});
|
||||||
Loading…
Add table
Reference in a new issue