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
|
||||
dist
|
||||
.output
|
||||
node_modules/
|
||||
dist/
|
||||
.vinxi/
|
||||
.output/
|
||||
.env
|
||||
.env.*
|
||||
.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