feat(frontend): add solidstart runtime onboarding renderer

This commit is contained in:
Ashwin Kumar 2026-03-16 23:46:21 +01:00
parent 2cf3ff4d0b
commit 6083f0e0fa
17 changed files with 9978 additions and 3 deletions

7
.gitignore vendored
View file

@ -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

File diff suppressed because it is too large Load diff

19
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

144
src/app.css Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
/// <reference types="vinxi/types/client" />
/// <reference types="vinxi/types/server" />

216
src/lib/runtime/seed.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
import { defineConfig } from '@solidjs/start/config';
export default defineConfig({});