feat(frontend): finalize onboarding, otp verification, and roleless dashboard flow
This commit is contained in:
parent
fee43e3655
commit
c435053810
36 changed files with 1572 additions and 418 deletions
244
src/app.css
244
src/app.css
|
|
@ -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 {
|
||||
|
|
|
|||
82
src/components/CaptchaCanvas.tsx
Normal file
82
src/components/CaptchaCanvas.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
219
src/lib/form-validation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
83
src/routes/api/gateway/[...path].ts
Normal file
83
src/routes/api/gateway/[...path].ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/routes/api/runtime-config.ts
Normal file
24
src/routes/api/runtime-config.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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' } },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
32
src/routes/api/runtime/onboarding/status.ts
Normal file
32
src/routes/api/runtime/onboarding/status.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
39
src/routes/api/users/auth/check-email.ts
Normal file
39
src/routes/api/users/auth/check-email.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
41
src/routes/api/users/auth/logout.ts
Normal file
41
src/routes/api/users/auth/logout.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
38
src/routes/api/users/auth/refresh.ts
Normal file
38
src/routes/api/users/auth/refresh.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/routes/api/users/auth/session.ts
Normal file
24
src/routes/api/users/auth/session.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/routes/api/users/auth/switch-role.ts
Normal file
32
src/routes/api/users/auth/switch-role.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
40
src/routes/api/users/auth/verification/resend-otp.ts
Normal file
40
src/routes/api/users/auth/verification/resend-otp.ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/routes/api/users/roles/register.ts
Normal file
40
src/routes/api/users/roles/register.ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
15
src/routes/dashboard/explore.tsx
Normal file
15
src/routes/dashboard/explore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
10
src/routes/dashboard/leads/accepted.tsx
Normal file
10
src/routes/dashboard/leads/accepted.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/routes/dashboard/packages.tsx
Normal file
15
src/routes/dashboard/packages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/routes/dashboard/profile.tsx
Normal file
22
src/routes/dashboard/profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/routes/dashboard/requests.tsx
Normal file
10
src/routes/dashboard/requests.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
133
src/routes/users/choose-role/index.tsx
Normal file
133
src/routes/users/choose-role/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue