diff --git a/public/images/roles/better_graphic.jpg b/public/images/roles/better_graphic.jpg new file mode 100644 index 0000000..6d015dd Binary files /dev/null and b/public/images/roles/better_graphic.jpg differ diff --git a/public/images/roles/better_job.jpg b/public/images/roles/better_job.jpg new file mode 100644 index 0000000..4c67052 Binary files /dev/null and b/public/images/roles/better_job.jpg differ diff --git a/public/images/roles/better_service.jpg b/public/images/roles/better_service.jpg new file mode 100644 index 0000000..883a531 Binary files /dev/null and b/public/images/roles/better_service.jpg differ diff --git a/public/images/roles/catering_services.jpg b/public/images/roles/catering_services.jpg new file mode 100644 index 0000000..e38fed1 Binary files /dev/null and b/public/images/roles/catering_services.jpg differ diff --git a/public/images/roles/company.jpg b/public/images/roles/company.jpg new file mode 100644 index 0000000..b090505 Binary files /dev/null and b/public/images/roles/company.jpg differ diff --git a/public/images/roles/developer.jpg b/public/images/roles/developer.jpg new file mode 100644 index 0000000..d83831a Binary files /dev/null and b/public/images/roles/developer.jpg differ diff --git a/public/images/roles/fitness_trainer.jpg b/public/images/roles/fitness_trainer.jpg new file mode 100644 index 0000000..4c67052 Binary files /dev/null and b/public/images/roles/fitness_trainer.jpg differ diff --git a/public/images/roles/graphic_designer.jpg b/public/images/roles/graphic_designer.jpg new file mode 100644 index 0000000..13ac876 Binary files /dev/null and b/public/images/roles/graphic_designer.jpg differ diff --git a/public/images/roles/job_seeker.jpg b/public/images/roles/job_seeker.jpg new file mode 100644 index 0000000..4c67052 Binary files /dev/null and b/public/images/roles/job_seeker.jpg differ diff --git a/public/images/roles/makeup_artist.jpg b/public/images/roles/makeup_artist.jpg new file mode 100644 index 0000000..c0161ab Binary files /dev/null and b/public/images/roles/makeup_artist.jpg differ diff --git a/public/images/roles/photographer.jpg b/public/images/roles/photographer.jpg new file mode 100644 index 0000000..83be023 Binary files /dev/null and b/public/images/roles/photographer.jpg differ diff --git a/public/images/roles/service_seeker.jpg b/public/images/roles/service_seeker.jpg new file mode 100644 index 0000000..4970d7e Binary files /dev/null and b/public/images/roles/service_seeker.jpg differ diff --git a/public/images/roles/social_media_manager.jpg b/public/images/roles/social_media_manager.jpg new file mode 100644 index 0000000..80e1aa6 Binary files /dev/null and b/public/images/roles/social_media_manager.jpg differ diff --git a/public/images/roles/test_camera.jpg b/public/images/roles/test_camera.jpg new file mode 100644 index 0000000..e3897ee Binary files /dev/null and b/public/images/roles/test_camera.jpg differ diff --git a/public/images/roles/test_fitness.jpg b/public/images/roles/test_fitness.jpg new file mode 100644 index 0000000..218939c Binary files /dev/null and b/public/images/roles/test_fitness.jpg differ diff --git a/public/images/roles/test_job.jpg b/public/images/roles/test_job.jpg new file mode 100644 index 0000000..208f406 Binary files /dev/null and b/public/images/roles/test_job.jpg differ diff --git a/public/images/roles/test_makeup.jpg b/public/images/roles/test_makeup.jpg new file mode 100644 index 0000000..4c67052 Binary files /dev/null and b/public/images/roles/test_makeup.jpg differ diff --git a/public/images/roles/test_makeup2.jpg b/public/images/roles/test_makeup2.jpg new file mode 100644 index 0000000..7ed0509 Binary files /dev/null and b/public/images/roles/test_makeup2.jpg differ diff --git a/public/images/roles/test_makeup3.jpg b/public/images/roles/test_makeup3.jpg new file mode 100644 index 0000000..9d35656 Binary files /dev/null and b/public/images/roles/test_makeup3.jpg differ diff --git a/public/images/roles/test_makeup4.jpg b/public/images/roles/test_makeup4.jpg new file mode 100644 index 0000000..71e15ca Binary files /dev/null and b/public/images/roles/test_makeup4.jpg differ diff --git a/public/images/roles/test_service.jpg b/public/images/roles/test_service.jpg new file mode 100644 index 0000000..b3da059 Binary files /dev/null and b/public/images/roles/test_service.jpg differ diff --git a/public/images/roles/test_social.jpg b/public/images/roles/test_social.jpg new file mode 100644 index 0000000..a63157d Binary files /dev/null and b/public/images/roles/test_social.jpg differ diff --git a/public/images/roles/test_social2.jpg b/public/images/roles/test_social2.jpg new file mode 100644 index 0000000..91c4a1a Binary files /dev/null and b/public/images/roles/test_social2.jpg differ diff --git a/public/images/roles/test_video.jpg b/public/images/roles/test_video.jpg new file mode 100644 index 0000000..54631b1 Binary files /dev/null and b/public/images/roles/test_video.jpg differ diff --git a/public/images/roles/test_video2.jpg b/public/images/roles/test_video2.jpg new file mode 100644 index 0000000..8bdcf24 Binary files /dev/null and b/public/images/roles/test_video2.jpg differ diff --git a/public/images/roles/tutor.jpg b/public/images/roles/tutor.jpg new file mode 100644 index 0000000..9898f3b Binary files /dev/null and b/public/images/roles/tutor.jpg differ diff --git a/public/images/roles/video_editor.jpg b/public/images/roles/video_editor.jpg new file mode 100644 index 0000000..4c67052 Binary files /dev/null and b/public/images/roles/video_editor.jpg differ diff --git a/src/app.css b/src/app.css index e55d733..0de5d2e 100644 --- a/src/app.css +++ b/src/app.css @@ -24,10 +24,7 @@ body { font-family: 'Exo 2', sans-serif; color: var(--ink); scrollbar-gutter: stable; - background: - radial-gradient(120% 90% at 0% 0%, rgba(253, 98, 22, 0.22), transparent 52%), - radial-gradient(100% 80% at 100% 0%, rgba(26, 54, 93, 0.16), transparent 56%), - linear-gradient(180deg, #fff9f4 0%, #f8f9ff 48%, #eef2ff 100%); + background: #07051a; } .container { @@ -1798,13 +1795,19 @@ body { min-height: 100vh; overflow-x: clip; isolation: isolate; + background: #07051a; +} + +.lp-main > *:not(.lp-bg) { + position: relative; + z-index: 1; } .lp-bg { pointer-events: none; position: fixed; inset: 0; - z-index: -1; + z-index: 0; } .lp-dark-base { @@ -2863,6 +2866,166 @@ body { width: min(680px, calc(100% - 32px)); } +.onboarding-auth-layout { + align-items: start; + padding-top: 36px; + padding-bottom: 36px; +} + +.onboarding-auth-form { + padding: 18px 20px 22px; +} + +.onboarding-auth-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 12px; +} + +.onboarding-primary-btn { + margin-top: 0; + width: 100%; +} + +.onboarding-auth-form .error { + color: #6e7591; +} + +.onboarding-progress { + width: 100%; + height: 8px; + border-radius: 999px; + background: #eceff5; + overflow: hidden; + margin-top: 8px; + margin-bottom: 10px; +} + +.onboarding-progress-fill { + height: 100%; + border-radius: 999px; + background: #fd6216; + transition: width 220ms ease; +} + +.multi-select-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; +} + +.multi-select-option { + width: 100%; + min-height: 44px; + border-radius: 12px; + border: 1px solid #cfd4e3; + background: #f8fafc; + color: #3f4967; + padding: 10px 12px; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + text-align: left; + cursor: pointer; + transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease, box-shadow 160ms ease; +} + +.multi-select-option:hover { + border-color: #fdc9ad; + background: #fff4ee; +} + +.multi-select-option.is-selected { + border-color: #fd6216; + background: #fff2ea; + color: #2c3551; + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.12); +} + +.multi-select-option:focus-visible { + outline: none; + border-color: #fd6216; + box-shadow: 0 0 0 2px #ffd8c3; +} + +.multi-select-option:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.multi-select-option-text { + font-size: 13px; + font-weight: 600; + line-height: 1.35; +} + +.multi-select-tick { + width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid #d2d7e6; + background: #f3f5fa; + color: #9aa3bc; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.multi-select-tick svg { + width: 12px; + height: 12px; + fill: none; + stroke: currentColor; + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0; +} + +.multi-select-tick.is-visible { + border-color: #fd6216; + background: #fd6216; + color: #ffffff; +} + +.multi-select-tick.is-visible svg { + opacity: 1; +} + +.multi-select-grid.is-disabled .multi-select-option { + pointer-events: none; +} + +.locked-input-wrap { + position: relative; +} + +.locked-input { + padding-right: 40px; +} + +.locked-input-icon { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: #94a3b8; + pointer-events: none; +} + +.locked-input-icon svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + .auth-visual { min-height: 620px; border-radius: 28px; @@ -4572,9 +4735,9 @@ body { min-height: 100vh; display: flex; flex-direction: column; - align-items: center; + align-items: stretch; justify-content: flex-start; - padding: 24px 16px 60px; + padding: 0; color: #fff; isolation: isolate; } @@ -4599,7 +4762,7 @@ body { .choose-role-header h1 { margin: 0 0 12px; - font-size: clamp(28px, 5vw, 40px); + font-size: clamp(36px, 6vw, 56px); font-weight: 800; color: #fff; line-height: 1.1; @@ -4640,7 +4803,41 @@ body { } .professional-roles-grid { - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.role-path-card { + appearance: none; + -webkit-appearance: none; + width: 100%; + padding: 0; + text-align: left; + cursor: pointer; +} + +.role-path-card.selected { + border-color: #fd6216; + box-shadow: 0 0 0 2px rgba(253, 98, 22, 0.28), 0 22px 42px -30px rgba(253, 98, 22, 0.85); +} + +.role-path-card .path-chip { + margin-left: auto; +} + +.role-path-card .role-path-cta { + pointer-events: none; +} + +@media (max-width: 1024px) { + .professional-roles-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .professional-roles-grid { + grid-template-columns: 1fr; + } } .role-card { @@ -4652,12 +4849,11 @@ body { 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); + border: 1px solid rgba(15, 23, 42, 0.12); + background: #ffffff; cursor: pointer; transition: all 240ms ease; - box-shadow: 0 18px 36px -24px rgba(2, 6, 23, 0.5); + box-shadow: 0 18px 36px -24px rgba(2, 6, 23, 0.25); } .role-card::before { @@ -4665,7 +4861,7 @@ body { position: absolute; inset: -1px; border-radius: 18px; - background: linear-gradient(135deg, rgba(253, 98, 22, 0), rgba(253, 98, 22, 0.15)); + background: linear-gradient(135deg, rgba(253, 98, 22, 0), rgba(253, 98, 22, 0.08)); opacity: 0; transition: opacity 240ms ease; pointer-events: none; @@ -4674,9 +4870,9 @@ body { .role-card:hover { border-color: rgba(253, 98, 22, 0.6); - background: rgba(255, 255, 255, 0.15); + background: #ffffff; transform: translateY(-6px); - box-shadow: 0 24px 52px -20px rgba(253, 98, 22, 0.45); + box-shadow: 0 24px 52px -20px rgba(253, 98, 22, 0.3); } .role-card:hover::before { @@ -4685,8 +4881,8 @@ body { .role-card.selected { border-color: #fd6216; - 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); + background: #ffffff; + box-shadow: 0 0 0 2px rgba(253, 98, 22, 0.3) inset, 0 24px 52px -20px rgba(253, 98, 22, 0.3); } .role-card:disabled { @@ -4694,23 +4890,40 @@ body { cursor: not-allowed; } -.role-icon { - font-size: clamp(36px, 6vw, 48px); - line-height: 1; +.role-image { display: block; + width: 96px; + height: 96px; + object-fit: cover; + border-radius: 14px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: #f8fafc; + box-shadow: 0 10px 24px -16px rgba(15, 23, 42, 0.35); + transition: transform 500ms ease; +} + +.role-media { + width: 96px; + height: 96px; + overflow: hidden; + border-radius: 14px; +} + +.role-card:hover .role-image { + transform: scale(1.08); } .role-title { margin: 0; font-size: 16px; font-weight: 700; - color: #fff; + color: #0f172a; } .role-description { margin: 0; font-size: 13px; - color: rgba(255, 255, 255, 0.75); + color: #475569; line-height: 1.5; flex-grow: 1; } @@ -4732,7 +4945,7 @@ body { text-align: center; margin-top: 48px; padding-top: 24px; - border-top: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid rgba(255, 255, 255, 0.2); } .choose-role-footer .footer-text { diff --git a/src/components/PublicHeader.tsx b/src/components/PublicHeader.tsx index c20a2b4..59aedba 100644 --- a/src/components/PublicHeader.tsx +++ b/src/components/PublicHeader.tsx @@ -4,6 +4,7 @@ import { Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js'; type PublicHeaderProps = { loginHref?: string; signupHref?: string; + showAuthActions?: boolean; }; const isRouteActive = (pathname: string, href: string) => { @@ -19,6 +20,7 @@ export default function PublicHeader(props: PublicHeaderProps) { const loginHref = () => props.loginHref || '/auth/login'; const signupHref = () => props.signupHref || '/auth/register'; + const showAuthActions = () => props.showAuthActions !== false; onMount(() => { const onScroll = () => setScrolled(window.scrollY > 10); @@ -46,10 +48,12 @@ export default function PublicHeader(props: PublicHeaderProps) { Contact Us - + + + + Back -

Back to Sign In

+

+ Back to {flow() === 'register' ? Sign Up : Sign In} +

diff --git a/src/routes/onboarding.tsx b/src/routes/onboarding.tsx index 33af456..74908f4 100644 --- a/src/routes/onboarding.tsx +++ b/src/routes/onboarding.tsx @@ -1,8 +1,10 @@ import { createMemo, createSignal, For, onMount, Show } from 'solid-js'; import { useSearchParams } from '@solidjs/router'; import { authState } from '~/lib/auth'; -import type { RuntimeOnboardingConfig, RuntimeOnboardingField, UploadedFileMeta } from '~/lib/runtime/types'; +import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeOption, UploadedFileMeta } from '~/lib/runtime/types'; import { evaluateVisibility, normalizeRoleKey, schemaIdFromInput } from '~/lib/onboarding-flow'; +import { isValidEmail } from '~/lib/form-validation'; +import PublicBackground from '~/components/PublicBackground'; function isEmptyValue(value: unknown) { if (value == null) return true; @@ -11,34 +13,126 @@ function isEmptyValue(value: unknown) { return false; } +function parseBoolean(value: unknown): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value === 1; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes'; + } + return false; +} + +const ROLE_SKILL_OPTIONS: Record = { + DEVELOPER: ['JavaScript', 'TypeScript', 'React', 'SolidJS', 'Node.js', 'Rust', 'PostgreSQL', 'Docker'], + PHOTOGRAPHER: ['Portrait Photography', 'Wedding Photography', 'Event Photography', 'Photo Editing', 'Studio Lighting', 'Product Photography'], + MAKEUP_ARTIST: ['Bridal Makeup', 'Party Makeup', 'Editorial Makeup', 'Airbrush Makeup', 'Skincare Prep', 'Hairstyling'], + TUTOR: ['Mathematics', 'Science', 'English', 'Coding', 'Test Preparation', 'Language Training'], + VIDEO_EDITOR: ['Adobe Premiere Pro', 'Final Cut Pro', 'DaVinci Resolve', 'Motion Graphics', 'Color Grading', 'Short-form Content'], + GRAPHIC_DESIGNER: ['Brand Identity', 'Logo Design', 'Social Media Design', 'UI Design', 'Typography', 'Illustration'], + SOCIAL_MEDIA_MANAGER: ['Content Strategy', 'Content Calendar', 'Community Management', 'Performance Analytics', 'Paid Campaigns', 'Copywriting'], + FITNESS_TRAINER: ['Weight Training', 'Fat Loss', 'Functional Training', 'Mobility', 'Nutrition Basics', 'Online Coaching'], + CATERING_SERVICES: ['Wedding Catering', 'Corporate Catering', 'Live Counters', 'Menu Planning', 'Bulk Orders', 'Event Logistics'], + COMPANY: ['Hiring', 'Team Management', 'Project Planning', 'Operations', 'Client Communication', 'Growth Strategy'], + JOB_SEEKER: ['Communication', 'Problem Solving', 'Teamwork', 'Time Management', 'Project Ownership', 'Adaptability'], + CUSTOMER: ['Home Services', 'Event Services', 'Education Services', 'Tech Services', 'Beauty Services', 'Fitness Services'], +}; + +function skillOptionsForRole(roleKey: string): RuntimeOption[] { + const normalized = String(roleKey || '').trim().toUpperCase(); + const options = ROLE_SKILL_OPTIONS[normalized] || []; + return options.map((entry) => ({ label: entry, value: entry })); +} + +function isSkillFieldCandidate(fieldId: unknown, fieldLabel: unknown): boolean { + const id = String(fieldId || '').toLowerCase(); + const label = String(fieldLabel || '').toLowerCase(); + return id.includes('skill') || label.includes('skill'); +} + +function isWorkModeFieldCandidate(fieldId: unknown, fieldLabel: unknown): boolean { + const id = String(fieldId || '').toLowerCase(); + const label = String(fieldLabel || '').toLowerCase(); + return id.includes('work_mode') || id.includes('workmode') || label.includes('work mode'); +} + +function isAlwaysEditableField(fieldId: unknown): boolean { + const id = String(fieldId || '').trim().toLowerCase(); + return id === 'preferred_role'; +} + +function isForcedTextField(fieldId: unknown): boolean { + const id = String(fieldId || '').trim().toLowerCase(); + return id === 'preferred_role'; +} + function validateField(field: RuntimeOnboardingField, value: unknown): string | null { - if (field.required && isEmptyValue(value)) return `${field.label} is required.`; + if (field.required && isEmptyValue(value)) { + if (field.type === 'checkbox') return 'Please enable this option.'; + if (field.type === 'file') return 'Please upload the file.'; + if (field.type === 'select') return 'Please select an option.'; + if (field.type === 'date') return 'Please select the date.'; + return 'Please fill this field.'; + } if (isEmptyValue(value)) return null; if (field.type === 'number') { const numeric = Number(value); - if (Number.isNaN(numeric)) return `${field.label} must be a number.`; - if (typeof field.validation?.min === 'number' && numeric < field.validation.min) return `${field.label} must be at least ${field.validation.min}.`; - if (typeof field.validation?.max === 'number' && numeric > field.validation.max) return `${field.label} must be at most ${field.validation.max}.`; + if (Number.isNaN(numeric)) return 'Please enter a valid number.'; + if (typeof field.validation?.min === 'number' && numeric < field.validation.min) return `Please enter a value of at least ${field.validation.min}.`; + if (typeof field.validation?.max === 'number' && numeric > field.validation.max) return `Please enter a value of at most ${field.validation.max}.`; } if (typeof value === 'string') { + if (field.type === 'email' && !isValidEmail(value)) { + return 'Please enter a valid email address (e.g., user@example.com).'; + } + if (field.type === 'tel') { + const telRegex = /^\+?[0-9\s\-()]{7,20}$/; + if (!telRegex.test(value.trim())) { + return 'Please enter a valid phone number.'; + } + } + if (field.type === 'url') { + try { + const parsed = new URL(value); + if (!/^https?:$/.test(parsed.protocol)) return 'Please enter a URL starting with http:// or https://.'; + } catch { + return 'Please enter a valid URL.'; + } + } + if (field.type === 'date' && Number.isNaN(Date.parse(value))) { + return 'Please select a valid date.'; + } if (typeof field.validation?.minLength === 'number' && value.length < field.validation.minLength) { - return `${field.label} must be at least ${field.validation.minLength} characters.`; + if (field.type === 'textarea') return `Please fill the description, min ${field.validation.minLength} characters.`; + return `Please enter at least ${field.validation.minLength} characters.`; } if (typeof field.validation?.maxLength === 'number' && value.length > field.validation.maxLength) { - return `${field.label} must be at most ${field.validation.maxLength} characters.`; + return `Please enter no more than ${field.validation.maxLength} characters.`; } if (field.validation?.pattern && !new RegExp(field.validation.pattern).test(value)) { - return `${field.label} format is invalid.`; + return 'Please enter a valid value.'; + } + } + + if (field.type === 'select') { + const options = (field.options || []).map((opt) => opt.value); + if (field.multiple) { + const values = Array.isArray(value) ? value : []; + const invalid = values.find((entry) => !options.includes(String(entry))); + if (invalid) return 'Please select a valid option.'; + } else { + const selected = String(value || ''); + if (selected && !options.includes(selected)) return 'Please select a valid option.'; } } if (field.type === 'file') { const files = value as UploadedFileMeta[] | undefined; const length = files?.length || 0; - if (field.required && length === 0) return `${field.label} is required.`; - if (typeof field.maxFiles === 'number' && length > field.maxFiles) return `${field.label} allows only ${field.maxFiles} file(s).`; + if (field.required && length === 0) return 'Please upload the file.'; + if (typeof field.maxFiles === 'number' && length > field.maxFiles) return `Please upload at most ${field.maxFiles} file(s).`; } return null; @@ -79,11 +173,19 @@ function normalizeSchemaPayload(payload: any, schemaId: string, roleKey: string) ...(field?.validation && typeof field.validation === 'object' ? field.validation : {}), }; + const roleSkills = skillOptionsForRole(roleKey); + const shouldForceSkillSelect = isSkillFieldCandidate(field?.id, field?.label); + const useSkillOptions = shouldForceSkillSelect && roleSkills.length > 0; + const useWorkModeSelect = isWorkModeFieldCandidate(field?.id, field?.label) && Array.isArray(options) && options.length > 0; + const forceText = isForcedTextField(field?.id); + return { ...field, - type, - options, - readOnly: Boolean(field?.readOnly ?? field?.readonly), + type: forceText ? 'text' : ((useSkillOptions || useWorkModeSelect) ? 'select' : type), + options: forceText ? undefined : (useSkillOptions ? roleSkills : options), + readOnly: isAlwaysEditableField(field?.id) ? false : parseBoolean(field?.readOnly ?? field?.readonly), + multiple: forceText ? false : (useSkillOptions ? true : (useWorkModeSelect ? false : parseBoolean(field?.multiple))), + ...(useSkillOptions ? { placeholder: 'Select one or more options' } : {}), validation: Object.keys(validation).length > 0 ? validation : undefined, }; }) : []; @@ -109,6 +211,7 @@ export default function OnboardingPage() { const [schema, setSchema] = createSignal(null); const [values, setValues] = createSignal>({}); const [errors, setErrors] = createSignal>({}); + const [touched, setTouched] = createSignal>({}); const [stepIndex, setStepIndex] = createSignal(0); const [statusMessage, setStatusMessage] = createSignal(''); const [submitted, setSubmitted] = createSignal(false); @@ -126,6 +229,16 @@ export default function OnboardingPage() { if (fromQuery) return fromQuery; return schemaIdFromInput(effectiveRoleKey(), requestedProfession()); }); + const userIdentity = createMemo(() => { + const fullName = String(authState().runtime_config?.user?.full_name || '').trim(); + if (!fullName) return { fullName: '', firstName: '', lastName: '' }; + const parts = fullName.split(/\s+/).filter(Boolean); + const firstName = parts[0] || ''; + const lastName = parts.slice(1).join(' '); + return { fullName, firstName, lastName }; + }); + const isManagedNameField = (fieldId: string) => ['full_name', 'first_name', 'last_name'].includes(fieldId); + const isLockedProfileField = (fieldId: string) => ['full_name', 'first_name', 'last_name', 'city'].includes(fieldId); onMount(async () => { try { @@ -155,7 +268,14 @@ export default function OnboardingPage() { const initialValues: Record = {}; normalized.steps.forEach((step) => { step.fields.forEach((field) => { - if (field.defaultValue !== undefined) initialValues[field.id] = field.defaultValue; + if (field.id === 'full_name') initialValues[field.id] = userIdentity().fullName; + else if (field.id === 'first_name') initialValues[field.id] = userIdentity().firstName; + else if (field.id === 'last_name') initialValues[field.id] = userIdentity().lastName; + else if (field.multiple && Array.isArray(field.defaultValue)) initialValues[field.id] = field.defaultValue; + else if (field.multiple && typeof field.defaultValue === 'string') { + initialValues[field.id] = field.defaultValue.split(',').map((entry) => entry.trim()).filter(Boolean); + } + else if (field.defaultValue !== undefined) initialValues[field.id] = field.defaultValue; else if (field.multiple) initialValues[field.id] = []; else initialValues[field.id] = field.type === 'checkbox' ? false : ''; }); @@ -176,6 +296,8 @@ export default function OnboardingPage() { if (profileResponse.ok && profilePayload?.success) { setProfileStatus(String(profilePayload?.data?.profileStatus || '')); } + } catch (error: any) { + setStatusMessage(String(error?.message || 'Unable to load onboarding flow right now.')); } finally { setLoading(false); } @@ -199,6 +321,38 @@ export default function OnboardingPage() { if (total === 0) return '0 / 0'; return `${Math.min(stepIndex() + 1, total)} / ${total}`; }); + const progressPercent = createMemo(() => { + const total = visibleSteps().length; + if (total <= 0) return 0; + const current = Math.min(stepIndex() + 1, total); + return Math.round((current / total) * 100); + }); + + const roleWelcome = createMemo(() => { + const role = String(schema()?.roleKey || '').toUpperCase(); + if (role === 'COMPANY') { + return { + title: "Let's build your company profile", + subtitle: 'A few quick details and you will be ready to post opportunities.', + }; + } + if (role === 'JOB_SEEKER' || role === 'JOBSEEKER') { + return { + title: "Let's shape your job profile", + subtitle: 'Share your details so we can match you with better opportunities.', + }; + } + if (role === 'CUSTOMER') { + return { + title: "Let's get your request journey started", + subtitle: 'Tell us what you need, and we will help you connect with trusted professionals.', + }; + } + return { + title: "Let's build your professional profile", + subtitle: 'Add your details once and start receiving stronger, relevant opportunities.', + }; + }); const setFieldValue = (fieldId: string, next: unknown) => { setValues((prev) => ({ ...prev, [fieldId]: next })); @@ -209,10 +363,84 @@ export default function OnboardingPage() { }); }; + const validateSingleField = (field: RuntimeOnboardingField, nextValue: unknown) => { + if (isLockedProfileField(field.id)) return true; + const message = validateField(field, nextValue); + setErrors((prev) => { + const copy = { ...prev }; + if (message) copy[field.id] = message; + else delete copy[field.id]; + return copy; + }); + return !message; + }; + + const markFieldTouched = (fieldId: string) => { + setTouched((prev) => ({ ...prev, [fieldId]: true })); + }; + + const markCurrentStepTouched = () => { + const marks: Record = {}; + visibleFields().forEach((field) => { + marks[field.id] = true; + }); + setTouched((prev) => ({ ...prev, ...marks })); + }; + + const handleFieldInput = (field: RuntimeOnboardingField, nextValue: unknown) => { + setFieldValue(field.id, nextValue); + if (touched()[field.id]) validateSingleField(field, nextValue); + }; + + const handleFieldBlur = (field: RuntimeOnboardingField, nextValue?: unknown) => { + markFieldTouched(field.id); + validateSingleField(field, nextValue ?? values()[field.id]); + }; + + const validationNote = (field: RuntimeOnboardingField) => { + if (isLockedProfileField(field.id)) return null; + const value = values()[field.id]; + const hasValue = !isEmptyValue(value); + const message = validateField(field, value); + if (message) return { text: message, tone: 'error' as const }; + + if (!hasValue) { + if (field.type === 'select') return { text: 'Please select an option.', tone: 'hint' as const }; + if (field.type === 'date') return { text: 'Please select the date.', tone: 'hint' as const }; + if (field.type === 'file') return { text: 'Please upload the file.', tone: 'hint' as const }; + if (field.type === 'checkbox') return { text: 'Please enable this option.', tone: 'hint' as const }; + if (field.type === 'textarea') return { text: 'Please fill the description.', tone: 'hint' as const }; + return { text: 'Please fill this field.', tone: 'hint' as const }; + } + + if (field.type === 'email') return { text: 'Valid email format', tone: 'ok' as const }; + if (field.type === 'url') return { text: 'Valid URL format', tone: 'ok' as const }; + if (field.type === 'tel') return { text: 'Valid phone format', tone: 'ok' as const }; + if (field.type === 'date') return { text: 'Great, date looks good', tone: 'ok' as const }; + if (field.type === 'number') return { text: 'Great, that looks good', tone: 'ok' as const }; + if (field.type === 'checkbox') return { text: 'Option enabled', tone: 'ok' as const }; + if (field.type === 'file') { + const files = Array.isArray(value) ? value : []; + return { text: `${files.length} file(s) selected`, tone: 'ok' as const }; + } + if (field.type === 'select') { + if (field.multiple) { + const selected = Array.isArray(value) ? value : []; + return { text: `${selected.length} option(s) selected`, tone: 'ok' as const }; + } + return { text: 'Option selected', tone: 'ok' as const }; + } + if (typeof value === 'string' && field.validation?.minLength) { + return { text: `${value.length} characters entered`, tone: 'ok' as const }; + } + return { text: 'Looks good', tone: 'ok' as const }; + }; + const validateCurrentStep = () => { const fieldList = visibleFields(); const nextErrors: Record = {}; fieldList.forEach((field) => { + if (isManagedNameField(field.id)) return; const message = validateField(field, values()[field.id]); if (message) nextErrors[field.id] = message; }); @@ -236,6 +464,7 @@ export default function OnboardingPage() { }; const goNext = async () => { + markCurrentStepTouched(); if (!validateCurrentStep()) { setStatusMessage('Please fix the highlighted fields.'); return; @@ -254,6 +483,7 @@ export default function OnboardingPage() { }; const submit = async () => { + markCurrentStepTouched(); if (!validateCurrentStep()) { setStatusMessage('Please fix the highlighted fields.'); return; @@ -261,13 +491,20 @@ export default function OnboardingPage() { const currentSchema = schema(); if (!currentSchema) return; + const payloadData = { + ...values(), + ...(userIdentity().fullName ? { full_name: userIdentity().fullName } : {}), + ...(userIdentity().firstName ? { first_name: userIdentity().firstName } : {}), + ...(userIdentity().lastName ? { last_name: userIdentity().lastName } : {}), + }; + const response = await fetch('/api/runtime/onboarding/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ roleKey: currentSchema.roleKey, requiresApproval: true, - dataJson: values(), + dataJson: payloadData, }), }); const payload = await response.json().catch(() => ({})); @@ -281,35 +518,99 @@ export default function OnboardingPage() { const renderField = (field: RuntimeOnboardingField) => { const value = values()[field.id]; + const lockedIdentityField = isLockedProfileField(field.id); + const fieldReadOnly = lockedIdentityField && !isAlwaysEditableField(field.id); if (field.type === 'textarea') { return (