diff --git a/src/app.css b/src/app.css index 5ffff0f..8f58d0a 100644 --- a/src/app.css +++ b/src/app.css @@ -315,6 +315,12 @@ body { box-shadow: 0 18px 34px -24px rgba(2, 6, 23, 0.44); } +.contact-layout-grid { + display: grid; + gap: 20px; + grid-template-columns: 1.45fr 1fr; +} + .contact-side-card { padding: 24px; border-radius: 24px; @@ -420,6 +426,12 @@ body { line-height: 1.55; } +.contact-quick-clarity { + color: #fff !important; + font-weight: 600; + text-shadow: 0 1px 10px rgba(2, 6, 23, 0.42); +} + .help-hero-panel { border-radius: 30px; box-shadow: 0 24px 50px -36px rgba(2, 6, 23, 0.9); @@ -447,14 +459,34 @@ body { } .help-solid-section { - background: #fff; + background: transparent; } .help-article-list { - grid-template-columns: 1fr; + display: grid; + gap: 16px; margin: 0; } +.help-article-headline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.help-article-headline h2 { + margin: 0; + font-size: 24px; + color: #f8fafc; +} + +.help-article-headline span { + font-size: 13px; + color: rgba(226, 232, 240, 0.82); +} + .help-empty-card { border-radius: 16px; border: 1px dashed rgba(16, 11, 47, 0.2); @@ -589,6 +621,10 @@ body { } @media (max-width: 900px) { + .contact-layout-grid { + grid-template-columns: 1fr; + } + .help-search-grid { grid-template-columns: 1fr; } @@ -1530,56 +1566,79 @@ body { } .public-footer { + position: relative; + z-index: 12; border-top: 1px solid rgba(16, 11, 47, 0.1); - background: rgba(255, 255, 255, 0.7); - backdrop-filter: blur(24px); + background: rgba(255, 255, 255, 0.96); + backdrop-filter: blur(8px); } -.public-footer .footer-row { - min-height: auto; - display: grid; - grid-template-columns: 1fr; +.public-footer-row { + min-height: 74px; + display: flex; + justify-content: space-between; align-items: center; - gap: 8px; + gap: 18px; padding: 12px 0; color: #334155; font-size: 14px; } -.public-footer .footer-row p { +.public-footer-row p { margin: 0; text-align: center; color: #334155; + flex: 1; + font-size: 14px; } -.public-footer .footer-links { +.public-footer-logo { + height: 44px; + width: auto; +} + +.public-footer-links { + display: flex; justify-content: center; gap: 16px; + white-space: nowrap; } -.public-footer .footer-links a { +.public-footer-links a { color: #100b2f; + font-size: 14px; + text-decoration: none; } -.public-footer .footer-links a:hover { +.public-footer-links a:hover { color: #fd6216; } @media (min-width: 640px) { - .public-footer .footer-row { - grid-template-columns: auto 1fr auto; - gap: 16px; - } - - .public-footer .footer-row p { - text-align: center; - } - - .public-footer .footer-links { + .public-footer-links { justify-content: flex-end; } } +@media (max-width: 639px) { + .public-footer-row { + min-height: auto; + display: grid; + grid-template-columns: 1fr; + gap: 8px; + justify-items: center; + padding: 12px 0; + } + + .public-footer-row p { + flex: initial; + } + + .public-footer-logo { + height: 36px; + } +} + .ghost-dark { border-color: rgba(255, 255, 255, 0.28); color: #fff; @@ -1738,13 +1797,14 @@ body { position: relative; min-height: 100vh; overflow-x: clip; + isolation: isolate; } .lp-bg { pointer-events: none; position: fixed; inset: 0; - z-index: 0; + z-index: -1; } .lp-dark-base { @@ -2777,6 +2837,12 @@ body { position: relative; min-height: 100vh; color: #fff; + isolation: isolate; +} + +.auth-page > *:not(.lp-bg) { + position: relative; + z-index: 1; } .auth-layout { @@ -3145,7 +3211,7 @@ body { z-index: 10; } -.about-content .container { +.about-content > section .container { width: 100%; max-width: 1240px; padding-left: 16px; @@ -3223,10 +3289,10 @@ body { .about-hero { position: relative; - min-height: clamp(520px, 78vh, 760px); + min-height: clamp(430px, 64vh, 620px); display: flex; align-items: center; - padding: 28px 0 8px; + padding: 18px 0 0; } .about-hero::before { @@ -3244,7 +3310,7 @@ body { z-index: 1; display: grid; grid-template-columns: 1fr; - gap: 20px; + gap: 14px; align-items: start; } @@ -3262,7 +3328,7 @@ body { } .about-title { - margin: 12px 0 0; + margin: 8px 0 0; font-size: clamp(38px, 7vw, 64px); line-height: 1.08; font-weight: 800; @@ -3270,12 +3336,16 @@ body { } .about-copy { - margin-top: 18px; + margin-top: 12px; max-width: 740px; font-size: 17px; color: rgba(255, 255, 255, 0.82); } +.about-hero .hero-actions { + margin-top: 12px; +} + .about-manifesto-card { border-radius: 24px; border: 1px solid rgba(255, 255, 255, 0.16); @@ -3283,7 +3353,7 @@ body { backdrop-filter: blur(16px); position: relative; overflow: hidden; - padding: 24px; + padding: 20px; } .about-manifesto-card h2 { @@ -3293,11 +3363,11 @@ body { } .about-manifesto-card ul { - margin: 14px 0 0; + margin: 10px 0 0; padding-left: 18px; color: rgba(255, 255, 255, 0.86); font-size: 14px; - line-height: 1.7; + line-height: 1.6; } .about-sheen-sweep { @@ -3310,11 +3380,11 @@ body { } .about-section-tight { - padding: 22px 0; + padding: 10px 0; } .about-section-mid { - padding: 40px 0; + padding: 28px 0; } .about-section-title, @@ -3373,16 +3443,16 @@ body { display: flex; align-items: flex-start; padding-top: 0; - padding-bottom: 4px; + padding-bottom: 2px; } .about-problem-stage { position: relative; - min-height: clamp(420px, 56vh, 540px); + min-height: clamp(360px, 50vh, 480px); display: grid; place-items: center; text-align: center; - padding: 16px 16px 10px; + padding: 10px 14px 6px; overflow: hidden; } @@ -3449,7 +3519,7 @@ body { position: relative; z-index: 1; max-width: 920px; - margin-top: 12px; + margin-top: 8px; font-size: clamp(34px, 6.4vw, 72px); line-height: 1.06; font-weight: 800; @@ -3461,10 +3531,10 @@ body { .about-problem-body { position: relative; z-index: 1; - margin-top: 16px; + margin-top: 10px; max-width: 780px; font-size: clamp(16px, 2vw, 24px); - line-height: 1.6; + line-height: 1.42; color: rgba(255, 255, 255, 0.86); transition: opacity 320ms ease, filter 320ms ease; } @@ -3485,13 +3555,13 @@ body { .about-chapter-two-shell, .about-trust-shell { - padding: 28px; + padding: 24px; } .about-chapter-two-grid { - margin-top: 14px; + margin-top: 10px; display: grid; - gap: 16px; + gap: 12px; grid-template-columns: 1fr; } @@ -3511,20 +3581,20 @@ body { } .about-chapter-two-text p { - margin-top: 16px; + margin-top: 12px; color: #334155; line-height: 1.8; } .about-chapter-two-body { - margin-top: 20px; + margin-top: 14px; color: #334155; font-size: 16px; line-height: 1.65; } .about-trust-shell { - padding: 28px; + padding: 24px; } .about-chapter-two-panel { @@ -3628,12 +3698,12 @@ body { } .about-trust-sequence { - margin-top: 20px; + margin-top: 14px; } .about-trust-sequence-list { display: grid; - gap: 12px; + gap: 10px; } .about-trust-sequence-row { @@ -3696,13 +3766,23 @@ body { .about-principles-section { min-height: 74vh; - padding-top: 14px; + padding-top: 20px; padding-bottom: 0; + scroll-margin-top: 240px; + position: relative; + z-index: 3; +} + +.about-principles-section:target { + scroll-margin-top: 240px; } .about-principle-narrative-section { position: relative; overflow: hidden; + border-color: rgba(255, 255, 255, 0.2); + background: linear-gradient(145deg, rgba(16, 11, 47, 0.66), rgba(16, 11, 47, 0.46)); + box-shadow: 0 16px 28px -22px rgba(2, 6, 23, 0.58); } .about-principle-narrative-section::before { @@ -3711,15 +3791,15 @@ body { inset: 0; border-radius: 24px; background: - radial-gradient(55% 46% at 76% 42%, rgba(253, 98, 22, 0.14), transparent 72%), - linear-gradient(180deg, rgba(16, 11, 47, 0.5), rgba(16, 11, 47, 0.16)); + radial-gradient(55% 46% at 76% 42%, rgba(253, 98, 22, 0.2), transparent 72%), + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(16, 11, 47, 0.02)); pointer-events: none; } .about-narrative-stage-root { - margin-top: 18px; + margin-top: 10px; position: relative; - min-height: clamp(280px, 44vh, 420px); + min-height: clamp(220px, 36vh, 320px); display: flex; align-items: center; justify-content: center; @@ -3728,8 +3808,8 @@ body { .about-narrative-viewport { position: relative; width: min(100%, 860px); - min-height: 320px; - padding: 12px 0; + min-height: 250px; + padding: 8px 0; } .about-narrative-glow { @@ -3750,7 +3830,7 @@ body { position: relative; z-index: 1; display: grid; - gap: 18px; + gap: 30px; } .about-narrative-item-active, @@ -3770,6 +3850,7 @@ body { } .about-narrative-headline { + margin: 0; font-size: clamp(30px, 5vw, 66px); font-weight: 800; line-height: 1.08; @@ -3814,7 +3895,7 @@ body { .about-review-line, .about-review-line-static { - margin-top: 14px; + margin-top: 8px; width: min(460px, 68vw); height: 1px; background: linear-gradient(90deg, rgba(253, 98, 22, 0.8), rgba(255, 255, 255, 0.12)); @@ -3835,13 +3916,22 @@ body { } .about-principles-subline { - margin-top: 4px; + margin: 2px 0 0; font-size: 14px; line-height: 1.4; letter-spacing: 0.12em; color: rgba(255, 255, 255, 0.78); } +.about-principles-subline-inline { + display: block; + margin-top: 18px; + font-size: 14px; + line-height: 1.35; + letter-spacing: 0.12em; + color: rgba(255, 255, 255, 0.78); +} + @media (min-width: 640px) { .about-chapter-title { font-size: 36px; @@ -3854,6 +3944,10 @@ body { .about-principles-subline { font-size: 16px; } + + .about-principles-subline-inline { + font-size: 16px; + } } .about-timeline-section-tight .about-glass-light { @@ -3879,9 +3973,9 @@ body { .about-timeline-wrap { position: relative; - margin-top: 18px; + margin-top: 14px; display: grid; - gap: 14px; + gap: 10px; padding-left: 38px; } @@ -3946,7 +4040,7 @@ body { } .about-closing-card { - padding: 30px; + padding: 24px; text-align: center; } @@ -3957,7 +4051,7 @@ body { } .about-closing-card .hero-actions { - margin-top: 18px; + margin-top: 14px; justify-content: center; } @@ -4008,7 +4102,7 @@ body { } .about-trust-shell { - padding: 36px; + padding: 30px; } .about-chapter-two-grid { @@ -4022,7 +4116,7 @@ body { display: flex; } - .about-with-rail .container { + .about-with-rail > section .container { padding-left: 128px; padding-right: 28px; } @@ -4034,7 +4128,7 @@ body { } .about-narrative-stage-root { - min-height: 74vh; + min-height: 62vh; } } @@ -4052,8 +4146,13 @@ body { .about-principles-section { min-height: auto; - padding-top: 6px; + padding-top: 14px; padding-bottom: 0; + scroll-margin-top: 180px; + } + + .about-principles-section:target { + scroll-margin-top: 180px; } .about-narrative-stage-root { @@ -4380,6 +4479,93 @@ body { margin-top: auto; } +/* ── Guided Tour ── */ +.tour-overlay { + position: fixed; + inset: 0; + background: rgba(5, 0, 38, 0.5); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 80; +} + +.tour-modal { + width: min(560px, 100%); + background: #fff; + border-radius: 16px; + border: 1px solid #e2e8f0; + box-shadow: 0 24px 80px -30px rgba(2, 6, 23, 0.5); + padding: 22px; +} + +.tour-eyebrow { + margin: 0 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + color: #fd6216; + text-transform: uppercase; +} + +.tour-modal h3 { + margin: 0; + font-size: 20px; + color: #100b2f; +} + +.tour-modal p { + margin: 10px 0 0; + color: #475569; + font-size: 14px; + line-height: 1.55; +} + +.tour-progress { + margin-top: 16px; + height: 8px; + border-radius: 999px; + background: #e2e8f0; + overflow: hidden; +} + +.tour-progress span { + display: block; + height: 100%; + background: linear-gradient(90deg, #fd6216, #ff8a4d); + transition: width 200ms ease; +} + +.tour-actions { + margin-top: 16px; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +@media (max-width: 640px) { + .tour-modal { + padding: 18px; + border-radius: 14px; + } + + .tour-modal h3 { + font-size: 18px; + } + + .tour-actions { + justify-content: stretch; + flex-wrap: wrap; + } + + .tour-actions .btn { + flex: 1; + min-width: 120px; + } +} + /* ── Choose Role Page ── */ .choose-role-page { position: relative; @@ -4390,6 +4576,12 @@ body { justify-content: flex-start; padding: 24px 16px 60px; color: #fff; + isolation: isolate; +} + +.choose-role-page > *:not(.lp-bg) { + position: relative; + z-index: 1; } .choose-role-container { @@ -4924,5 +5116,3 @@ body { flex-wrap: wrap; gap: 6px; } - - diff --git a/src/components/PublicBackground.tsx b/src/components/PublicBackground.tsx new file mode 100644 index 0000000..7a008b6 --- /dev/null +++ b/src/components/PublicBackground.tsx @@ -0,0 +1,92 @@ +import { For } from 'solid-js'; + +const chipNodes = [ + { kind: 'code', left: '2%', top: '14%', size: 44, cls: 'lp-chip-slow' }, + { kind: 'camera', left: '94%', top: '22%', size: 46, cls: 'lp-chip-mid' }, + { kind: 'briefcase', left: '3%', top: '76%', size: 46, cls: 'lp-chip-fast' }, + { kind: 'bell', left: '93%', top: '80%', size: 42, cls: 'lp-chip-slow' }, + { kind: 'sparkles', left: '50%', top: '6%', size: 40, cls: 'lp-chip-mid' }, +] as const; + +function ChipIcon(props: { kind: (typeof chipNodes)[number]['kind'] }) { + const common = { fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' } as const; + if (props.kind === 'camera') { + return ( + + ); + } + if (props.kind === 'briefcase') { + return ( + + ); + } + if (props.kind === 'bell') { + return ( + + ); + } + if (props.kind === 'sparkles') { + return ( + + ); + } + return ( + + ); +} + +type PublicBackgroundProps = { + scrollY?: number; + reduceMotion?: boolean; + meshFactor?: number; + ribbonFactor?: number; + chipsFactor?: number; + meshCap?: number; + ribbonCap?: number; + chipsCap?: number; +}; + +export default function PublicBackground(props: PublicBackgroundProps) { + const y = () => (props.reduceMotion ? 0 : props.scrollY || 0); + const meshFactor = () => props.meshFactor ?? 0.1; + const ribbonFactor = () => props.ribbonFactor ?? 0.18; + const chipsFactor = () => props.chipsFactor ?? 0.24; + const meshCap = () => props.meshCap ?? 36; + const ribbonCap = () => props.ribbonCap ?? 58; + const chipsCap = () => props.chipsCap ?? 80; + + return ( + ); } diff --git a/src/lib/auth-intent.test.ts b/src/lib/auth-intent.test.ts new file mode 100644 index 0000000..0418366 --- /dev/null +++ b/src/lib/auth-intent.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { intentToOnboardingPath, normalizeIntent } from './auth-intent'; + +describe('normalizeIntent', () => { + it('normalizes all supported intent aliases', () => { + expect(normalizeIntent('customer')).toBe('customer'); + expect(normalizeIntent('professional')).toBe('professional'); + expect(normalizeIntent('pro')).toBe('professional'); + expect(normalizeIntent('company')).toBe('company'); + expect(normalizeIntent('employer')).toBe('company'); + expect(normalizeIntent('job_seeker')).toBe('job_seeker'); + expect(normalizeIntent('job-seeker')).toBe('job_seeker'); + expect(normalizeIntent('jobseeker')).toBe('job_seeker'); + }); + + it('returns null for unknown values', () => { + expect(normalizeIntent('unknown')).toBeNull(); + expect(normalizeIntent('')).toBeNull(); + expect(normalizeIntent(null)).toBeNull(); + expect(normalizeIntent(undefined)).toBeNull(); + }); +}); + +describe('intentToOnboardingPath', () => { + it('maps each intent to expected onboarding path', () => { + expect(intentToOnboardingPath('company')).toBe('/users/onboarding/company'); + expect(intentToOnboardingPath('job_seeker')).toBe('/users/onboarding/job-seeker'); + expect(intentToOnboardingPath('professional')).toBe('/users/onboarding/professional'); + expect(intentToOnboardingPath('customer')).toBe('/users/onboarding/customer'); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 3040caa..dbb687a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,6 +6,11 @@ export interface RuntimeConfig { role: string; onboarding_required: boolean; onboarding_status?: string; + guided_tours?: { + welcome?: Array<{ title: string; body: string }>; + role_approved_default?: Array<{ title: string; body: string }>; + roles?: Record>; + }; enabled_modules: string[]; feature_flags: Record; permissions: Record; diff --git a/src/lib/guided-tour-content.test.ts b/src/lib/guided-tour-content.test.ts new file mode 100644 index 0000000..196929f --- /dev/null +++ b/src/lib/guided-tour-content.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { resolveRoleApprovedTourSteps, resolveWelcomeTourSteps } from './guided-tour-content'; + +describe('resolveWelcomeTourSteps', () => { + it('uses runtime-config welcome steps when provided', () => { + const steps = resolveWelcomeTourSteps({ + welcome: [ + { title: 'A', body: 'B' }, + { title: 'C', body: 'D' }, + ], + }); + expect(steps).toHaveLength(2); + expect(steps[0].title).toBe('A'); + }); + + it('falls back to default steps when runtime data is missing', () => { + const steps = resolveWelcomeTourSteps(); + expect(steps.length).toBeGreaterThan(0); + }); +}); + +describe('resolveRoleApprovedTourSteps', () => { + it('returns role-specific defaults for primary roles', () => { + expect(resolveRoleApprovedTourSteps('COMPANY').length).toBeGreaterThan(0); + expect(resolveRoleApprovedTourSteps('CUSTOMER').length).toBeGreaterThan(0); + expect(resolveRoleApprovedTourSteps('JOB_SEEKER').length).toBeGreaterThan(0); + }); + + it('returns professional defaults for non-primary roles', () => { + const steps = resolveRoleApprovedTourSteps('PHOTOGRAPHER'); + expect(steps.length).toBeGreaterThan(0); + expect(steps[0].title.toLowerCase()).toContain('photographer'); + }); + + it('uses runtime role override when present', () => { + const steps = resolveRoleApprovedTourSteps('TUTOR', { + roles: { + TUTOR: [{ title: 'Tutor Custom', body: 'Custom flow' }], + }, + }); + expect(steps).toEqual([{ title: 'Tutor Custom', body: 'Custom flow' }]); + }); + + it('uses runtime role approved default when specific role override is absent', () => { + const steps = resolveRoleApprovedTourSteps('MAKEUP_ARTIST', { + role_approved_default: [{ title: 'Default Custom', body: 'Default flow' }], + }); + expect(steps).toEqual([{ title: 'Default Custom', body: 'Default flow' }]); + }); +}); diff --git a/src/lib/guided-tour-content.ts b/src/lib/guided-tour-content.ts new file mode 100644 index 0000000..68813e2 --- /dev/null +++ b/src/lib/guided-tour-content.ts @@ -0,0 +1,132 @@ +import { normalizeRoleForTour } from './guided-tour'; + +export interface GuidedTourStep { + title: string; + body: string; +} + +export interface RuntimeGuidedTours { + welcome?: GuidedTourStep[]; + role_approved_default?: GuidedTourStep[]; + roles?: Record; +} + +const DEFAULT_WELCOME_STEPS: GuidedTourStep[] = [ + { + title: 'Welcome to your Nxtgauge dashboard', + body: 'This is your home base. You can explore options and start your first onboarding flow from here.', + }, + { + title: 'Choose what you want to do', + body: 'Use "Choose What You Need" or "Explore Nxtgauge" to select a path that matches your goal.', + }, + { + title: 'Track your progress', + body: 'You can always return to check status updates, notifications, and your profile in one place.', + }, +]; + +const DEFAULT_COMPANY_APPROVED_STEPS: GuidedTourStep[] = [ + { + title: 'Your company profile is approved', + body: 'You can now create job posts and manage hiring activity from your company dashboard.', + }, + { + title: 'Start with job postings', + body: 'Go to Jobs to create or update postings and send them for admin approval when required.', + }, + { + title: 'Review incoming applications', + body: 'Use Applications to shortlist, reject, and progress candidates through your hiring flow.', + }, +]; + +const DEFAULT_CUSTOMER_APPROVED_STEPS: GuidedTourStep[] = [ + { + title: 'Your customer profile is approved', + body: 'You now have access to requirement workflows and can connect with verified professionals.', + }, + { + title: 'Share your requirements', + body: 'Create requirements with your details, timeline, and budget to receive relevant responses.', + }, + { + title: 'Track and manage responses', + body: 'Use marketplace and request views to compare professionals and move forward confidently.', + }, +]; + +const DEFAULT_JOB_SEEKER_APPROVED_STEPS: GuidedTourStep[] = [ + { + title: 'Your job seeker profile is approved', + body: 'You can now browse jobs and apply directly from your dashboard.', + }, + { + title: 'Find opportunities faster', + body: 'Use job filters and details pages to focus on roles that match your goals.', + }, + { + title: 'Track every application', + body: 'Check your applications panel for shortlist, interview, and offer updates.', + }, +]; + +function isValidStepArray(value: unknown): value is GuidedTourStep[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (step) => + step && + typeof step === 'object' && + typeof (step as GuidedTourStep).title === 'string' && + typeof (step as GuidedTourStep).body === 'string', + ) + ); +} + +function prettyRoleName(role: string): string { + const normalized = normalizeRoleForTour(role); + if (!normalized) return 'professional'; + return normalized.toLowerCase().replaceAll('_', ' '); +} + +function defaultProfessionalApprovedSteps(role: string): GuidedTourStep[] { + const roleName = prettyRoleName(role); + return [ + { + title: `Your ${roleName} profile is approved`, + body: 'Your role-specific dashboard is now unlocked with modules tailored to your work.', + }, + { + title: 'Complete your public profile', + body: 'Update portfolio, services, and profile sections so customers and companies can trust your expertise.', + }, + { + title: 'Respond and grow', + body: 'Track incoming leads or opportunities, and use your dashboard tools to convert them into outcomes.', + }, + ]; +} + +export function resolveWelcomeTourSteps(runtimeTours?: RuntimeGuidedTours | null): GuidedTourStep[] { + if (isValidStepArray(runtimeTours?.welcome)) return runtimeTours!.welcome; + return DEFAULT_WELCOME_STEPS; +} + +export function resolveRoleApprovedTourSteps( + role: string | null | undefined, + runtimeTours?: RuntimeGuidedTours | null, +): GuidedTourStep[] { + const normalizedRole = normalizeRoleForTour(role); + if (!normalizedRole || normalizedRole === 'USER') return []; + + const roleOverride = runtimeTours?.roles?.[normalizedRole]; + if (isValidStepArray(roleOverride)) return roleOverride; + if (isValidStepArray(runtimeTours?.role_approved_default)) return runtimeTours!.role_approved_default; + + if (normalizedRole === 'COMPANY') return DEFAULT_COMPANY_APPROVED_STEPS; + if (normalizedRole === 'CUSTOMER') return DEFAULT_CUSTOMER_APPROVED_STEPS; + if (normalizedRole === 'JOB_SEEKER') return DEFAULT_JOB_SEEKER_APPROVED_STEPS; + return defaultProfessionalApprovedSteps(normalizedRole); +} diff --git a/src/lib/guided-tour.test.ts b/src/lib/guided-tour.test.ts new file mode 100644 index 0000000..839d72e --- /dev/null +++ b/src/lib/guided-tour.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + getRoleTourStorageKey, + getWelcomeTourStorageKey, + normalizeRoleForTour, + pickGuidedTour, + readSeenRoleTours, + writeSeenRoleTours, +} from './guided-tour'; + +describe('guided-tour storage keys', () => { + it('creates stable keys by user id', () => { + expect(getWelcomeTourStorageKey('u1')).toBe('nxtgauge_tour_welcome_seen_u1'); + expect(getRoleTourStorageKey('u1')).toBe('nxtgauge_tour_roles_seen_u1'); + }); +}); + +describe('normalizeRoleForTour', () => { + it('normalizes spacing and case', () => { + expect(normalizeRoleForTour(' job seeker ')).toBe('JOB_SEEKER'); + expect(normalizeRoleForTour('make-up artist')).toBe('MAKE_UP_ARTIST'); + }); +}); + +describe('role tour serialization', () => { + it('reads and writes role sets without duplicates', () => { + const serialized = writeSeenRoleTours(['customer', 'CUSTOMER', 'job-seeker']); + expect(serialized).toBe('CUSTOMER,JOB_SEEKER'); + + const set = readSeenRoleTours(serialized); + expect(set.has('CUSTOMER')).toBe(true); + expect(set.has('JOB_SEEKER')).toBe(true); + expect(set.size).toBe(2); + }); +}); + +describe('pickGuidedTour', () => { + it('shows welcome tour first for known user', () => { + const next = pickGuidedTour({ + userId: 'u1', + activeRole: 'USER', + welcomeTourSeen: false, + seenRoleTours: new Set(), + }); + expect(next).toBe('welcome'); + }); + + it('shows role-approved tour once when active role is non-user', () => { + const next = pickGuidedTour({ + userId: 'u1', + activeRole: 'photographer', + welcomeTourSeen: true, + seenRoleTours: new Set(['CUSTOMER']), + }); + expect(next).toBe('role-approved'); + }); + + it('does not show role-approved tour for already seen roles', () => { + const next = pickGuidedTour({ + userId: 'u1', + activeRole: 'TUTOR', + welcomeTourSeen: true, + seenRoleTours: new Set(['TUTOR']), + }); + expect(next).toBeNull(); + }); +}); diff --git a/src/lib/guided-tour.ts b/src/lib/guided-tour.ts new file mode 100644 index 0000000..3a24104 --- /dev/null +++ b/src/lib/guided-tour.ts @@ -0,0 +1,50 @@ +export type GuidedTourKind = 'welcome' | 'role-approved'; + +export const WELCOME_TOUR_VALUE = '1'; + +export function normalizeRoleForTour(role: string | null | undefined): string { + return String(role ?? '') + .trim() + .toUpperCase() + .replace(/[\s-]+/g, '_'); +} + +export function getWelcomeTourStorageKey(userId: string): string { + return `nxtgauge_tour_welcome_seen_${userId}`; +} + +export function getRoleTourStorageKey(userId: string): string { + return `nxtgauge_tour_roles_seen_${userId}`; +} + +export function readSeenRoleTours(raw: string | null | undefined): Set { + if (!raw) return new Set(); + return new Set( + raw + .split(',') + .map((item) => normalizeRoleForTour(item)) + .filter(Boolean), + ); +} + +export function writeSeenRoleTours(roles: Iterable): string { + const normalized = Array.from(roles) + .map((role) => normalizeRoleForTour(role)) + .filter(Boolean); + return Array.from(new Set(normalized)).join(','); +} + +export function pickGuidedTour(params: { + userId: string | null | undefined; + activeRole: string | null | undefined; + welcomeTourSeen: boolean; + seenRoleTours: Set; +}): GuidedTourKind | null { + if (!params.userId) return null; + if (!params.welcomeTourSeen) return 'welcome'; + + const activeRole = normalizeRoleForTour(params.activeRole); + if (!activeRole || activeRole === 'USER') return null; + if (params.seenRoleTours.has(activeRole)) return null; + return 'role-approved'; +} diff --git a/src/lib/onboarding-flow.test.ts b/src/lib/onboarding-flow.test.ts new file mode 100644 index 0000000..1f7a099 --- /dev/null +++ b/src/lib/onboarding-flow.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { evaluateVisibility, normalizeRoleKey, schemaIdFromInput } from './onboarding-flow'; + +describe('normalizeRoleKey', () => { + it('normalizes case and separators', () => { + expect(normalizeRoleKey('job seeker')).toBe('JOB_SEEKER'); + expect(normalizeRoleKey('job-seeker')).toBe('JOB_SEEKER'); + expect(normalizeRoleKey(' photographer ')).toBe('PHOTOGRAPHER'); + }); +}); + +describe('schemaIdFromInput', () => { + it('maps major role schemas', () => { + expect(schemaIdFromInput('CUSTOMER', '')).toBe('customer_onboarding_v1'); + expect(schemaIdFromInput('COMPANY', '')).toBe('company_onboarding_v1'); + expect(schemaIdFromInput('JOB_SEEKER', '')).toBe('jobseeker_onboarding_v1'); + expect(schemaIdFromInput('jobseeker', '')).toBe('jobseeker_onboarding_v1'); + }); + + it('maps professional schema with profession key', () => { + expect(schemaIdFromInput('PROFESSIONAL', 'Photographer')).toBe('photographer_onboarding_v1'); + expect(schemaIdFromInput('professional', 'Social Media Manager')).toBe('social_media_manager_onboarding_v1'); + expect(schemaIdFromInput('PROFESSIONAL', 'Fitness-Trainer')).toBe('fitness_trainer_onboarding_v1'); + }); + + it('falls back for generic professional when no profession', () => { + expect(schemaIdFromInput('PROFESSIONAL', '')).toBe('professional_onboarding_v1'); + }); +}); + +describe('evaluateVisibility', () => { + it('supports equals conditions', () => { + const visible = evaluateVisibility( + [{ field: 'profession', equals: 'photographer' }], + { profession: 'photographer' }, + ); + expect(visible).toBe(true); + }); + + it('supports in conditions', () => { + const visible = evaluateVisibility( + [{ field: 'service_mode', in: ['remote', 'hybrid'] }], + { service_mode: 'hybrid' }, + ); + expect(visible).toBe(true); + }); + + it('returns false when condition mismatches', () => { + const visible = evaluateVisibility( + [{ field: 'profession', equals: 'developer' }], + { profession: 'tutor' }, + ); + expect(visible).toBe(false); + }); +}); diff --git a/src/lib/onboarding-flow.ts b/src/lib/onboarding-flow.ts new file mode 100644 index 0000000..7d0ced9 --- /dev/null +++ b/src/lib/onboarding-flow.ts @@ -0,0 +1,37 @@ +import type { RuntimeVisibilityCondition } from '~/lib/runtime/types'; + +export function evaluateVisibility( + conditions: RuntimeVisibilityCondition[] | undefined, + values: Record, +) { + if (!conditions || conditions.length === 0) return true; + return conditions.every((condition) => { + const value = values[condition.field]; + if (typeof condition.equals === 'string') return String(value || '') === condition.equals; + if (Array.isArray(condition.in)) return condition.in.includes(String(value || '')); + return true; + }); +} + +export function normalizeRoleKey(value: string | null | undefined) { + return String(value || '') + .trim() + .toUpperCase() + .replace(/[-\s]+/g, '_'); +} + +export function schemaIdFromInput(roleKey: string, profession: string) { + const normalizedRole = normalizeRoleKey(roleKey); + const normalizedProfession = String(profession || '') + .trim() + .toLowerCase() + .replace(/[-\s]+/g, '_') + .replace(/[^a-z_]/g, ''); + + if (normalizedRole === 'CUSTOMER') return 'customer_onboarding_v1'; + if (normalizedRole === 'COMPANY') return 'company_onboarding_v1'; + if (normalizedRole === 'JOB_SEEKER' || normalizedRole === 'JOBSEEKER') return 'jobseeker_onboarding_v1'; + if (normalizedRole === 'PROFESSIONAL' && normalizedProfession) return `${normalizedProfession}_onboarding_v1`; + if (normalizedRole === 'PROFESSIONAL') return 'professional_onboarding_v1'; + return ''; +} diff --git a/src/routes/about.tsx b/src/routes/about.tsx index dc09978..cfc7a16 100644 --- a/src/routes/about.tsx +++ b/src/routes/about.tsx @@ -1,5 +1,7 @@ import { A } from '@solidjs/router'; import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import PublicBackground from '~/components/PublicBackground'; +import PublicFooter from '~/components/PublicFooter'; import PublicHeader from '~/components/PublicHeader'; const chapters = [ @@ -176,44 +178,46 @@ 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 chapterFourMotion = (idx: number) => { const p = effectivePrincipleProgress(); - const stageStart = stageIdx * 0.25; - const stageEnd = (stageIdx + 1) * 0.25; + const center = (idx + 0.5) / chapterFourNarrative.length; + const preWindow = 0.14; - // 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; + // Once a line has crossed center, keep it bright and glowing. + if (p >= center) { + return { + opacity: 1, + y: 0, + glow: 52, + }; } - // Fade in from dimmed to bright - if (p < stageStart) { - return 0.35 + (1 - 0.35) * ((p - fadeInStart) / (stageStart - fadeInStart)); - } + // Before center: approach from dim to bright, but no glow yet. + const distanceToCenter = Math.max(0, center - p); + const t = Math.max(0, Math.min(1, 1 - distanceToCenter / preWindow)); - // 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 { + opacity: 0.46 + t * 0.54, + y: 0, + glow: 0, + }; + }; + const chapterFourBackdropGlow = createMemo(() => { + // Keep chapter backdrop glow once the first narrative line reaches center. + const firstCenter = 0.5 / chapterFourNarrative.length; + return effectivePrincipleProgress() >= firstCenter ? 1 : 0; + }); + const scrollToChapter = (chapterId: string) => { + const target = document.getElementById(chapterId); + if (!target) return; + const offset = window.innerWidth >= 1280 ? 180 : 150; + const top = target.getBoundingClientRect().top + window.scrollY - offset; + window.scrollTo({ top: Math.max(0, top), behavior: reduceMotion() ? 'auto' : 'smooth' }); }; return (
-
); diff --git a/src/routes/auth/forgot-password/index.tsx b/src/routes/auth/forgot-password/index.tsx index 553b5f3..d120afa 100644 --- a/src/routes/auth/forgot-password/index.tsx +++ b/src/routes/auth/forgot-password/index.tsx @@ -1,5 +1,6 @@ import { A, useSearchParams } from '@solidjs/router'; import { createMemo, createSignal } from 'solid-js'; +import PublicBackground from '~/components/PublicBackground'; function getPasswordChecks(password: string, confirmPassword: string) { return { @@ -110,12 +111,7 @@ export default function ForgotPasswordPage() { return (
-