feat(frontend): finalize onboarding, otp verification, and roleless dashboard flow

This commit is contained in:
Ashwin Kumar 2026-03-19 00:30:33 +01:00
parent fee43e3655
commit c435053810
36 changed files with 1572 additions and 418 deletions

View file

@ -2978,6 +2978,15 @@ body {
color: #1e293b;
}
.auth-captcha-canvas {
height: 52px;
border: 1px solid #c8cedd;
border-radius: 12px;
background: #fff;
display: block;
user-select: none;
}
.auth-submit-btn {
margin-top: 10px;
width: 100%;
@ -3007,6 +3016,77 @@ body {
font-size: 12px;
}
.auth-footer-row a {
color: #fd6216;
font-weight: 600;
text-decoration: none;
}
.auth-footer-row a:hover {
color: #e4570f;
text-decoration: underline;
}
.auth-checkbox-wrapper {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
margin-top: 12px;
}
.auth-checkbox {
width: 20px;
height: 20px;
min-width: 20px;
margin-top: 2px;
cursor: pointer;
accent-color: #fd6216;
}
.auth-checkbox-label {
font-size: 13px;
color: #4b546f;
line-height: 1.5;
}
.auth-checkbox-label a {
color: #fd6216;
text-decoration: underline;
font-weight: 600;
}
.auth-checkbox-label a:hover {
color: #e45a14;
}
.validation-note {
margin-top: 4px;
font-size: 12px;
line-height: 1.4;
transition: color 180ms ease;
}
.password-strength-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px 8px;
margin-top: 6px;
font-size: 11px;
line-height: 1.4;
}
.password-strength-grid p {
margin: 0;
transition: color 180ms ease;
}
.footer-text {
margin: 8px 0 0;
font-size: 12px;
color: #6a7390;
}
.otp-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
@ -3755,7 +3835,7 @@ body {
}
.about-principles-subline {
margin-top: 12px;
margin-top: 4px;
font-size: 14px;
line-height: 1.4;
letter-spacing: 0.12em;
@ -4302,64 +4382,172 @@ body {
/* ── Choose Role Page ── */
.choose-role-page {
position: relative;
min-height: 100vh;
background: var(--bg-soft, #f6f8ff);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
justify-content: flex-start;
padding: 24px 16px 60px;
color: #fff;
}
.choose-role-container {
max-width: 900px;
width: 100%;
position: relative;
z-index: 1;
width: min(1200px, calc(100% - 32px));
max-width: 1200px;
margin: 0 auto;
}
.choose-role-header {
text-align: center;
margin-bottom: 32px;
margin: 60px 0 48px;
}
.choose-role-header h1 {
margin: 12px 0 8px;
font-size: 32px;
margin: 0 0 12px;
font-size: clamp(28px, 5vw, 40px);
font-weight: 800;
color: #100b2f;
color: #fff;
line-height: 1.1;
}
.choose-role-header p { color: #64748b; }
.choose-role-page .role-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
.choose-role-header p {
margin: 0;
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
line-height: 1.5;
}
.choose-role-page .role-card {
.choose-role-section {
margin-bottom: 52px;
}
.choose-role-section .section-title {
margin: 0 0 8px;
font-size: 24px;
font-weight: 700;
color: #fff;
}
.choose-role-section .section-subtitle {
margin: 0 0 24px;
color: rgba(255, 255, 255, 0.75);
font-size: 14px;
}
.roles-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.main-roles-grid {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.professional-roles-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.role-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
padding: 20px 16px;
gap: 12px;
padding: 28px 18px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
cursor: pointer;
transition: border-color 200ms, box-shadow 200ms, transform 150ms;
border: 1.5px solid #e2e8f0;
transition: all 240ms ease;
box-shadow: 0 18px 36px -24px rgba(2, 6, 23, 0.5);
}
.choose-role-page .role-card:hover {
.role-card::before {
content: '';
position: absolute;
inset: -1px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(253, 98, 22, 0), rgba(253, 98, 22, 0.15));
opacity: 0;
transition: opacity 240ms ease;
pointer-events: none;
z-index: -1;
}
.role-card:hover {
border-color: rgba(253, 98, 22, 0.6);
background: rgba(255, 255, 255, 0.15);
transform: translateY(-6px);
box-shadow: 0 24px 52px -20px rgba(253, 98, 22, 0.45);
}
.role-card:hover::before {
opacity: 1;
}
.role-card.selected {
border-color: #fd6216;
box-shadow: 0 8px 24px -12px rgba(253, 98, 22, 0.28);
transform: translateY(-2px);
background: rgba(253, 98, 22, 0.2);
box-shadow: 0 0 0 2px rgba(253, 98, 22, 0.3) inset, 0 24px 52px -20px rgba(253, 98, 22, 0.5);
}
.choose-role-page .role-card:disabled {
opacity: 0.6;
.role-card:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.role-icon { font-size: 32px; line-height: 1; }
.role-label { font-size: 14px; font-weight: 700; color: #100b2f; }
.role-desc { font-size: 12px; color: #64748b; line-height: 1.4; }
.role-icon {
font-size: clamp(36px, 6vw, 48px);
line-height: 1;
display: block;
}
.role-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #fff;
}
.role-description {
margin: 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.5;
flex-grow: 1;
}
.role-cta {
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: #fd6216;
opacity: 0.8;
transition: opacity 180ms ease;
}
.role-card:hover .role-cta {
opacity: 1;
}
.choose-role-footer {
text-align: center;
margin-top: 48px;
padding-top: 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.choose-role-footer .footer-text {
margin: 0;
color: rgba(255, 255, 255, 0.65);
font-size: 14px;
}
/* ── Pending Verification Page ── */
.pending-page {

View file

@ -0,0 +1,82 @@
import { createEffect } from 'solid-js';
type CaptchaCanvasProps = {
code: string;
class?: string;
};
export default function CaptchaCanvas(props: CaptchaCanvasProps) {
let canvasRef: HTMLCanvasElement | undefined;
createEffect(() => {
const canvas = canvasRef;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = 176;
const height = 52;
const dpr = typeof window !== 'undefined' ? Math.max(1, window.devicePixelRatio || 1) : 1;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Clear and fill background
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Draw decorative lines
for (let i = 0; i < 2; i += 1) {
ctx.strokeStyle = i % 2 === 0 ? 'rgba(253,98,22,0.16)' : 'rgba(27,36,64,0.14)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.random() * width, Math.random() * height);
ctx.lineTo(Math.random() * width, Math.random() * height);
ctx.stroke();
}
// Draw decorative circles
for (let i = 0; i < 3; i += 1) {
ctx.fillStyle = i % 2 === 0 ? 'rgba(253,98,22,0.10)' : 'rgba(27,36,64,0.09)';
ctx.beginPath();
ctx.arc(Math.random() * width, Math.random() * height, Math.random() * 1.8 + 0.6, 0, Math.PI * 2);
ctx.fill();
}
// Draw characters
const chars = String(props.code || '').slice(0, 6).split('');
const startX = 16;
const charGap = 24;
chars.forEach((char, index) => {
const x = startX + index * charGap;
const y = height / 2 + 1;
const rotation = 0;
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.textBaseline = 'middle';
ctx.font = '800 22px "Courier New", monospace';
ctx.fillStyle = index % 2 === 0 ? '#0f172a' : '#c2410c';
ctx.lineWidth = 0;
ctx.fillText(char, 0, 0);
ctx.restore();
});
});
return (
<canvas
ref={canvasRef}
width={176}
height={52}
aria-label="Captcha image"
draggable={false}
onContextMenu={(event) => event.preventDefault()}
class={props.class}
/>
);
}

View file

@ -40,10 +40,10 @@ export default function PublicHeader(props: PublicHeaderProps) {
</A>
<div class="desktop-only nav-links">
<A class="nav-underline" href="/" aria-current={isRouteActive(location.pathname, '/') ? 'page' : undefined}>Home</A>
<A class="nav-underline" href="/about" aria-current={isRouteActive(location.pathname, '/about') ? 'page' : undefined}>About Us</A>
<A class="nav-underline" href="/help-center" aria-current={isRouteActive(location.pathname, '/help-center') ? 'page' : undefined}>Help Center</A>
<A class="nav-underline" href="/contact" aria-current={isRouteActive(location.pathname, '/contact') ? 'page' : undefined}>Contact Us</A>
<A class="nav-underline" classList={{ active: isRouteActive(location.pathname, '/') }} href="/">Home</A>
<A class="nav-underline" classList={{ active: isRouteActive(location.pathname, '/about') }} href="/about">About Us</A>
<A class="nav-underline" classList={{ active: isRouteActive(location.pathname, '/help-center') }} href="/help-center">Help Center</A>
<A class="nav-underline" classList={{ active: isRouteActive(location.pathname, '/contact') }} href="/contact">Contact Us</A>
</div>
<div class="desktop-only nav-actions">

View file

@ -41,6 +41,7 @@ const IconCompass = () => (
// ── Module → nav item mapping ─────────────────────────────────────────────────
const MODULE_NAV_MAP: Record<string, { label: string; href: string; icon: Component }> = {
// Uppercase module keys
COMPANY_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
JOBSEEKER_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
CUSTOMER_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
@ -64,6 +65,21 @@ const MODULE_NAV_MAP: Record<string, { label: string; href: string; icon: Compon
NOTIFICATIONS: { label: 'Notifications', href: '/dashboard/notifications', icon: IconBell },
SETTINGS: { label: 'Settings', href: '/dashboard/settings', icon: IconSettings },
EXPLORE_NXTGAUGE: { label: 'Explore Nxtgauge', href: '/dashboard/explore', icon: IconCompass },
// Lowercase module keys (from seed/runtime config)
jobs: { label: 'Jobs', href: '/dashboard/jobs', icon: IconJobs },
applications: { label: 'Applications', href: '/dashboard/applications', icon: IconJobs },
profile: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings },
browse_jobs: { label: 'Browse Jobs', href: '/dashboard/jobs', icon: IconJobs },
my_applications: { label: 'My Applications',href: '/dashboard/applications', icon: IconJobs },
requirements: { label: 'Requirements', href: '/dashboard/requirements', icon: IconJobs },
marketplace: { label: 'Marketplace', href: '/dashboard/marketplace', icon: IconCompass },
leads: { label: 'My Leads', href: '/dashboard/requests', icon: IconJobs },
portfolio: { label: 'Portfolio', href: '/dashboard/portfolio', icon: IconJobs },
services: { label: 'Services', href: '/dashboard/services', icon: IconJobs },
wallet: { label: 'Wallet', href: '/dashboard/wallet', icon: IconCompass },
notifications: { label: 'Notifications', href: '/dashboard/notifications', icon: IconBell },
settings: { label: 'Settings', href: '/dashboard/settings', icon: IconSettings },
};
// ── Dashboard Layout ──────────────────────────────────────────────────────────
@ -82,14 +98,19 @@ export default function DashboardLayout(props: { children: any }) {
navigate('/onboarding', { replace: true });
return;
}
if (s.runtime_config?.role === 'USER') {
navigate('/choose-role', { replace: true });
}
});
const rc = () => authState().runtime_config;
const navItems = () => {
if (rc()?.role === 'USER') {
return [
{ label: 'Dashboard', href: '/dashboard', icon: IconDashboard },
{ label: 'Explore Nxtgauge', href: '/dashboard/explore', icon: IconCompass },
{ label: 'Settings', href: '/dashboard/settings', icon: IconSettings },
];
}
const modules = rc()?.enabled_modules ?? [];
const seen = new Set<string>();
return modules

View file

@ -102,13 +102,13 @@ export async function switchRole(roleKey: string): Promise<void> {
const token = authState().access_token;
if (!token) return;
const res = await fetch(`${API_BASE}/api/me/roles/switch`, {
const res = await fetch(`${API_BASE}/api/auth/switch-role`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ role_key: roleKey }),
body: JSON.stringify({ requested_role: roleKey }),
credentials: 'include',
});

219
src/lib/form-validation.ts Normal file
View file

@ -0,0 +1,219 @@
/**
* Form Validation Utilities
* Reusable validation functions for auth forms and other components
*/
/**
* Validate email format - requires valid domain with 2+ char TLD
* @param email - Email address to validate
* @returns true if email is valid
*/
export function isValidEmail(email: string): boolean {
const trimmed = email.trim();
// Regex requires: user@domain.TLD (TLD must be 2+ chars)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
return emailRegex.test(trimmed);
}
/**
* Validate name format - letters, spaces, hyphens, apostrophes only
* @param name - Name to validate
* @param minLength - Minimum length (default: 2)
* @param maxLength - Maximum length (default: 50)
* @returns true if name is valid
*/
export function isValidName(name: string, minLength = 2, maxLength = 50): boolean {
const trimmed = name.trim();
if (trimmed.length < minLength || trimmed.length > maxLength) {
return false;
}
// Allow letters, spaces, hyphens, and apostrophes
const nameRegex = /^[a-zA-Z\s\-']+$/;
return nameRegex.test(trimmed);
}
/**
* Password strength check
* @param password - Password to check
* @param confirmPassword - Confirmation password (optional)
* @returns Object with individual check results
*/
export interface PasswordChecks {
minLength: boolean; // 8+ characters
uppercase: boolean; // At least one A-Z
lowercase: boolean; // At least one a-z
number: boolean; // At least one 0-9
special: boolean; // At least one special character
match: boolean; // Matches confirmation password
}
export function checkPasswordStrength(
password: string,
confirmPassword?: string
): PasswordChecks {
return {
minLength: password.length >= 8,
uppercase: /[A-Z]/.test(password),
lowercase: /[a-z]/.test(password),
number: /[0-9]/.test(password),
special: /[^A-Za-z0-9]/.test(password),
match: confirmPassword ? password === confirmPassword : true,
};
}
/**
* Check if password is strong (all requirements met)
* @param checks - PasswordChecks object
* @returns true if all checks pass
*/
export function isPasswordStrong(checks: PasswordChecks): boolean {
return (
checks.minLength &&
checks.uppercase &&
checks.lowercase &&
checks.number &&
checks.special &&
checks.match
);
}
/**
* Validate CAPTCHA input
* @param input - User's captcha input
* @param expected - Expected captcha value
* @returns true if captcha matches (case-insensitive)
*/
export function isValidCaptcha(input: string, expected: string): boolean {
return input.trim().toUpperCase() === expected.toUpperCase();
}
/**
* Get user-friendly error message for validation failure
* @param fieldName - Name of the field
* @param validationType - Type of validation that failed
* @returns User-friendly error message
*/
export function getValidationErrorMessage(fieldName: string, validationType: string): string {
const messages: Record<string, Record<string, string>> = {
email: {
invalid: 'Please enter a valid email address with a domain (e.g., user@example.com)',
required: 'Email address is required',
},
firstName: {
invalid: 'First name must be 2-50 characters and contain only letters, spaces, hyphens, or apostrophes',
required: 'First name is required',
tooShort: 'First name must be at least 2 characters',
tooLong: 'First name must not exceed 50 characters',
},
lastName: {
invalid: 'Last name must be 2-50 characters and contain only letters, spaces, hyphens, or apostrophes',
required: 'Last name is required',
tooShort: 'Last name must be at least 2 characters',
tooLong: 'Last name must not exceed 50 characters',
},
fullName: {
invalid: 'Full name must be 2-50 characters and contain only letters, spaces, hyphens, or apostrophes',
required: 'Full name is required',
},
password: {
required: 'Password is required',
weak: 'Password must contain: 8+ characters, uppercase, lowercase, number, and special character',
tooShort: 'Password must be at least 8 characters',
noUppercase: 'Password must contain at least one uppercase letter',
noLowercase: 'Password must contain at least one lowercase letter',
noNumber: 'Password must contain at least one number',
noSpecial: 'Password must contain at least one special character',
},
confirmPassword: {
required: 'Please confirm your password',
noMatch: 'Passwords do not match',
},
captcha: {
required: 'Captcha is required',
invalid: 'Captcha does not match. Please try again.',
},
terms: {
required: 'You must agree to the Terms and Privacy Policy to continue',
},
};
return messages[fieldName]?.[validationType] ?? 'Invalid input';
}
/**
* Validate entire register form
* @param formData - Object with form field values
* @returns Object with validation results and errors
*/
export interface RegisterFormData {
firstName: string;
lastName: string;
email: string;
password: string;
confirmPassword: string;
captcha: string;
expectedCaptcha: string;
termsAccepted: boolean;
}
export interface ValidationResult {
isValid: boolean;
errors: Record<string, string>;
}
export function validateRegisterForm(formData: RegisterFormData): ValidationResult {
const errors: Record<string, string> = {};
// Validate first name
if (!formData.firstName.trim()) {
errors.firstName = getValidationErrorMessage('firstName', 'required');
} else if (!isValidName(formData.firstName, 2, 50)) {
errors.firstName = getValidationErrorMessage('firstName', 'invalid');
}
// Validate last name
if (!formData.lastName.trim()) {
errors.lastName = getValidationErrorMessage('lastName', 'required');
} else if (!isValidName(formData.lastName, 2, 50)) {
errors.lastName = getValidationErrorMessage('lastName', 'invalid');
}
// Validate email
if (!formData.email.trim()) {
errors.email = getValidationErrorMessage('email', 'required');
} else if (!isValidEmail(formData.email)) {
errors.email = getValidationErrorMessage('email', 'invalid');
}
// Validate password
const passwordChecks = checkPasswordStrength(formData.password, formData.confirmPassword);
if (!formData.password) {
errors.password = getValidationErrorMessage('password', 'required');
} else if (!isPasswordStrong(passwordChecks)) {
errors.password = getValidationErrorMessage('password', 'weak');
}
// Validate confirm password
if (!formData.confirmPassword) {
errors.confirmPassword = getValidationErrorMessage('confirmPassword', 'required');
} else if (!passwordChecks.match) {
errors.confirmPassword = getValidationErrorMessage('confirmPassword', 'noMatch');
}
// Validate captcha
if (!formData.captcha) {
errors.captcha = getValidationErrorMessage('captcha', 'required');
} else if (!isValidCaptcha(formData.captcha, formData.expectedCaptcha)) {
errors.captcha = getValidationErrorMessage('captcha', 'invalid');
}
// Validate terms acceptance
if (!formData.termsAccepted) {
errors.terms = getValidationErrorMessage('terms', 'required');
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}

View file

@ -1,216 +1,4 @@
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 },
] },
],
},
];
// No hardcoded fallbacks — all onboarding schemas are loaded from the Rust backend runtime config.
export const SEEDED_ONBOARDING_SCHEMAS: RuntimeOnboardingConfig[] = [];

View file

@ -29,3 +29,13 @@ export function withAuthHeaders(request: Request, extra?: Record<string, string>
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}
// Alias for forwardAuth (used in some API endpoints)
export const forwardAuth = withAuthHeaders;
// Forward cookies from request
export function forwardCookies(request: Request): Record<string, string> {
const cookie = request.headers.get('cookie');
if (!cookie) return {};
return { cookie };
}

View file

@ -38,13 +38,14 @@ export default function AboutPage() {
const [activeChapter, setActiveChapter] = createSignal(0);
const [scrollY, setScrollY] = createSignal(0);
const [heroVisible, setHeroVisible] = createSignal(false);
const [problemVisible, setProblemVisible] = createSignal(false);
const [builtVisible, setBuiltVisible] = createSignal(false);
const [trustVisible, setTrustVisible] = createSignal(false);
const [principlesVisible, setPrinciplesVisible] = createSignal(false);
const [timelineVisible, setTimelineVisible] = createSignal(false);
const [closingVisible, setClosingVisible] = createSignal(false);
// Initialize visible for SSR - all content renders on server, client hydration just updates visibility for animations
const [heroVisible, setHeroVisible] = createSignal(true);
const [problemVisible, setProblemVisible] = createSignal(true);
const [builtVisible, setBuiltVisible] = createSignal(true);
const [trustVisible, setTrustVisible] = createSignal(true);
const [principlesVisible, setPrinciplesVisible] = createSignal(true);
const [timelineVisible, setTimelineVisible] = createSignal(true);
const [closingVisible, setClosingVisible] = createSignal(true);
const [problemProgress, setProblemProgress] = createSignal(0);
const [trustProgress, setTrustProgress] = createSignal(0);
@ -176,6 +177,35 @@ export default function AboutPage() {
const stateTwoUnderline = createMemo(() => progressBetween(effectivePrincipleProgress(), 0.26, 0.46));
const stateThreeLine = createMemo(() => progressBetween(effectivePrincipleProgress(), 0.52, 0.74));
// Calculate brightness for each narrative item as it passes through center
const getNarrativeItemOpacity = (stageIdx: number) => {
const p = effectivePrincipleProgress();
const stageStart = stageIdx * 0.25;
const stageEnd = (stageIdx + 1) * 0.25;
// Fade in/out boundaries (slight buffer before/after stage)
const fadeInStart = Math.max(0, stageStart - 0.05);
const fadeOutEnd = Math.min(1, stageEnd + 0.05);
// Outside the visible range = dim
if (p < fadeInStart || p > fadeOutEnd) {
return 0.35;
}
// Fade in from dimmed to bright
if (p < stageStart) {
return 0.35 + (1 - 0.35) * ((p - fadeInStart) / (stageStart - fadeInStart));
}
// In the center stage = fully bright
if (p <= stageEnd) {
return 1;
}
// Fade out from bright to dimmed
return 1 - ((p - stageEnd) / (fadeOutEnd - stageEnd)) * (1 - 0.35);
};
return (
<main class="lp-main about-page-root">
<div class="lp-bg" aria-hidden="true">
@ -460,7 +490,13 @@ export default function AboutPage() {
<div class="about-narrative-stack">
<For each={chapterFourNarrative}>
{(line, idx) => (
<div class={principleStage() === idx() ? 'about-narrative-item-active' : 'about-narrative-item-inactive'}>
<div
class={principleStage() === idx() ? 'about-narrative-item-active' : 'about-narrative-item-inactive'}
style={{
opacity: getNarrativeItemOpacity(idx()),
'text-shadow': `0 0 ${Math.max(0, (getNarrativeItemOpacity(idx()) - 0.35) * 8)}px rgba(253, 98, 22, 0.3)`,
}}
>
<p class="about-narrative-headline">
{idx() === 1 ? (
<>

View file

@ -0,0 +1,83 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
/**
* Generic gateway proxy endpoint
* Forwards all requests to the Rust backend gateway with proper auth headers
* Usage: /api/gateway/api/companies/jobs forwards to gateway /api/companies/jobs
*/
export async function GET({ request, params }: { request: Request; params: any }) {
return proxyRequest('GET', request, params);
}
export async function POST({ request, params }: { request: Request; params: any }) {
return proxyRequest('POST', request, params);
}
export async function PUT({ request, params }: { request: Request; params: any }) {
return proxyRequest('PUT', request, params);
}
export async function DELETE({ request, params }: { request: Request; params: any }) {
return proxyRequest('DELETE', request, params);
}
export async function PATCH({ request, params }: { request: Request; params: any }) {
return proxyRequest('PATCH', request, params);
}
async function proxyRequest(method: string, request: Request, params: any) {
try {
// Handle different param structures
let pathArray = params.path;
if (!Array.isArray(pathArray)) {
pathArray = [pathArray];
}
const path = `/${pathArray.join('/')}`;
// Preserve query string
const url = new URL(request.url);
const queryString = url.search ? url.search : '';
// Build request body if needed
let body: string | undefined;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
body = await request.text();
}
// Forward to gateway
const upstreamUrl = gatewayUrl(path + queryString);
const upstreamRequest = new Request(upstreamUrl, {
method,
headers: withAuthHeaders(request, {
'Content-Type': request.headers.get('Content-Type') || 'application/json',
}),
body,
cache: 'no-store',
});
const response = await fetch(upstreamRequest);
// Copy response headers and return
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
if (!['server', 'transfer-encoding', 'connection'].includes(key.toLowerCase())) {
responseHeaders.set(key, value);
}
});
responseHeaders.set('Content-Type', 'application/json');
const responseBody = await response.text();
return new Response(responseBody, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error: any) {
return new Response(
JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
}

View file

@ -0,0 +1,24 @@
import { gatewayUrl, forwardAuth } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
try {
const res = await fetch(gatewayUrl('/api/runtime-config'), {
method: 'GET',
headers: {
...forwardAuth(request),
},
});
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -1,21 +1,91 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
// Each role has its own separate service with its own PATCH /profile/me endpoint.
const ROLE_PROFILE_PATHS: Record<string, string> = {
PHOTOGRAPHER: '/photographers/profile/me',
MAKEUP_ARTIST: '/makeup-artists/profile/me',
TUTOR: '/tutors/profile/me',
DEVELOPER: '/developers/profile/me',
VIDEO_EDITOR: '/video-editors/profile/me',
GRAPHIC_DESIGNER: '/graphic-designers/profile/me',
SOCIAL_MEDIA_MANAGER: '/social-media-managers/profile/me',
FITNESS_TRAINER: '/fitness-trainers/profile/me',
CATERING_SERVICES: '/catering-services/profile/me',
COMPANY: '/companies/profile/me',
JOB_SEEKER: '/job-seekers/profile/me',
CUSTOMER: '/customers/profile/me',
};
/**
* Build the typed profile payload for each profession service.
* All professions share (display_name, bio, location, custom_data).
* Catering uses "business_name" instead of "display_name".
* custom_data stores the entire form so nothing is ever lost.
*/
function buildProfilePayload(roleKey: string, data: Record<string, unknown>) {
const bio = data.bio ? String(data.bio) : undefined;
const location = data.city ? String(data.city) : undefined;
const name = data.full_name ? String(data.full_name): undefined;
if (roleKey === 'CATERING_SERVICES') {
return {
business_name: data.business_name ? String(data.business_name) : name,
bio,
location,
custom_data: data,
};
}
return {
display_name: name,
bio,
location,
custom_data: data,
};
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const roleKey = String(body?.roleKey || '').trim();
const body = await request.json().catch(() => ({}));
const roleKey = String(body?.roleKey || '').trim().toUpperCase();
const requiresApproval = body?.requiresApproval !== false;
const dataJson = body?.dataJson as Record<string, unknown> | undefined;
const upstream = await fetch(gatewayUrl('/me/onboarding-state/complete'), {
const authHeaders = withAuthHeaders(request, {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-portal-target': 'public',
});
// ── Step 1: Save profile data to the role-specific service ─────────────
// Every profession is a separate microservice — PHOTOGRAPHER → photographers,
// MAKEUP_ARTIST → makeup-artists, etc. Nothing goes to a generic /professionals.
const profilePath = ROLE_PROFILE_PATHS[roleKey];
if (profilePath && dataJson) {
const profilePayload = buildProfilePayload(roleKey, dataJson);
const profileRes = await fetch(gatewayUrl(profilePath), {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify(profilePayload),
cache: 'no-store',
});
if (!profileRes.ok) {
const err = await profileRes.json().catch(() => ({}));
return new Response(
JSON.stringify({ success: false, error: err?.message || err?.error || `Failed to save ${roleKey} profile` }),
{ status: profileRes.status, headers: { 'Content-Type': 'application/json' } },
);
}
}
// ── Step 2: Mark onboarding complete in users service ──────────────────
const upstream = await fetch(gatewayUrl('/onboarding/submit'), {
method: 'POST',
headers: withAuthHeaders(request, {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-portal-target': 'public',
}),
headers: authHeaders,
body: JSON.stringify({
...(roleKey ? { roleKey } : {}),
requiresApproval,
roleKey,
progress_json: dataJson ?? {},
}),
cache: 'no-store',
});
@ -23,10 +93,7 @@ export async function POST({ request }: { request: Request }) {
const payload = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return new Response(
JSON.stringify({
success: false,
error: payload?.message || payload?.error || 'Failed to complete onboarding',
}),
JSON.stringify({ success: false, error: payload?.message || payload?.error || 'Failed to complete onboarding' }),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}

View file

@ -3,12 +3,12 @@ import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const roleKey = String(body?.roleKey || '').trim();
const currentStep = Number(body?.currentStep || 0);
const totalSteps = Number(body?.totalSteps || 0);
const dataJson = body?.dataJson;
const roleKey = String(body?.roleKey || '').trim();
const currentStep = Number(body?.currentStep ?? 0);
const totalSteps = Number(body?.totalSteps ?? 0);
const dataJson = body?.dataJson ?? {};
const upstream = await fetch(gatewayUrl('/me/onboarding-state/progress'), {
const upstream = await fetch(gatewayUrl('/onboarding/save-progress'), {
method: 'POST',
headers: withAuthHeaders(request, {
'Content-Type': 'application/json',
@ -16,10 +16,12 @@ export async function POST({ request }: { request: Request }) {
'x-portal-target': 'public',
}),
body: JSON.stringify({
...(roleKey ? { roleKey } : {}),
currentStep: Number.isFinite(currentStep) ? currentStep : 0,
totalSteps: Number.isFinite(totalSteps) ? totalSteps : 0,
...(dataJson ? { dataJson } : {}),
roleKey,
progress_json: {
step: Number.isFinite(currentStep) ? currentStep : 0,
total: Number.isFinite(totalSteps) ? totalSteps : 0,
data: dataJson,
},
}),
cache: 'no-store',
});
@ -27,10 +29,7 @@ export async function POST({ request }: { request: Request }) {
const payload = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return new Response(
JSON.stringify({
success: false,
error: payload?.message || payload?.error || 'Failed to update onboarding progress',
}),
JSON.stringify({ success: false, error: payload?.message || payload?.error || 'Failed to save progress' }),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}

View file

@ -3,8 +3,7 @@ const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080
export async function GET({ request }: { request: Request }) {
try {
const url = new URL(request.url);
const schemaId = String(url.searchParams.get('schemaId') || '').trim();
const roleKey = String(url.searchParams.get('roleKey') || '').trim();
const roleKey = String(url.searchParams.get('roleKey') || '').trim().toUpperCase();
if (!roleKey) {
return new Response(JSON.stringify({ success: false, error: 'roleKey is required' }), {
@ -13,28 +12,16 @@ export async function GET({ request }: { request: Request }) {
});
}
// 1. Fetch Role ID from the Rust API
const roleRes = await fetch(`${RUST_API_URL}/api/admin/roles/${roleKey}`);
if (!roleRes.ok) {
return new Response(JSON.stringify({ success: false, error: 'Role not found in backend' }), {
status: roleRes.status,
headers: { 'Content-Type': 'application/json' },
});
}
const role = await roleRes.json();
// 2. Fetch the Active Onboarding Config for that Role
const configRes = await fetch(`${RUST_API_URL}/api/admin/onboarding-config/${role.id}`);
const configRes = await fetch(`${RUST_API_URL}/api/admin/onboarding-config/by-key/${roleKey}`);
if (!configRes.ok) {
return new Response(JSON.stringify({ success: false, error: 'Active onboarding config not found for this role' }), {
status: configRes.status,
headers: { 'Content-Type': 'application/json' },
});
return new Response(JSON.stringify({ success: false, error: 'Onboarding config not found for this role' }), {
status: configRes.status,
headers: { 'Content-Type': 'application/json' },
});
}
const config = await configRes.json();
// 3. Return the schema_json exactly as expected by the frontend
return new Response(JSON.stringify({ success: true, data: config.schema_json }), {
status: 200,
headers: { 'Content-Type': 'application/json' },

View file

@ -5,31 +5,35 @@ export async function GET({ request }: { request: Request }) {
const url = new URL(request.url);
const roleKey = String(url.searchParams.get('roleKey') || '').trim();
const upstreamUrl = roleKey
? `${gatewayUrl('/me/onboarding-state')}?${new URLSearchParams({ roleKey }).toString()}`
: gatewayUrl('/me/onboarding-state');
const upstream = await fetch(upstreamUrl, {
method: 'GET',
headers: withAuthHeaders(request, { Accept: 'application/json', 'x-portal-target': 'public' }),
cache: 'no-store',
});
const upstream = await fetch(
`${gatewayUrl('/onboarding/state')}${roleKey ? `?roleKey=${encodeURIComponent(roleKey)}` : ''}`,
{
method: 'GET',
headers: withAuthHeaders(request, { Accept: 'application/json', 'x-portal-target': 'public' }),
cache: 'no-store',
},
);
const payload = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return new Response(
JSON.stringify({
success: false,
error: payload?.message || payload?.error || 'Failed to load onboarding state',
}),
JSON.stringify({ success: false, error: payload?.message || payload?.error || 'Failed to load onboarding state' }),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ success: true, data: payload }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// Normalise the response so the frontend always sees { status, currentStep }
return new Response(
JSON.stringify({
success: true,
data: {
status: payload?.status ?? 'NOT_STARTED',
currentStep: payload?.currentStep ?? 0,
progress: payload?.progress ?? null,
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,

View file

@ -0,0 +1,32 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
try {
const upstream = await fetch(gatewayUrl('/api/onboarding/status'), {
method: 'GET',
headers: withAuthHeaders(request, { Accept: 'application/json' }),
cache: 'no-store',
});
const payload = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return new Response(
JSON.stringify({
success: false,
error: payload?.message || payload?.error || 'Failed to load onboarding status',
}),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ success: true, data: payload }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,39 @@
const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080';
export async function POST({ request }: { request: Request }) {
try {
const payload = await request.json().catch(() => ({}));
const email = String(payload?.email || '').trim().toLowerCase();
if (!email) {
return new Response(JSON.stringify({ success: false, error: 'Email is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const res = await fetch(`${RUST_API_URL}/api/auth/check-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return new Response(JSON.stringify({ success: false, error: data?.error || 'Failed to check email' }), {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true, exists: Boolean(data?.exists) }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,41 @@
import { gatewayUrl, forwardAuth, forwardCookies } from '~/lib/server/gateway';
export async function POST({ request }: { request: Request }) {
try {
const res = await fetch(gatewayUrl('/api/auth/logout'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...forwardAuth(request),
...forwardCookies(request),
},
});
const data = await res.json().catch(() => ({}));
// Always return success — clear the cookie regardless
const responseHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
const setCookie = res.headers.get('set-cookie');
if (setCookie) {
responseHeaders['set-cookie'] = setCookie;
} else {
// Ensure cookie is cleared even if upstream fails
responseHeaders['set-cookie'] =
'nxtgauge_refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0';
}
return new Response(JSON.stringify({ success: true, message: data.message || 'Logged out' }), {
status: 200,
headers: responseHeaders,
});
} catch {
return new Response(JSON.stringify({ success: true, message: 'Logged out' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'set-cookie':
'nxtgauge_refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0',
},
});
}
}

View file

@ -0,0 +1,38 @@
import { gatewayUrl, forwardCookies } from '~/lib/server/gateway';
export async function POST({ request }: { request: Request }) {
try {
// Forward the httpOnly cookie to the gateway refresh endpoint
const res = await fetch(gatewayUrl('/api/auth/refresh'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...forwardCookies(request),
},
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return new Response(JSON.stringify({ success: false, error: data.error || 'Token refresh failed' }), {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
// Forward the rotated httpOnly cookie back to the browser
const responseHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
const setCookie = res.headers.get('set-cookie');
if (setCookie) responseHeaders['set-cookie'] = setCookie;
return new Response(JSON.stringify({ success: true, ...data }), {
status: 200,
headers: responseHeaders,
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,24 @@
import { gatewayUrl, forwardAuth } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
try {
const res = await fetch(gatewayUrl('/api/auth/session'), {
method: 'GET',
headers: {
...forwardAuth(request),
},
});
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,32 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const res = await fetch(gatewayUrl('/api/auth/switch-role'), {
method: 'POST',
headers: withAuthHeaders(request),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return new Response(
JSON.stringify({ success: false, error: data.error || 'Failed to switch role' }),
{ status: res.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ success: true, ...data }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,40 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const { email } = body as { email?: string };
if (!email) {
return new Response(
JSON.stringify({ success: false, error: 'Email is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
const res = await fetch(gatewayUrl('/api/auth/resend-otp'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return new Response(
JSON.stringify({ success: false, error: data.error || 'Failed to resend OTP' }),
{ status: res.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(
JSON.stringify({ success: true, message: data.message || 'OTP resent successfully.' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
} catch (error: any) {
return new Response(
JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
}

View file

@ -0,0 +1,40 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const { roleKey } = body as { roleKey?: string };
if (!roleKey) {
return new Response(
JSON.stringify({ success: false, error: 'Role key is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
const res = await fetch(gatewayUrl('/api/me/roles/register'), {
method: 'POST',
headers: withAuthHeaders(request),
body: JSON.stringify({ role_key: roleKey }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return new Response(
JSON.stringify({ success: false, error: data.error || 'Failed to register role' }),
{ status: res.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(
JSON.stringify({ success: true, message: data.message || 'Role registered successfully.' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
} catch (error: any) {
return new Response(
JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
}

View file

@ -1,17 +1,15 @@
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, onMount } from 'solid-js';
import { intentToOnboardingPath, normalizeIntent, readCanonicalIntent, saveCanonicalIntent } from '~/lib/auth-intent';
import { isValidEmail, isValidCaptcha } from '~/lib/form-validation';
import PublicHeader from '~/components/PublicHeader';
import CaptchaCanvas from '~/components/CaptchaCanvas';
function makeCaptcha() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
function PasswordVisibilityIcon(props: { visible: boolean }) {
if (props.visible) {
return (
@ -37,7 +35,10 @@ export default function LoginPage() {
const intent = normalizeIntent(search.intent || search.intentRole);
const redirect = search.redirect;
const safeRedirect = redirect && redirect.startsWith('/') ? redirect : null;
const postLoginTarget = safeRedirect || intentToOnboardingPath(intent || readCanonicalIntent());
const postLoginTarget = safeRedirect || (() => {
const resolvedIntent = intent || readCanonicalIntent();
return resolvedIntent ? intentToOnboardingPath(resolvedIntent) : '/dashboard';
})();
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
@ -85,18 +86,29 @@ export default function LoginPage() {
const handleLogin = async () => {
setError('');
if (!email().trim() || !password().trim() || !captchaInput().trim()) {
setError('Please fill all fields.');
// Validate email
if (!email().trim()) {
setError('Email address is required.');
return;
}
if (!isValidEmail(email())) {
setError('Please enter a valid email address (e.g., user@example.com).');
return;
}
if (!emailValid()) {
setError('Please enter a valid email address.');
// Validate password
if (!password().trim()) {
setError('Password is required.');
return;
}
if (captchaInput().trim().toUpperCase() !== captcha()) {
setError('Captcha does not match.');
// Validate captcha
if (!captchaInput().trim()) {
setError('Please enter the captcha.');
return;
}
if (!isValidCaptcha(captchaInput(), captcha())) {
setError('Captcha does not match. Please try again.');
setCaptcha(makeCaptcha());
setCaptchaInput('');
return;
@ -176,14 +188,16 @@ export default function LoginPage() {
<p class="subtitle">Use your external account credentials to continue.</p>
<div class="field">
<label class="label">Email</label>
<label class="label">EMAIL</label>
<input class="input" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} placeholder="Enter your email" />
<p class="note">{emailValid() ? '✓ Valid email format' : '• Enter a valid email format'}</p>
<p class="validation-note" style={{ color: email().trim() && isValidEmail(email()) ? '#fd6216' : '#6e7591' }}>
{email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format'}
</p>
</div>
<div class="field">
<div class="auth-field-head">
<label class="label">Password</label>
<label class="label">PASSWORD</label>
<A class="auth-forgot-link" href="/auth/forgot-password">Forgot?</A>
</div>
<div class="auth-password-wrap">
@ -206,12 +220,12 @@ export default function LoginPage() {
</div>
<div class="field">
<label class="label">Captcha</label>
<label class="label">CAPTCHA</label>
<div class="auth-captcha-row">
<button class="auth-captcha-refresh" type="button" onClick={() => { setCaptcha(makeCaptcha()); setCaptchaInput(''); }}>
</button>
<div class="auth-captcha-code">{captcha()}</div>
<CaptchaCanvas code={captcha()} class="auth-captcha-canvas" />
<input class="input" value={captchaInput()} onInput={(e) => setCaptchaInput(e.currentTarget.value)} placeholder="Enter captcha" />
</div>
</div>
@ -223,7 +237,7 @@ export default function LoginPage() {
</button>
<div class="auth-footer-row">
<p class="note">Secure login with email verification.</p>
<p class="footer-text">Secure login with email verification.</p>
<p class="note">New user? <A href={signUpHref()}>Sign Up</A></p>
</div>
</section>

View file

@ -2,6 +2,8 @@ import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, onMount } from 'solid-js';
import { intentToOnboardingPath, normalizeIntent, saveCanonicalIntent } from '~/lib/auth-intent';
import PublicHeader from '~/components/PublicHeader';
import CaptchaCanvas from '~/components/CaptchaCanvas';
import { isValidEmail, isValidName, checkPasswordStrength, isPasswordStrong, isValidCaptcha } from '~/lib/form-validation';
const PENDING_REGISTER_KEY = 'nxtgauge_pending_register_v1';
const DEV_VERIFICATION_CODE_KEY = 'nxtgauge_dev_verification_code_v1';
@ -11,10 +13,6 @@ const makeCaptcha = () => {
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
};
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
function normalizeProfessionalRole(value: string | null): string | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
@ -54,9 +52,10 @@ export default function RegisterPage() {
const redirectParam = search.redirect;
const safeRedirect = redirectParam && redirectParam.startsWith('/') ? redirectParam : null;
const professionalRole = normalizeProfessionalRole(search.profession || search.role || null);
const resolvedIntent = intentParam || 'customer';
const resolvedIntent = intentParam;
const onboardingTarget = createMemo(() => {
if (!resolvedIntent) return '/dashboard';
const base = intentToOnboardingPath(resolvedIntent);
if (resolvedIntent !== 'professional' || !professionalRole) return base;
return `${base}?profession=${encodeURIComponent(professionalRole)}`;
@ -82,7 +81,9 @@ export default function RegisterPage() {
const [confirmPassword, setConfirmPassword] = createSignal('');
const [captcha, setCaptcha] = createSignal('');
const [captchaInput, setCaptchaInput] = createSignal('');
const [termsAccepted, setTermsAccepted] = createSignal(false);
const [error, setError] = createSignal('');
const [emailExists, setEmailExists] = createSignal(false);
const [loading, setLoading] = createSignal(false);
const [showPassword, setShowPassword] = createSignal(false);
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
@ -114,34 +115,35 @@ export default function RegisterPage() {
.catch(() => {});
});
const checks = createMemo(() => ({
minLength: password().length >= 8,
uppercase: /[A-Z]/.test(password()),
lowercase: /[a-z]/.test(password()),
number: /[0-9]/.test(password()),
special: /[^A-Za-z0-9]/.test(password()),
match: confirmPassword().length > 0 && password() === confirmPassword(),
}));
const checks = createMemo(() => checkPasswordStrength(password(), confirmPassword()));
const emailValid = createMemo(() => isValidEmail(email()));
const passwordStrong = createMemo(() => {
const c = checks();
return c.minLength && c.uppercase && c.lowercase && c.number && c.special;
});
const firstNameValid = createMemo(() => !firstName().trim() || isValidName(firstName()));
const lastNameValid = createMemo(() => !lastName().trim() || isValidName(lastName()));
const emailValid = createMemo(() => !email().trim() || isValidEmail(email()));
const passwordStrong = createMemo(() => isPasswordStrong(checks()));
const canSubmit = createMemo(() => {
return (
firstName().trim().length > 0 &&
isValidName(firstName()) &&
lastName().trim().length > 0 &&
isValidName(lastName()) &&
emailValid() &&
isValidEmail(email()) &&
passwordStrong() &&
checks().match &&
captchaInput().trim().toUpperCase() === captcha()
isValidCaptcha(captchaInput(), captcha()) &&
termsAccepted()
);
});
const loginHref = createMemo(() =>
`/auth/login?intent=${encodeURIComponent(resolvedIntent || 'customer')}&redirect=${encodeURIComponent(resolvedRedirect())}`,
`/auth/login${(() => {
const next = new URLSearchParams();
if (resolvedIntent) next.set('intent', resolvedIntent);
next.set('redirect', resolvedRedirect());
return `?${next.toString()}`;
})()}`,
);
const refreshCaptcha = () => {
@ -149,20 +151,99 @@ export default function RegisterPage() {
setCaptchaInput('');
};
const checkEmailExists = async (emailValue: string) => {
const normalized = emailValue.trim().toLowerCase();
if (!normalized || !isValidEmail(normalized)) {
setEmailExists(false);
return false;
}
try {
const response = await fetch('/api/users/auth/check-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: normalized }),
});
const payload = await response.json().catch(() => ({}));
const exists = Boolean(response.ok && payload?.success && payload?.exists);
setEmailExists(exists);
return exists;
} catch {
setEmailExists(false);
return false;
}
};
const handleRegister = async () => {
setError('');
if (!canSubmit()) {
setError('Please complete all fields correctly.');
// Validate all fields
if (!firstName().trim()) {
setError('First name is required.');
return;
}
if (!isValidName(firstName())) {
setError('First name must be 2-50 characters and contain only letters, spaces, hyphens, or apostrophes.');
return;
}
if (captchaInput().trim().toUpperCase() !== captcha()) {
setError('Captcha does not match.');
if (!lastName().trim()) {
setError('Last name is required.');
return;
}
if (!isValidName(lastName())) {
setError('Last name must be 2-50 characters and contain only letters, spaces, hyphens, or apostrophes.');
return;
}
if (!email().trim()) {
setError('Email address is required.');
return;
}
if (!isValidEmail(email())) {
setError('Please enter a valid email address (e.g., user@example.com).');
return;
}
const exists = await checkEmailExists(email());
if (exists) {
setError('This email is already registered. Please sign in or use another email.');
return;
}
if (!password()) {
setError('Password is required.');
return;
}
if (!isPasswordStrong(checks())) {
setError('Password must contain: 8+ characters, uppercase, lowercase, number, and special character.');
return;
}
if (!confirmPassword()) {
setError('Please confirm your password.');
return;
}
if (!checks().match) {
setError('Passwords do not match.');
return;
}
if (!captchaInput().trim()) {
setError('Please enter the captcha.');
return;
}
if (!isValidCaptcha(captchaInput(), captcha())) {
setError('Captcha does not match. Please try again.');
refreshCaptcha();
return;
}
if (!termsAccepted()) {
setError('You must agree to the Terms and Privacy Policy to continue.');
return;
}
setLoading(true);
try {
@ -247,76 +328,119 @@ export default function RegisterPage() {
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<div class="field">
<label class="label">First Name</label>
<label class="label">FULL NAME</label>
<input class="input" value={firstName()} onInput={(e) => setFirstName(e.currentTarget.value)} />
<p class="validation-note" style={{ color: firstName().trim() && isValidName(firstName()) ? '#fd6216' : '#6e7591' }}>
{firstName().trim() && isValidName(firstName()) ? '✓ First name looks good' : '• First name is required'}
</p>
</div>
<div class="field">
<label class="label">Last Name</label>
<label class="label">LAST NAME</label>
<input class="input" value={lastName()} onInput={(e) => setLastName(e.currentTarget.value)} />
<p class="validation-note" style={{ color: lastName().trim() && isValidName(lastName()) ? '#fd6216' : '#6e7591' }}>
{lastName().trim() && isValidName(lastName()) ? '✓ Last name looks good' : '• Last name is required'}
</p>
</div>
</div>
<div class="field">
<label class="label">Email</label>
<input class="input" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
<p class="note">{emailValid() ? '✓ Valid email format' : '• Enter a valid email format'}</p>
<label class="label">EMAIL ADDRESS</label>
<input
class="input"
value={email()}
onInput={(e) => {
setEmail(e.currentTarget.value);
setEmailExists(false);
}}
onBlur={() => {
void checkEmailExists(email());
}}
/>
<p class="validation-note" style={{ color: emailExists() ? '#dc2626' : (email().trim() && isValidEmail(email()) ? '#fd6216' : '#6e7591') }}>
{emailExists()
? '• This email is already registered'
: (email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format')}
</p>
</div>
<div class="field">
<label class="label">Password</label>
<div class="auth-password-wrap">
<input class="input" type={showPassword() ? 'text' : 'password'} value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
<button
class="auth-toggle-visibility"
type="button"
onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword() ? 'Hide password' : 'Show password'}
>
<PasswordVisibilityIcon visible={showPassword()} />
</button>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<div class="field">
<label class="label">PASSWORD</label>
<div class="auth-password-wrap">
<input class="input" type={showPassword() ? 'text' : 'password'} value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
<button
class="auth-toggle-visibility"
type="button"
onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword() ? 'Hide password' : 'Show password'}
>
<PasswordVisibilityIcon visible={showPassword()} />
</button>
</div>
<div class="password-strength-grid">
<p style={{ color: checks().minLength ? '#fd6216' : '#6e7591' }}>{checks().minLength ? '✓' : '•'} 8+ chars</p>
<p style={{ color: checks().uppercase ? '#fd6216' : '#6e7591' }}>{checks().uppercase ? '✓' : '•'} Uppercase</p>
<p style={{ color: checks().special ? '#fd6216' : '#6e7591' }}>{checks().special ? '✓' : '•'} Special</p>
<p style={{ color: checks().lowercase ? '#fd6216' : '#6e7591' }}>{checks().lowercase ? '✓' : '•'} Lowercase</p>
<p style={{ color: checks().number ? '#fd6216' : '#6e7591' }}>{checks().number ? '✓' : '•'} Number</p>
</div>
</div>
<div class="field">
<label class="label">CONFIRM PASSWORD</label>
<div class="auth-password-wrap">
<input class="input" type={showConfirmPassword() ? 'text' : 'password'} value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} />
<button
class="auth-toggle-visibility"
type="button"
onClick={() => setShowConfirmPassword((prev) => !prev)}
aria-label={showConfirmPassword() ? 'Hide password' : 'Show password'}
>
<PasswordVisibilityIcon visible={showConfirmPassword()} />
</button>
</div>
<p class="validation-note" style={{ color: confirmPassword() && checks().match ? '#fd6216' : '#6e7591' }}>
{confirmPassword() && checks().match ? '✓ Passwords match' : '• Passwords do not match'}
</p>
</div>
</div>
<div class="field">
<label class="label">Confirm Password</label>
<div class="auth-password-wrap">
<input class="input" type={showConfirmPassword() ? 'text' : 'password'} value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} />
<button
class="auth-toggle-visibility"
type="button"
onClick={() => setShowConfirmPassword((prev) => !prev)}
aria-label={showConfirmPassword() ? 'Hide password' : 'Show password'}
>
<PasswordVisibilityIcon visible={showConfirmPassword()} />
</button>
</div>
</div>
<div class="field">
<label class="label">Captcha</label>
<label class="label">CAPTCHA</label>
<div class="auth-captcha-row">
<button class="auth-captcha-refresh" type="button" onClick={refreshCaptcha}></button>
<div class="auth-captcha-code">{captcha()}</div>
<CaptchaCanvas code={captcha()} class="auth-captcha-canvas" />
<input class="input" value={captchaInput()} onInput={(e) => setCaptchaInput(e.currentTarget.value)} placeholder="Enter captcha" />
</div>
<p class="validation-note" style={{ color: captchaInput() && isValidCaptcha(captchaInput(), captcha()) ? '#fd6216' : '#6e7591' }}>
{captchaInput() ? (isValidCaptcha(captchaInput(), captcha()) ? '✓ Captcha matched' : '• Captcha does not match') : '• Enter captcha to continue'}
</p>
</div>
<div class="note">
<div>{checks().minLength ? '✓' : '•'} 8+ chars</div>
<div>{checks().uppercase ? '✓' : '•'} uppercase</div>
<div>{checks().lowercase ? '✓' : '•'} lowercase</div>
<div>{checks().number ? '✓' : '•'} number</div>
<div>{checks().special ? '✓' : '•'} special character</div>
<div>{checks().match ? '✓' : '•'} passwords match</div>
<div class="field" style={{ 'margin-top': '16px' }}>
<label class="auth-checkbox-wrapper">
<input
type="checkbox"
checked={termsAccepted()}
onChange={(e) => setTermsAccepted(e.currentTarget.checked)}
class="auth-checkbox"
/>
<span class="auth-checkbox-label">
I agree to the <A href="/terms" target="_blank">Terms and Conditions</A> and <A href="/privacy" target="_blank">Privacy Policy</A>
</span>
</label>
</div>
{error() && <p class="error">{error()}</p>}
<button class="auth-submit-btn" disabled={!canSubmit() || loading()} onClick={handleRegister}>
{loading() ? 'Please wait...' : 'Create Account'}
{loading() ? 'Creating Account...' : 'Sign Up'}
</button>
<p class="note">Already have an account? <A href={loginHref()}>Sign In</A></p>
<div class="auth-footer-row">
<p class="footer-text">We will send a verification code to your email.</p>
<p class="note">Already have an account? <A href={loginHref()}>Sign In</A></p>
</div>
</section>
</div>
</main>

View file

@ -59,7 +59,7 @@ export default function VerificationPage() {
};
const resolvedIntent = createMemo(() => intent() || readCanonicalIntent());
const registerTarget = createMemo(() => intentToOnboardingPath(resolvedIntent()));
const registerTarget = createMemo(() => (resolvedIntent() ? intentToOnboardingPath(resolvedIntent()) : '/dashboard'));
const [otp, setOtp] = createSignal(Array.from({ length: OTP_LENGTH }, () => ''));
const [timer, setTimer] = createSignal(30);
@ -171,10 +171,10 @@ export default function VerificationPage() {
setInfo('');
try {
const response = await fetch('/api/users/auth/login', {
const response = await fetch('/api/users/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email(), password: '-' }), // We will bypass verification for now, just auto login if you hit "Verify"
body: JSON.stringify({ email: email(), code, flow: flow() }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {

View file

@ -1,6 +1,6 @@
import { createSignal, Show, For } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { authState, switchRole } from '~/lib/auth';
import { authState } from '~/lib/auth';
const ALL_ROLES = [
{ key: 'COMPANY', label: 'Company', icon: '🏢', desc: 'Post jobs and hire talent' },
@ -38,7 +38,7 @@ export default function ChooseRole() {
});
if (res.ok) {
navigate('/onboarding', { replace: true });
navigate(`/onboarding?roleKey=${encodeURIComponent(roleKey)}`, { replace: true });
} else {
const body = await res.json();
setError(body.error ?? 'Failed to register role');

View file

@ -0,0 +1,15 @@
import { A } from '@solidjs/router';
export default function DashboardExplorePage() {
return (
<section class="dashboard-card">
<h1>Explore Nxtgauge</h1>
<p class="dashboard-muted">
Add an additional role to unlock more modules in your dashboard.
</p>
<p>
Go to <A href="/choose-role">Choose Role</A> to register another role.
</p>
</section>
);
}

View file

@ -40,6 +40,16 @@ export default function DashboardIndex() {
{/* KPI cards — rendered based on role via runtimeConfig */}
<div class="kpi-grid">
<Show when={role() === 'USER'}>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--blue">🧭</div>
<div class="kpi-content">
<div class="kpi-value">Get Started</div>
<div class="kpi-label">Choose a role to unlock role-based onboarding and modules</div>
</div>
<A href="/dashboard/explore" class="kpi-link">Explore Nxtgauge </A>
</div>
</Show>
<Show when={role() === 'COMPANY'}>
<CompanyKPIs />
</Show>

View file

@ -0,0 +1,10 @@
export default function DashboardAcceptedLeadsPage() {
return (
<section class="dashboard-card">
<h1>Accepted Leads</h1>
<p class="dashboard-muted">
Accepted lead details and contact visibility are being finalized for the shared professional flow.
</p>
</section>
);
}

View file

@ -0,0 +1,15 @@
import { A } from '@solidjs/router';
export default function DashboardPackagesPage() {
return (
<section class="dashboard-card">
<h1>Packages</h1>
<p class="dashboard-muted">
Package purchase flow is being finalized in the Rust payments module.
</p>
<p>
You can review your current wallet in <A href="/dashboard/wallet">Wallet</A>.
</p>
</section>
);
}

View file

@ -0,0 +1,22 @@
import { Show } from 'solid-js';
import { authState } from '~/lib/auth';
export default function DashboardProfilePage() {
const rc = () => authState().runtime_config;
return (
<section class="dashboard-card">
<h1>Profile</h1>
<p class="dashboard-muted">Your active account details for this role.</p>
<Show when={rc()} fallback={<p class="dashboard-muted">Loading profile...</p>}>
<div class="kv-grid">
<div><strong>Name:</strong> {rc()?.user?.full_name || '—'}</div>
<div><strong>Email:</strong> {rc()?.user?.email || '—'}</div>
<div><strong>Active Role:</strong> {rc()?.user?.active_role || rc()?.role || '—'}</div>
<div><strong>Roles:</strong> {(rc()?.user?.roles || []).join(', ') || '—'}</div>
</div>
</Show>
</section>
);
}

View file

@ -0,0 +1,10 @@
export default function DashboardRequestsPage() {
return (
<section class="dashboard-card">
<h1>My Lead Requests</h1>
<p class="dashboard-muted">
Role-specific lead request listing is enabled on the backend and is being connected to this page.
</p>
</section>
);
}

View file

@ -1,5 +1,6 @@
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import { authState } from '~/lib/auth';
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeVisibilityCondition, UploadedFileMeta } from '~/lib/runtime/types';
function evaluateVisibility(conditions: RuntimeVisibilityCondition[] | undefined, values: Record<string, unknown>) {
@ -146,30 +147,35 @@ export default function OnboardingPage() {
const [profileStatus, setProfileStatus] = createSignal<string>('');
const requestedRoleKey = createMemo(() => normalizeRoleKey(Array.isArray(searchParams.roleKey) ? searchParams.roleKey[0] : searchParams.roleKey));
const activeRoleKey = createMemo(() =>
normalizeRoleKey(authState().runtime_config?.user?.active_role || authState().runtime_config?.role),
);
const effectiveRoleKey = createMemo(() => requestedRoleKey() || activeRoleKey());
const requestedProfession = createMemo(() => String((Array.isArray(searchParams.profession) ? searchParams.profession[0] : searchParams.profession) || '').trim());
const requestedSchemaId = createMemo(() => {
const fromQuery = String(searchParams.schemaId || '').trim();
if (fromQuery) return fromQuery;
return schemaIdFromInput(requestedRoleKey(), requestedProfession());
return schemaIdFromInput(effectiveRoleKey(), requestedProfession());
});
onMount(async () => {
try {
setLoading(true);
const schemaId = requestedSchemaId();
if (!schemaId) {
setStatusMessage('Missing schemaId/roleKey. Unable to load runtime onboarding schema.');
const roleKey = effectiveRoleKey();
if (!schemaId || !roleKey) {
setStatusMessage('Missing role information. Please select a role first.');
return;
}
const schemaResponse = await fetch(`/api/runtime/onboarding/schema?${new URLSearchParams({ schemaId, roleKey: requestedRoleKey() }).toString()}`);
const schemaResponse = await fetch(`/api/runtime/onboarding/schema?${new URLSearchParams({ schemaId, roleKey }).toString()}`);
const schemaPayload = await schemaResponse.json().catch(() => ({}));
if (!schemaResponse.ok || !schemaPayload?.success) {
setStatusMessage(schemaPayload?.error || 'Unable to load onboarding schema from backend.');
return;
}
const normalized = normalizeSchemaPayload(schemaPayload, schemaId, requestedRoleKey() || 'CUSTOMER');
const normalized = normalizeSchemaPayload(schemaPayload, schemaId, roleKey || 'CUSTOMER');
if (!normalized) {
setStatusMessage('Schema loaded but steps are missing.');
return;

View file

@ -0,0 +1,133 @@
import { useNavigate } from '@solidjs/router';
import { createSignal, For } from 'solid-js';
import PublicHeader from '~/components/PublicHeader';
type RoleOption = {
id: string;
title: string;
description: string;
icon: string;
intent: string;
isSubtype?: boolean;
};
const MAIN_ROLES: RoleOption[] = [
{
id: 'company',
title: 'Company',
description: 'Post jobs and hire verified professionals',
icon: '🏢',
intent: 'company',
},
{
id: 'job_seeker',
title: 'Job Seeker',
description: 'Browse and apply for job opportunities',
icon: '💼',
intent: 'job_seeker',
},
{
id: 'customer',
title: 'Customer',
description: 'Find verified professionals for your needs',
icon: '🛍️',
intent: 'customer',
},
];
const PROFESSIONAL_SUBTYPES: RoleOption[] = [
{ id: 'photographer', title: 'Photographer', description: 'Photography & Visual Content', icon: '📸', intent: 'professional', isSubtype: true },
{ id: 'makeup_artist', title: 'Makeup Artist', description: 'Makeup & Beauty Services', icon: '💄', intent: 'professional', isSubtype: true },
{ id: 'tutor', title: 'Tutor', description: 'Online & Offline Tutoring', icon: '📚', intent: 'professional', isSubtype: true },
{ id: 'developer', title: 'Developer', description: 'Software Development & Coding', icon: '💻', intent: 'professional', isSubtype: true },
{ id: 'video_editor', title: 'Video Editor', description: 'Video Editing & Production', icon: '🎬', intent: 'professional', isSubtype: true },
{ id: 'graphic_designer', title: 'Graphic Designer', description: 'Design & Branding Services', icon: '🎨', intent: 'professional', isSubtype: true },
{ id: 'social_media_manager', title: 'Social Media Manager', description: 'Social Media & Content Management', icon: '📱', intent: 'professional', isSubtype: true },
{ id: 'fitness_trainer', title: 'Fitness Trainer', description: 'Fitness & Personal Training', icon: '💪', intent: 'professional', isSubtype: true },
{ id: 'catering_services', title: 'Catering Services', description: 'Food & Catering Services', icon: '🍽️', intent: 'professional', isSubtype: true },
];
export default function ChooseRolePage() {
const navigate = useNavigate();
const [selectedRole, setSelectedRole] = createSignal<RoleOption | null>(null);
const handleSelectRole = (role: RoleOption) => {
setSelectedRole(role);
// Navigate to appropriate onboarding page
if (role.isSubtype) {
// Professional roles with subtype
navigate(`/users/onboarding/professional?profession=${role.id}&intent=${role.intent}`);
} else {
// Main roles
navigate(`/users/onboarding/${role.id}?intent=${role.intent}`);
}
};
return (
<main class="choose-role-page">
<div class="lp-bg" aria-hidden="true">
<div class="lp-dark-base" />
<div class="lp-mesh" />
<div class="lp-ribbon" />
<div class="lp-noise" />
</div>
<PublicHeader />
<div class="container choose-role-container">
<div class="choose-role-header">
<h1 class="choose-role-title">What would you like to do today?</h1>
<p class="choose-role-subtitle">Select your role to get started with Nxtgauge</p>
</div>
{/* Main Roles Section */}
<div class="choose-role-section">
<h2 class="section-title">Choose Your Primary Role</h2>
<div class="roles-grid main-roles-grid">
<For each={MAIN_ROLES}>
{(role) => (
<button
class="role-card"
classList={{ selected: selectedRole()?.id === role.id }}
onClick={() => handleSelectRole(role)}
>
<div class="role-icon">{role.icon}</div>
<h3 class="role-title">{role.title}</h3>
<p class="role-description">{role.description}</p>
<div class="role-cta">Select Role </div>
</button>
)}
</For>
</div>
</div>
{/* Professional Subtypes Section */}
<div class="choose-role-section">
<h2 class="section-title">Or Explore Professional Opportunities</h2>
<p class="section-subtitle">Register as a verified professional in your field</p>
<div class="roles-grid professional-roles-grid">
<For each={PROFESSIONAL_SUBTYPES}>
{(role) => (
<button
class="role-card professional-card"
classList={{ selected: selectedRole()?.id === role.id }}
onClick={() => handleSelectRole(role)}
>
<div class="role-icon">{role.icon}</div>
<h3 class="role-title">{role.title}</h3>
<p class="role-description">{role.description}</p>
<div class="role-cta">Select Role </div>
</button>
)}
</For>
</div>
</div>
<div class="choose-role-footer">
<p class="footer-text">You can always add more roles to your account later from your dashboard.</p>
</div>
</div>
</main>
);
}

View file

@ -16,8 +16,9 @@ export default function ProfessionalOnboardingRoute() {
onMount(() => {
const profession = normalizeProfession(search.profession || search.role || null);
const schemaId = profession ? `${profession}_onboarding_v1` : 'professional_onboarding_v1';
const roleKey = profession ? profession.toUpperCase() : 'PROFESSIONAL';
const params = new URLSearchParams({
roleKey: 'PROFESSIONAL',
roleKey,
schemaId,
});
if (profession) params.set('profession', profession);