feat: align solid public pages and auth flows with nextjs parity

This commit is contained in:
Ashwin Kumar 2026-03-17 15:35:58 +01:00
parent e3b857a767
commit bf4b2558d8
71 changed files with 7205 additions and 506 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

View file

@ -0,0 +1,12 @@
<svg width="640" height="320" viewBox="0 0 640 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="320" rx="24" fill="#F8FAFF"/>
<rect x="42" y="46" width="556" height="228" rx="18" fill="#FFFFFF"/>
<rect x="82" y="90" width="210" height="18" rx="9" fill="#100B2F"/>
<rect x="82" y="124" width="164" height="10" rx="5" fill="#64748B"/>
<rect x="82" y="148" width="190" height="10" rx="5" fill="#94A3B8"/>
<rect x="82" y="172" width="132" height="10" rx="5" fill="#CBD5E1"/>
<rect x="324" y="82" width="234" height="156" rx="14" fill="#100B2F"/>
<rect x="352" y="112" width="178" height="12" rx="6" fill="#FD6216"/>
<rect x="352" y="140" width="154" height="10" rx="5" fill="#E2E8F0"/>
<rect x="352" y="164" width="134" height="10" rx="5" fill="#CBD5E1"/>
</svg>

After

Width:  |  Height:  |  Size: 809 B

View file

@ -0,0 +1,12 @@
<svg width="640" height="320" viewBox="0 0 640 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="320" rx="24" fill="#FFF8F5"/>
<rect x="42" y="46" width="556" height="228" rx="18" fill="#FFFFFF"/>
<circle cx="146" cy="126" r="34" fill="#FD6216" fill-opacity="0.2"/>
<circle cx="226" cy="158" r="26" fill="#100B2F" fill-opacity="0.2"/>
<circle cx="174" cy="194" r="22" fill="#FD6216"/>
<rect x="286" y="88" width="250" height="18" rx="9" fill="#100B2F"/>
<rect x="286" y="120" width="206" height="10" rx="5" fill="#64748B"/>
<rect x="286" y="144" width="226" height="10" rx="5" fill="#94A3B8"/>
<rect x="286" y="168" width="178" height="10" rx="5" fill="#CBD5E1"/>
<rect x="286" y="196" width="148" height="10" rx="5" fill="#E2E8F0"/>
</svg>

After

Width:  |  Height:  |  Size: 790 B

View file

@ -0,0 +1,10 @@
<svg width="640" height="320" viewBox="0 0 640 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="320" rx="24" fill="#F8FAFF"/>
<rect x="34" y="42" width="572" height="236" rx="18" fill="#100B2F"/>
<rect x="74" y="84" width="198" height="12" rx="6" fill="#FD6216"/>
<rect x="74" y="114" width="304" height="10" rx="5" fill="#E2E8F0"/>
<rect x="74" y="140" width="268" height="10" rx="5" fill="#CBD5E1"/>
<rect x="74" y="166" width="168" height="10" rx="5" fill="#94A3B8"/>
<circle cx="492" cy="144" r="56" fill="#FD6216" fill-opacity="0.18"/>
<circle cx="492" cy="144" r="28" fill="#FD6216"/>
</svg>

After

Width:  |  Height:  |  Size: 644 B

View file

@ -0,0 +1,10 @@
<svg width="640" height="320" viewBox="0 0 640 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="320" rx="24" fill="#FFF7F2"/>
<rect x="42" y="46" width="556" height="228" rx="18" fill="#FFFFFF"/>
<rect x="74" y="78" width="236" height="164" rx="14" fill="#100B2F"/>
<circle cx="192" cy="146" r="44" fill="#FD6216"/>
<rect x="332" y="88" width="236" height="14" rx="7" fill="#100B2F"/>
<rect x="332" y="118" width="190" height="10" rx="5" fill="#64748B"/>
<rect x="332" y="142" width="210" height="10" rx="5" fill="#94A3B8"/>
<rect x="332" y="166" width="160" height="10" rx="5" fill="#CBD5E1"/>
</svg>

After

Width:  |  Height:  |  Size: 648 B

View file

@ -0,0 +1,13 @@
<svg width="640" height="320" viewBox="0 0 640 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="640" height="320" rx="24" fill="#F8FAFC"/>
<rect x="42" y="46" width="556" height="228" rx="18" fill="#FFFFFF"/>
<rect x="80" y="78" width="160" height="196" rx="12" fill="#100B2F"/>
<rect x="104" y="108" width="112" height="12" rx="6" fill="#FD6216"/>
<rect x="104" y="136" width="92" height="10" rx="5" fill="#E2E8F0"/>
<rect x="104" y="160" width="96" height="10" rx="5" fill="#CBD5E1"/>
<rect x="270" y="82" width="284" height="22" rx="11" fill="#100B2F"/>
<rect x="270" y="120" width="260" height="12" rx="6" fill="#64748B"/>
<rect x="270" y="146" width="222" height="12" rx="6" fill="#94A3B8"/>
<rect x="270" y="172" width="242" height="12" rx="6" fill="#CBD5E1"/>
<circle cx="528" cy="226" r="20" fill="#FD6216"/>
</svg>

After

Width:  |  Height:  |  Size: 863 B

BIN
public/images/artist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

BIN
public/images/expert-1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
public/images/expert-2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
public/images/expert-3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

BIN
public/images/expert-4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/images/hero-left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
public/images/photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

BIN
public/images/study.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

BIN
public/images/tutor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,204 @@
import { createMemo, createSignal, onCleanup, onMount } from 'solid-js';
const PHASES = [
'Opportunities start scattered',
'Profiles and opportunities connect',
'Relevant matches are prioritized',
'Everything organizes into one system',
'Nxtgauge: one clear opportunity workspace',
] as const;
type NodeDef = {
id: string;
label: string;
kind: 'profile' | 'opportunity' | 'signal' | 'status';
p2: [number, number];
p4: [number, number];
};
const NODES: NodeDef[] = [
{ id: 'developer', label: 'Developer', kind: 'profile', p2: [12, 18], p4: [18, 28] },
{ id: 'tutor', label: 'Tutor', kind: 'profile', p2: [30, 20], p4: [18, 40] },
{ id: 'photographer', label: 'Photographer', kind: 'profile', p2: [19, 44], p4: [18, 52] },
{ id: 'company', label: 'Company', kind: 'profile', p2: [24, 72], p4: [18, 64] },
{ id: 'verified', label: 'Verified', kind: 'status', p2: [44, 18], p4: [40, 28] },
{ id: 'skills', label: 'Skills', kind: 'signal', p2: [38, 62], p4: [40, 40] },
{ id: 'portfolio', label: 'Portfolio', kind: 'signal', p2: [58, 12], p4: [40, 52] },
{ id: 'match', label: 'Match', kind: 'signal', p2: [56, 70], p4: [56, 40] },
{ id: 'lead', label: 'Lead', kind: 'signal', p2: [72, 24], p4: [56, 52] },
{ id: 'job', label: 'Job', kind: 'opportunity', p2: [84, 30], p4: [72, 30] },
{ id: 'project', label: 'Project', kind: 'opportunity', p2: [78, 58], p4: [72, 44] },
{ id: 'opening', label: 'Opening', kind: 'opportunity', p2: [90, 74], p4: [72, 58] },
{ id: 'response', label: 'Response', kind: 'status', p2: [92, 48], p4: [88, 36] },
{ id: 'update', label: 'Update', kind: 'status', p2: [74, 10], p4: [88, 52] },
];
const EDGES: Array<[string, string]> = [
['developer', 'skills'],
['tutor', 'skills'],
['photographer', 'skills'],
['company', 'verified'],
['developer', 'verified'],
['portfolio', 'verified'],
['verified', 'match'],
['skills', 'match'],
['lead', 'match'],
['match', 'job'],
['match', 'project'],
['match', 'opening'],
['portfolio', 'project'],
['job', 'response'],
['project', 'response'],
['opening', 'response'],
['response', 'update'],
];
const WORKSPACE_CARDS = [
{ label: 'Verified Profiles', value: 'Trust layer ready' },
{ label: 'Matched Opportunities', value: 'Priority queue' },
{ label: 'Responses', value: 'Tracked in one flow' },
{ label: 'Updates', value: 'Live status signals' },
];
type Props = {
reduceMotion: boolean;
};
export default function OpportunityGraph(props: Props) {
const [timelineMs, setTimelineMs] = createSignal(0);
const [paused, setPaused] = createSignal(false);
const LOOP_MS = 42000;
const EDGE_REVEAL_STEP_MS = 1500;
onMount(() => {
if (props.reduceMotion) {
setTimelineMs(LOOP_MS - 1000);
return;
}
const ticker = window.setInterval(() => {
if (paused()) return;
setTimelineMs((prev) => Math.min(prev + 100, LOOP_MS));
}, 100);
onCleanup(() => window.clearInterval(ticker));
});
const edgeStartMs = 16500;
const nodeEndMs = 14000;
const workspaceStartMs = 37200;
const edgeRevealCount = createMemo(() => {
if (timelineMs() < edgeStartMs) return 0;
return Math.min(EDGES.length, Math.floor((timelineMs() - edgeStartMs) / EDGE_REVEAL_STEP_MS) + 1);
});
const nodeRevealCount = createMemo(() => {
const nodeProgress = Math.max(0, Math.min(1, timelineMs() / nodeEndMs));
return Math.floor(nodeProgress * NODES.length);
});
const workspaceVisible = createMemo(() => timelineMs() >= workspaceStartMs);
const nodePos = createMemo(() => {
const map = new Map<string, [number, number]>();
for (const node of NODES) map.set(node.id, node.p2);
return map;
});
const copy = createMemo(() => {
const t = timelineMs();
if (t < 12000) return PHASES[0];
if (t < 23500) return PHASES[1];
if (t < 32500) return PHASES[2];
if (t < workspaceStartMs) return PHASES[3];
return PHASES[4];
});
const lineCoords = (pair: [string, string]) => {
const [from, to] = pair;
const a = nodePos().get(from);
const b = nodePos().get(to);
if (!a || !b) return null;
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.001) return null;
const trim = 1.6;
const ux = dx / dist;
const uy = dy / dist;
return {
x1: a[0] + ux * trim,
y1: a[1] + uy * trim,
x2: b[0] - ux * trim,
y2: b[1] - uy * trim,
len: Math.sqrt((b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])),
};
};
return (
<div
class="op-graph-wrap"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusIn={() => setPaused(true)}
onFocusOut={() => setPaused(false)}
aria-label="Nxtgauge opportunity graph preview"
role="region"
>
<div class={`op-graph ${workspaceVisible() ? 'op-graph-phase4' : ''}`}>
<div class="op-graph-canvas">
<svg class="op-graph-svg" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{EDGES.map((edge, index) => {
const c = lineCoords(edge);
if (!c) return null;
const visible = index < edgeRevealCount();
const drawing = visible && index === edgeRevealCount() - 1;
return (
<line
class={`op-graph-line ${visible ? 'op-graph-line-visible' : 'op-graph-line-hidden'} ${drawing ? 'op-graph-line-drawing' : ''}`}
style={{ '--op-line-len': `${Math.sqrt((c.x2 - c.x1) * (c.x2 - c.x1) + (c.y2 - c.y1) * (c.y2 - c.y1))}` }}
x1={c.x1}
y1={c.y1}
x2={c.x2}
y2={c.y2}
/>
);
})}
</svg>
{!workspaceVisible() && NODES.map((node, index) => {
if (index >= nodeRevealCount()) return null;
const xy = nodePos().get(node.id) || [0, 0];
return (
<span
class={`op-graph-node op-graph-node-chip op-graph-node-${node.kind} op-graph-node-on`}
style={{ left: `${xy[0]}%`, top: `${xy[1]}%` }}
>
{node.label}
</span>
);
})}
<div class="op-graph-workspace">
<div class="op-graph-workspace-tick">
<span class="op-graph-workspace-tick-icon"></span>
<span class="op-graph-workspace-tick-text">Everything from Nxtgauge</span>
</div>
<p class="op-graph-workspace-title">Opportunity Workspace</p>
{WORKSPACE_CARDS.map((card) => (
<span class="op-graph-workspace-row">
<strong>{card.label}</strong>
<small>{card.value}</small>
</span>
))}
</div>
</div>
<div class="op-graph-copy">
<p class="op-graph-copy-line">{copy()}</p>
{workspaceVisible() ? <p class="op-graph-copy-sub">From scattered signals to one clear decision system.</p> : null}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,87 @@
import { A, useLocation } from '@solidjs/router';
import { Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js';
type PublicHeaderProps = {
loginHref?: string;
signupHref?: string;
};
const isRouteActive = (pathname: string, href: string) => {
if (href === '/') return pathname === '/';
if (href === '/help-center') return pathname === '/help-center' || pathname.startsWith('/help-center/');
return pathname === href;
};
export default function PublicHeader(props: PublicHeaderProps) {
const location = useLocation();
const [scrolled, setScrolled] = createSignal(false);
const [mobileOpen, setMobileOpen] = createSignal(false);
const loginHref = () => props.loginHref || '/auth/login';
const signupHref = () => props.signupHref || '/auth/register';
onMount(() => {
const onScroll = () => setScrolled(window.scrollY > 10);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
onCleanup(() => window.removeEventListener('scroll', onScroll));
});
createEffect(() => {
location.pathname;
setMobileOpen(false);
});
return (
<header class={`public-header ${scrolled() ? 'public-header-scrolled' : ''}`}>
<nav class="container nav-row">
<A href="/" aria-label="Nxtgauge home">
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
</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>
</div>
<div class="desktop-only nav-actions">
<A class="nav-auth-btn nav-auth-secondary" href={loginHref()}>Login</A>
<A class="nav-auth-btn nav-auth-primary" href={signupHref()}>Sign Up</A>
</div>
<button
type="button"
class="mobile-menu-toggle"
aria-label={mobileOpen() ? 'Close navigation menu' : 'Open navigation menu'}
aria-expanded={mobileOpen()}
aria-controls="public-mobile-nav"
onClick={() => setMobileOpen(!mobileOpen())}
>
<span class="sr-only">Menu</span>
<span class="mobile-bars">
<span />
<span />
<span />
</span>
</button>
</nav>
<Show when={mobileOpen()}>
<div id="public-mobile-nav" class="mobile-nav">
<div class="mobile-nav-links container">
<A href="/" onClick={() => setMobileOpen(false)}>Home</A>
<A href="/about" onClick={() => setMobileOpen(false)}>About Us</A>
<A href="/help-center" onClick={() => setMobileOpen(false)}>Help Center</A>
<A href="/contact" onClick={() => setMobileOpen(false)}>Contact Us</A>
</div>
<div class="mobile-nav-actions container">
<A class="mobile-login" href={loginHref()} onClick={() => setMobileOpen(false)}>Login</A>
<A class="mobile-signup" href={signupHref()} onClick={() => setMobileOpen(false)}>Sign Up</A>
</div>
</div>
</Show>
</header>
);
}

View file

@ -1,10 +1,14 @@
import { A } from '@solidjs/router';
import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
import OpportunityGraph from '~/components/OpportunityGraph';
import PublicHeader from '~/components/PublicHeader';
type PathCard = {
title: string;
description: string;
button: string;
chip: string;
icon: 'briefcase' | 'user' | 'users' | 'code' | 'camera' | 'sparkles' | 'grad' | 'film' | 'pen' | 'share' | 'dumbbell' | 'utensils';
href: string;
image: string;
audience: 'customer' | 'professional' | 'company' | 'job_seeker';
@ -18,39 +22,14 @@ type Flow = {
steps: Array<{ title: string; description: string }>;
};
const heroSlides = [
{
title: 'Customers',
bullets: ['Post requirements with clear intent', 'Receive verified responses after review'],
href: '/onboarding?schemaId=customer_onboarding_v1',
cta: 'Hire a Professional',
},
{
title: 'Professionals',
bullets: ['Complete role onboarding and verification', 'Discover leads with focused matching'],
href: '/onboarding?schemaId=professional_onboarding_v1',
cta: 'Join as Professional',
},
{
title: 'Companies',
bullets: ['Create approved job listings', 'Track applications in one workflow'],
href: '/onboarding?schemaId=company_onboarding_v1',
cta: 'Post a Job',
},
{
title: 'Job Seekers',
bullets: ['Build profile and clear approvals', 'Apply and monitor opportunities'],
href: '/onboarding?schemaId=jobseeker_onboarding_v1',
cta: 'Apply for Jobs',
},
] as const;
const pathCards: PathCard[] = [
{
title: 'Post a Job',
description: 'Create verified job openings and find the right talent faster.',
button: 'Start as Company',
href: '/onboarding?schemaId=company_onboarding_v1',
chip: 'Company',
icon: 'briefcase',
href: '/auth/register?intent=company&redirect=/users/onboarding/company',
audience: 'company',
image: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?q=80&w=800&auto=format&fit=crop',
},
@ -58,7 +37,9 @@ const pathCards: PathCard[] = [
title: 'Apply for Jobs',
description: 'Build your profile and apply to approved opportunities quickly.',
button: 'Start as Job Seeker',
href: '/onboarding?schemaId=jobseeker_onboarding_v1',
chip: 'Job Seeker',
icon: 'user',
href: '/auth/register?intent=job_seeker&redirect=/users/onboarding/job-seeker',
audience: 'job_seeker',
image: 'https://images.unsplash.com/photo-1586281380349-632531db7ed4?q=80&w=800&auto=format&fit=crop',
},
@ -66,7 +47,9 @@ const pathCards: PathCard[] = [
title: 'Hire a Professional',
description: 'Post your requirement and discover trusted specialists.',
button: 'Start as Customer',
href: '/onboarding?schemaId=customer_onboarding_v1',
chip: 'Customer',
icon: 'users',
href: '/auth/register?intent=customer&redirect=/users/onboarding/customer',
audience: 'customer',
image: 'https://images.unsplash.com/photo-1450101499163-c8848c66ca85?q=80&w=800&auto=format&fit=crop',
},
@ -74,7 +57,9 @@ const pathCards: PathCard[] = [
title: 'Join as Developer',
description: 'Build products and grow with verified client demand.',
button: 'Join Developer',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Developer',
chip: 'Professional',
icon: 'code',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=developer',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=800&auto=format&fit=crop',
},
@ -82,7 +67,9 @@ const pathCards: PathCard[] = [
title: 'Join as Photographer',
description: 'Capture events and campaigns with trusted bookings.',
button: 'Join Photographer',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Photographer',
chip: 'Professional',
icon: 'camera',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=photographer',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1516035069371-29a1b244cc32?q=80&w=800&auto=format&fit=crop',
},
@ -90,7 +77,9 @@ const pathCards: PathCard[] = [
title: 'Join as Makeup Artist',
description: 'Offer styling services with profile-based trust signals.',
button: 'Join Makeup Artist',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Makeup%20Artist',
chip: 'Professional',
icon: 'sparkles',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=makeup_artist',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9?q=80&w=800&auto=format&fit=crop',
},
@ -98,7 +87,9 @@ const pathCards: PathCard[] = [
title: 'Join as Tutor',
description: 'Teach learners and build your reputation with verified profiles.',
button: 'Join Tutor',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Tutor',
chip: 'Professional',
icon: 'grad',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=tutor',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1497633762265-9d179a990aa6?q=80&w=800&auto=format&fit=crop',
},
@ -106,7 +97,9 @@ const pathCards: PathCard[] = [
title: 'Join as Video Editor',
description: 'Create compelling edits and work with quality clients.',
button: 'Join Video Editor',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Video%20Editor',
chip: 'Professional',
icon: 'film',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=video_editor',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?q=80&w=800&auto=format&fit=crop',
},
@ -114,7 +107,9 @@ const pathCards: PathCard[] = [
title: 'Join as Graphic Designer',
description: 'Design brand-ready visuals and collaborate with growing businesses.',
button: 'Join Graphic Designer',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Graphic%20Designer',
chip: 'Professional',
icon: 'pen',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=graphic_designer',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1558655146-d09347e92766?q=80&w=800&auto=format&fit=crop',
},
@ -122,7 +117,9 @@ const pathCards: PathCard[] = [
title: 'Join as Social Media Manager',
description: 'Plan campaigns and scale audience growth for clients.',
button: 'Join Social Manager',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Social%20Media%20Manager',
chip: 'Professional',
icon: 'share',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=social_media_manager',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1611162618071-b39a2ec055fb?q=80&w=800&auto=format&fit=crop',
},
@ -130,7 +127,9 @@ const pathCards: PathCard[] = [
title: 'Join as Fitness Trainer',
description: 'Coach clients with structured plans and trusted profiles.',
button: 'Join Trainer',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Fitness%20Trainer',
chip: 'Professional',
icon: 'dumbbell',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=fitness_trainer',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=800&auto=format&fit=crop',
},
@ -138,19 +137,21 @@ const pathCards: PathCard[] = [
title: 'Join as Catering Services',
description: 'Showcase event-ready menus to customers and companies.',
button: 'Join Catering',
href: '/onboarding?schemaId=professional_onboarding_v1&profession=Catering%20Services',
chip: 'Professional',
icon: 'utensils',
href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=catering_services',
audience: 'professional',
image: 'https://images.unsplash.com/photo-1555244162-803834f70033?q=80&w=800&auto=format&fit=crop',
},
];
const benefits = [
{ title: 'Verified profiles & businesses', body: 'Identity and profile checks reduce fake submissions and spam.' },
{ title: 'Approval-based quality (24-48 hours)', body: 'Profiles, requirements, and jobs are reviewed before visibility.' },
{ title: 'Smart matching using tags/skills', body: 'Tag-based relevance helps surface better opportunities faster.' },
{ title: 'Focused discovery with filters', body: 'Search and filter tools keep opportunity discovery focused.' },
{ title: 'Controlled contact visibility', body: 'Sensitive contact flow remains controlled by role and workflow.' },
{ title: 'Notifications & updates', body: 'Track approvals, responses, applications, and key updates.' },
{ title: 'Verified profiles & businesses', body: 'Identity and profile checks reduce fake submissions and spam.', icon: 'shield-check' },
{ title: 'Approval-based quality (24-48 hours)', body: 'Profiles, requirements, and jobs are reviewed before visibility.', icon: 'zap' },
{ title: 'Smart matching using tags/skills', body: 'Tag-based relevance helps surface better opportunities faster.', icon: 'hash' },
{ title: 'Focused discovery with filters', body: 'Search and filter tools keep opportunity discovery focused.', icon: 'search' },
{ title: 'Controlled contact visibility', body: 'Sensitive contact flow remains controlled by role and workflow.', icon: 'lock' },
{ title: 'Notifications & updates', body: 'Track approvals, responses, applications, and key updates.', icon: 'bell' },
] as const;
const flows: Flow[] = [
@ -228,36 +229,159 @@ const faqs = [
] as const;
const chipNodes = [
{ label: '</>', left: '3%', top: '14%', cls: 'lp-chip-slow' },
{ label: 'Cam', left: '95%', top: '20%', cls: 'lp-chip-mid' },
{ label: 'Job', left: '5%', top: '78%', cls: 'lp-chip-fast' },
{ label: 'Pro', left: '92%', top: '74%', cls: 'lp-chip-slow' },
{ label: 'AI', left: '48%', top: '7%', cls: 'lp-chip-mid' },
{ 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 (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="M14 4a2 2 0 0 1 1.76 1.05l.49.9A2 2 0 0 0 18 7h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h2a2 2 0 0 0 1.76-1.05l.49-.9A2 2 0 0 1 10 4z" />
<circle cx="12" cy="13" r="3" />
</svg>
);
}
if (props.kind === 'briefcase') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
<path d="M22 13a18 18 0 0 1-20 0" />
<rect x="2" y="6" width="20" height="14" rx="2" />
</svg>
);
}
if (props.kind === 'bell') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="M10.3 21a2 2 0 0 0 3.4 0" />
<path d="M3.3 15.3A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.67C19.4 14 18 12.5 18 8A6 6 0 0 0 6 8c0 4.5-1.4 6-2.7 7.3" />
</svg>
);
}
if (props.kind === 'sparkles') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="M11 2.8a1 1 0 0 1 2 0l1 5.6a2 2 0 0 0 1.6 1.6l5.6 1a1 1 0 0 1 0 2l-5.6 1a2 2 0 0 0-1.6 1.6l-1 5.6a1 1 0 0 1-2 0l-1-5.6A2 2 0 0 0 8.4 14l-5.6-1a1 1 0 0 1 0-2l5.6-1A2 2 0 0 0 10 8.4z" />
<path d="M20 2v4M22 4h-4" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="m18 16 4-4-4-4" />
<path d="m6 8-4 4 4 4" />
<path d="m14.5 4-5 16" />
</svg>
);
}
function PathRoleIcon(props: { kind: PathCard['icon'] }) {
const common = { fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' } as const;
if (props.kind === 'briefcase') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" /><path d="M22 13a18 18 0 0 1-20 0" /><rect x="2" y="6" width="20" height="14" rx="2" /></svg>;
if (props.kind === 'user') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><circle cx="12" cy="8" r="4" /><path d="M6 20c1.4-3.2 3.4-5 6-5s4.6 1.8 6 5" /></svg>;
if (props.kind === 'users') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>;
if (props.kind === 'code') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="m18 16 4-4-4-4" /><path d="m6 8-4 4 4 4" /><path d="m14.5 4-5 16" /></svg>;
if (props.kind === 'camera') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="M14 4a2 2 0 0 1 1.76 1.05l.49.9A2 2 0 0 0 18 7h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h2a2 2 0 0 0 1.76-1.05l.49-.9A2 2 0 0 1 10 4z" /><circle cx="12" cy="13" r="3" /></svg>;
if (props.kind === 'sparkles') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="M11 2.8a1 1 0 0 1 2 0l1 5.6a2 2 0 0 0 1.6 1.6l5.6 1a1 1 0 0 1 0 2l-5.6 1a2 2 0 0 0-1.6 1.6l-1 5.6a1 1 0 0 1-2 0l-1-5.6A2 2 0 0 0 8.4 14l-5.6-1a1 1 0 0 1 0-2l5.6-1A2 2 0 0 0 10 8.4z" /><path d="M20 2v4M22 4h-4" /></svg>;
if (props.kind === 'grad') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="m22 10-10-5-10 5 10 5 10-5z" /><path d="M6 12v5c3 2 9 2 12 0v-5" /></svg>;
if (props.kind === 'film') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><rect x="2" y="2" width="20" height="20" rx="2" /><path d="M7 2v20M17 2v20M2 7h20M2 17h20" /></svg>;
if (props.kind === 'pen') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="M12 20h9" /><path d="m16.5 3.5 4 4L7 21H3v-4z" /></svg>;
if (props.kind === 'share') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.4 15.4 17M15.4 7 8.6 10.6" /></svg>;
if (props.kind === 'dumbbell') return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="M6 10v4M4 9v6M2 8v8M18 10v4M20 9v6M22 8v8M6 12h12" /></svg>;
return <svg viewBox="0 0 24 24" aria-hidden="true" {...common}><path d="M3 4h18" /><path d="M6 4v16h12V4" /><path d="M9 10h6M9 14h6" /></svg>;
}
function CheckBadgeIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 12.4l2 2 4-4" />
<circle cx="12" cy="12" r="9" />
</svg>
);
}
function WhyBenefitIcon(props: { kind: (typeof benefits)[number]['icon'] }) {
const common = { fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' } as const;
if (props.kind === 'shield-check') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="M12 2 4 6v6c0 5 3.5 8.5 8 10 4.5-1.5 8-5 8-10V6z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
if (props.kind === 'zap') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="M13 2 3 14h7l-1 8 10-12h-7z" />
</svg>
);
}
if (props.kind === 'hash') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="M4 9h16M3 15h16M10 3 8 21M16 3l-2 18" />
</svg>
);
}
if (props.kind === 'search') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.35-4.35" />
</svg>
);
}
if (props.kind === 'lock') {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
<path d="M10.3 21a2 2 0 0 0 3.4 0" />
<path d="M3.3 15.3A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.67C19.4 14 18 12.5 18 8A6 6 0 0 0 6 8c0 4.5-1.4 6-2.7 7.3" />
</svg>
);
}
export default function PublicLanding() {
const [scrolled, setScrolled] = createSignal(false);
const [mobileOpen, setMobileOpen] = createSignal(false);
const [scrollY, setScrollY] = createSignal(0);
const [reduceMotion, setReduceMotion] = createSignal(false);
const [scrollY, setScrollY] = createSignal(0);
const [heroTilt, setHeroTilt] = createSignal({ x: 0, y: 0 });
const [heroIdx, setHeroIdx] = createSignal(0);
const [filter, setFilter] = createSignal<'all' | 'customer' | 'professional' | 'company' | 'job_seeker'>('all');
const [pathPage, setPathPage] = createSignal(0);
const [cardsPerPage, setCardsPerPage] = createSignal(3);
const [pathPaused, setPathPaused] = createSignal(false);
const [pathTouchStartX, setPathTouchStartX] = createSignal<number | null>(null);
const [benefitIdx, setBenefitIdx] = createSignal(0);
const [flowIndex, setFlowIndex] = createSignal(0);
const [flowStepIndex, setFlowStepIndex] = createSignal(0);
const [openFaq, setOpenFaq] = createSignal(0);
const [showBackToTop, setShowBackToTop] = createSignal(false);
onMount(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const syncMotion = () => setReduceMotion(media.matches);
syncMotion();
let ticking = false;
const onScroll = () => {
setScrolled(window.scrollY > 10);
setScrollY(window.scrollY || 0);
setShowBackToTop(window.scrollY > 500);
if (media.matches) return;
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
setScrollY(window.scrollY || 0);
ticking = false;
});
};
const syncCardsPerPage = () => {
@ -279,8 +403,6 @@ export default function PublicLanding() {
window.addEventListener('resize', syncCardsPerPage);
media.addEventListener('change', syncMotion);
const heroTimer = window.setInterval(() => setHeroIdx((x) => (x + 1) % heroSlides.length), 3500);
const pathTimer = window.setInterval(() => setPathPage((x) => x + 1), 4200);
const benefitTimer = window.setInterval(() => setBenefitIdx((x) => (x + 1) % benefits.length), 4200);
const flowTimer = window.setInterval(() => {
const active = flows[flowIndex()];
@ -296,21 +418,13 @@ export default function PublicLanding() {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', syncCardsPerPage);
media.removeEventListener('change', syncMotion);
window.clearInterval(heroTimer);
window.clearInterval(pathTimer);
window.clearInterval(benefitTimer);
window.clearInterval(flowTimer);
});
});
const filteredPaths = createMemo(() => {
const value = filter();
if (value === 'all') return pathCards;
return pathCards.filter((card) => card.audience === value);
});
const pagedPaths = createMemo(() => {
const cards = filteredPaths();
const cards = pathCards;
const per = cardsPerPage();
const pages: PathCard[][] = [];
for (let i = 0; i < cards.length; i += per) pages.push(cards.slice(i, i + per));
@ -323,29 +437,29 @@ export default function PublicLanding() {
return pathPage() % pages.length;
});
const pathCardsVisible = createMemo(() => {
const pages = pagedPaths();
if (pages.length === 0) return [];
return pages[activePathPage()];
createEffect(() => {
const pagesLen = pagedPaths().length;
if (reduceMotion() || pathPaused() || pagesLen <= 1) return;
const timer = window.setInterval(() => {
setPathPage((prev) => {
const next = prev + 1;
return next >= pagesLen ? 0 : next;
});
}, 4200);
onCleanup(() => window.clearInterval(timer));
});
const parallax = createMemo(() => ({
mesh: Math.min(36, scrollY() * 0.1),
ribbon: Math.min(52, scrollY() * 0.18),
chips: Math.min(70, scrollY() * 0.23),
}));
return (
<main class="lp-main">
<div class="lp-bg" aria-hidden="true">
<div class="lp-dark-base" />
<div class="lp-mesh" style={{ transform: `translate3d(0, ${parallax().mesh}px, 0)` }} />
<div class="lp-ribbon" style={{ transform: `translate3d(0, ${parallax().ribbon}px, 0)` }} />
<div class="lp-chips" style={{ transform: `translate3d(0, ${parallax().chips}px, 0)` }}>
<div class="lp-mesh" style={{ transform: `translate3d(0, ${reduceMotion() ? 0 : Math.min(36, scrollY() * 0.1)}px, 0)` }} />
<div class="lp-ribbon" style={{ transform: `translate3d(0, ${reduceMotion() ? 0 : Math.min(58, scrollY() * 0.18)}px, 0)` }} />
<div class="lp-chips" style={{ transform: `translate3d(0, ${reduceMotion() ? 0 : Math.min(80, scrollY() * 0.24)}px, 0)` }}>
<For each={chipNodes}>
{(chip) => (
<span class={`lp-chip ${chip.cls}`} style={{ left: chip.left, top: chip.top }}>
{chip.label}
<span class={`lp-chip ${chip.cls}`} style={{ left: chip.left, top: chip.top, width: `${chip.size}px`, height: `${chip.size}px` }}>
<ChipIcon kind={chip.kind} />
</span>
)}
</For>
@ -354,39 +468,10 @@ export default function PublicLanding() {
</div>
<div class="lp-content">
<header class={`public-header ${scrolled() ? 'public-header-scrolled' : ''}`}>
<nav class="container nav-row">
<A href="/"><img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" /></A>
<div class="desktop-only nav-links">
<A href="/">Home</A>
<A href="/about">About Us</A>
<A href="/help-center">Help Center</A>
<A href="/contact">Contact Us</A>
</div>
<div class="desktop-only nav-actions">
<A class="btn" href="/auth/login">Login</A>
<A class="btn primary" href="/auth/register">Sign Up</A>
</div>
<button class="btn mobile-menu" onClick={() => setMobileOpen(!mobileOpen())}>Menu</button>
</nav>
<Show when={mobileOpen()}>
<div class="mobile-nav container">
<A href="/" onClick={() => setMobileOpen(false)}>Home</A>
<A href="/about" onClick={() => setMobileOpen(false)}>About Us</A>
<A href="/help-center" onClick={() => setMobileOpen(false)}>Help Center</A>
<A href="/contact" onClick={() => setMobileOpen(false)}>Contact Us</A>
<A href="/auth/login" onClick={() => setMobileOpen(false)}>Login</A>
<A href="/auth/register" onClick={() => setMobileOpen(false)}>Sign Up</A>
</div>
</Show>
</header>
<PublicHeader />
<section
class="public-hero scene-dark"
class="public-hero scene-dark lp-section-hero"
onMouseMove={(event) => {
if (reduceMotion()) return;
const rect = event.currentTarget.getBoundingClientRect();
@ -403,127 +488,204 @@ export default function PublicLanding() {
Nxtgauge connects customers, companies, job seekers, and professionals through a trusted approval workflow.
</p>
<div class="hero-actions">
<A class="btn primary" href="/onboarding?schemaId=customer_onboarding_v1">Hire a Professional</A>
<A class="btn ghost-dark" href="/onboarding?schemaId=jobseeker_onboarding_v1">Apply for Jobs</A>
<A class="lp-primary-btn" href="/auth/register?intent=customer&redirect=/users/onboarding/customer">Hire a Professional</A>
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/auth/register?intent=job_seeker&redirect=/users/onboarding/job-seeker">Apply for Jobs</A>
</div>
</div>
<div
class="lp-hero-slider"
class="lp-hero-graph"
style={{
transform: reduceMotion()
? 'none'
: `translate3d(${heroTilt().x}px, ${heroTilt().y}px, 0)`,
transform: reduceMotion() ? 'none' : `translate3d(${heroTilt().x}px, ${heroTilt().y}px, 0)`,
}}
>
<For each={heroSlides}>
{(slide, idx) => (
<article class={`lp-float-card ${idx() === heroIdx() ? 'active' : ''}`}>
<h3>{slide.title}</h3>
<ul>
<For each={slide.bullets}>{(b) => <li>{b}</li>}</For>
</ul>
<A class="btn" href={slide.href}>{slide.cta}</A>
</article>
)}
</For>
<OpportunityGraph reduceMotion={reduceMotion()} />
</div>
</div>
</section>
<section id="choose-path" class="public-section scene-dark">
<div class="container panel">
<section id="choose-path" class="public-section scene-dark lp-section">
<div class="container panel choose-path-panel">
<div class="section-head">
<div>
<h2>Choose Your Path</h2>
<p class="sub">One account, multiple journeys. Pick your goal and get started.</p>
</div>
<div class="filter-row">
<button class={`chip-btn ${filter() === 'all' ? 'active' : ''}`} onClick={() => { setFilter('all'); setPathPage(0); }}>All Paths</button>
<button class={`chip-btn ${filter() === 'customer' ? 'active' : ''}`} onClick={() => { setFilter('customer'); setPathPage(0); }}>Customers</button>
<button class={`chip-btn ${filter() === 'professional' ? 'active' : ''}`} onClick={() => { setFilter('professional'); setPathPage(0); }}>Professionals</button>
<button class={`chip-btn ${filter() === 'company' ? 'active' : ''}`} onClick={() => { setFilter('company'); setPathPage(0); }}>Companies</button>
<button class={`chip-btn ${filter() === 'job_seeker' ? 'active' : ''}`} onClick={() => { setFilter('job_seeker'); setPathPage(0); }}>Job Seekers</button>
<div class="lp-path-controls desktop-only">
<button
class="lp-path-arrow"
onClick={() => setPathPage((x) => Math.max(0, x - 1))}
aria-label="Previous cards"
disabled={activePathPage() === 0}
>
</button>
<button
class="lp-path-arrow"
onClick={() => setPathPage((x) => Math.min(Math.max(0, pagedPaths().length - 1), x + 1))}
aria-label="Next cards"
disabled={activePathPage() >= Math.max(0, pagedPaths().length - 1)}
>
</button>
</div>
</div>
<div class="lp-carousel-nav">
<button class="btn" onClick={() => setPathPage((x) => Math.max(0, x - 1))}></button>
<span class="note">Page {activePathPage() + 1} / {Math.max(1, pagedPaths().length)}</span>
<button class="btn" onClick={() => setPathPage((x) => x + 1)}></button>
</div>
<div class="path-grid">
<For each={pathCardsVisible()}>
{(card) => (
<article class="path-card">
<img src={card.image} alt={card.title} />
<div class="path-body">
<h3>{card.title}</h3>
<p>{card.description}</p>
<A class="btn primary" href={card.href}>{card.button}</A>
<div class="path-carousel-shell">
<div
class={`path-carousel-track ${pathPaused() ? 'path-carousel-track-hover' : ''}`}
style={{ transform: `translate3d(-${activePathPage() * 100}%, 0, 0)` }}
onMouseEnter={() => setPathPaused(true)}
onMouseLeave={() => setPathPaused(false)}
onTouchStart={(event) => {
setPathPaused(true);
setPathTouchStartX(event.changedTouches[0]?.clientX ?? null);
}}
onTouchEnd={(event) => {
setPathPaused(false);
const startX = pathTouchStartX();
const endX = event.changedTouches[0]?.clientX ?? null;
if (startX == null || endX == null) return;
const deltaX = endX - startX;
if (Math.abs(deltaX) < 40) return;
if (deltaX < 0) {
setPathPage((x) => Math.min(Math.max(0, pagedPaths().length - 1), x + 1));
return;
}
setPathPage((x) => Math.max(0, x - 1));
}}
>
<For each={pagedPaths()}>
{(group) => (
<div class="path-page">
<div class={`path-grid path-grid-${cardsPerPage()}`}>
<For each={group}>
{(card) => (
<article class="path-card path-card-hero">
<div class="path-media">
<img src={card.image} alt={card.title} loading="lazy" />
<div class="path-media-overlay" />
</div>
<div class="path-body">
<div class="path-head-row">
<span class="path-icon"><PathRoleIcon kind={card.icon} /></span>
<span class="path-chip"><CheckBadgeIcon />{card.chip}</span>
</div>
<h3>{card.title}</h3>
<p>{card.description}</p>
<A class="path-secondary-btn" href={card.href}>{card.button}</A>
</div>
</article>
)}
</For>
</div>
</div>
</article>
)}
</For>
</div>
</div>
<div class="lp-path-dots">
<For each={pagedPaths()}>
{(_, idx) => (
<button
class={`lp-path-dot ${idx() === activePathPage() ? 'active' : ''}`}
onClick={() => setPathPage(idx())}
aria-label={`Go to page ${idx() + 1}`}
/>
)}
</For>
</div>
</div>
</section>
<section id="why-nxtgauge" class="public-section scene-dark">
<div class="container panel panel-dark lp-benefit-panel">
<h2 class="center">Why Nxtgauge</h2>
<p class="center sub">Trust, approvals, and better matching in one flow.</p>
<article class="lp-benefit-hero">
<h3>{benefits[benefitIdx()].title}</h3>
<p>{benefits[benefitIdx()].body}</p>
<div class="lp-benefit-dots">
<section id="why-nxtgauge" class="public-section scene-dark lp-section">
<div class="container panel panel-dark">
<div class="center">
<p class="eyebrow">Why Nxtgauge</p>
<h2>Trust, approvals, and better matching in one flow.</h2>
</div>
<div class="whySliderWrap">
<article class="whyHeroCard whyHeroCardPulse">
<div class="whyCardGlow" />
<div class="whyHaloA" />
<div class="whyHaloB" />
<div class="whyCardContent whyContentSwap">
<p class="whyMetaKicker">Capability {benefitIdx() + 1} of {benefits.length}</p>
<span class="whyIconShell"><WhyBenefitIcon kind={benefits[benefitIdx()].icon} /></span>
<h3 style={{ margin: '12px 0 0', color: '#fff', 'font-size': '30px' }}>{benefits[benefitIdx()].title}</h3>
<p style={{ margin: '8px 0 0', color: 'rgba(255,255,255,0.9)', 'font-size': '17px' }}>{benefits[benefitIdx()].body}</p>
</div>
<span class="whyAutoTrack" />
</article>
<div class="whyOrbitalNav">
<For each={benefits}>
{(_, idx) => (
{(item, idx) => (
<button
class={`lp-dot ${idx() === benefitIdx() ? 'active' : ''}`}
class={`whyOrbitalBtn ${idx() === benefitIdx() ? 'whyOrbitalBtnActive' : ''}`}
onClick={() => setBenefitIdx(idx())}
aria-label={`Benefit ${idx() + 1}`}
/>
aria-label={`Show ${item.title}`}
title={item.title}
>
<WhyBenefitIcon kind={item.icon} />
</button>
)}
</For>
</div>
</article>
<div class="whyControls">
<button class="whyControlBtn" onClick={() => setBenefitIdx((benefitIdx() - 1 + benefits.length) % benefits.length)} aria-label="Previous slide"></button>
<button class="whyControlBtn" onClick={() => setBenefitIdx((benefitIdx() + 1) % benefits.length)} aria-label="Next slide"></button>
</div>
</div>
</div>
</section>
<section id="how-it-works" class="public-section scene-light">
<section id="how-it-works" class="public-section scene-dark lp-section landing-hiw-section">
<div class="container panel panel-light">
<h2 class="center">How it works</h2>
<p class="center sub">Clear journey, zero confusion.</p>
<article class="flow-card">
<img src={flows[flowIndex()].image} alt={flows[flowIndex()].label} />
<div>
<p class="eyebrow">{flows[flowIndex()].label}</p>
<h3>{flows[flowIndex()].title}</h3>
<p class="sub">{flows[flowIndex()].description}</p>
<div class="step-list">
<div class="center">
<p class="eyebrow">How it works</p>
<h2>Clear journey, zero confusion</h2>
</div>
<article class="hiwCodeCard">
<div class="hiwCodeMedia">
<div class="hiwCodeBigRect">
<img class="hiwCodePhoto" src={flows[flowIndex()].image} alt={`${flows[flowIndex()].label} role`} />
</div>
<div class="hiwCodeMediaCopy">
<p class="hiwCodeKicker">{flows[flowIndex()].label}</p>
<h3 class="hiwCodeTitle">{flows[flowIndex()].title}</h3>
<p class="hiwCodeDesc">{flows[flowIndex()].description}</p>
</div>
</div>
<div class="hiwCodeBody">
<p class="hiwCodeStepsHeading">Step Flow</p>
<div class="hiwCodeSteps">
<For each={flows[flowIndex()].steps}>
{(step, idx) => (
<div class={`step-item ${idx() === flowStepIndex() ? 'active' : ''}`}>
<span>{idx() + 1}</span>
<div class={`hiwCodeStep ${idx() === flowStepIndex() ? 'hiwCodeStepActive' : ''}`}>
<span class="hiwCodeStepNum">{idx() + 1}</span>
<div>
<h4>{step.title}</h4>
<p>{step.description}</p>
<h4 class="hiwCodeStepTitle">{step.title}</h4>
<p class="hiwCodeStepDesc">{step.description}</p>
</div>
</div>
)}
</For>
</div>
<div class="actions">
<button class="btn" onClick={() => { setFlowIndex((flowIndex() - 1 + flows.length) % flows.length); setFlowStepIndex(0); }}></button>
<button class="btn" onClick={() => { setFlowIndex((flowIndex() + 1) % flows.length); setFlowStepIndex(0); }}></button>
<div class="hiwCodeFooter">
<div class="hiwCodeArrows">
<button class="hiwCodeArrow" onClick={() => { setFlowIndex((flowIndex() - 1 + flows.length) % flows.length); setFlowStepIndex(0); }} aria-label="Previous role"></button>
<button class="hiwCodeArrow" onClick={() => { setFlowIndex((flowIndex() + 1) % flows.length); setFlowStepIndex(0); }} aria-label="Next role"></button>
</div>
<div class="hiwCodeDots">
<For each={flows}>
{(_, idx) => <span class={`hiwCodeDot ${idx() === flowIndex() ? 'hiwCodeDotActive' : ''}`} />}
</For>
</div>
</div>
</div>
</article>
</div>
</section>
<section id="faqs" class="public-section scene-dark">
<section id="faqs" class="public-section scene-dark lp-section landing-faq-section">
<div class="container panel panel-dark faq-wrap">
<h2 class="center">Frequently asked questions</h2>
<p class="center sub">Quick answers before you create your account.</p>
@ -533,7 +695,7 @@ export default function PublicLanding() {
<article class={`faq-item ${openFaq() === idx() ? 'open' : ''}`}>
<button class="faq-q" onClick={() => setOpenFaq(openFaq() === idx() ? -1 : idx())}>
<span>{item.q}</span>
<span>{openFaq() === idx() ? '' : '+'}</span>
<span class={`faq-q-icon ${openFaq() === idx() ? 'open' : ''}`}></span>
</button>
<Show when={openFaq() === idx()}>
<p class="faq-a">{item.a}</p>
@ -545,17 +707,18 @@ export default function PublicLanding() {
</div>
</section>
<section class="public-section scene-dark">
<div class="container panel panel-dark cta-row">
<div>
<section class="public-section scene-dark lp-section">
<div class="container panel panel-dark cta-panel">
<div class="cta-glow" />
<div class="cta-copy">
<p class="eyebrow">Quick Actions</p>
<h2>Ready to get started?</h2>
<p class="sub">Pick your next action and continue with the correct role flow.</p>
</div>
<div class="hero-actions">
<A class="btn primary" href="/onboarding?schemaId=customer_onboarding_v1">Hire a Professional</A>
<A class="btn ghost-dark" href="/onboarding?schemaId=jobseeker_onboarding_v1">Apply for Jobs</A>
<A class="btn ghost-dark" href="/onboarding?schemaId=company_onboarding_v1">Post a Job</A>
<div class="hero-actions cta-actions">
<A class="lp-primary-btn pulse" href="/auth/register?intent=customer&redirect=/users/onboarding/customer">Hire a Professional</A>
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/auth/register?intent=job_seeker&redirect=/users/onboarding/job-seeker">Apply for Jobs</A>
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/auth/register?intent=company&redirect=/users/onboarding/company">Post a Job</A>
</div>
</div>
</section>
@ -565,14 +728,17 @@ export default function PublicLanding() {
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
<p>© {new Date().getFullYear()} Nxtgauge. All rights reserved.</p>
<div class="footer-links">
<A href="/about">About Us</A>
<A href="/terms">Terms</A>
<A href="/privacy">Privacy</A>
<A href="/help-center">Help Center</A>
<A href="/contact">Contact Us</A>
</div>
</div>
</footer>
<Show when={showBackToTop()}>
<button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' })}>
</button>
</Show>
</div>
</main>
);

View file

@ -1,4 +1,4 @@
// @refresh reload
import { mount, StartClient } from '@solidjs/start/client';
mount(() => <StartClient />, document.getElementById('app')!);
export default mount(() => <StartClient />, document.getElementById('app')!);

35
src/lib/auth-intent.ts Normal file
View file

@ -0,0 +1,35 @@
export type CanonicalIntent = 'customer' | 'professional' | 'company' | 'job_seeker' | null;
const INTENT_KEY = 'nxtgauge_intent_v1';
export function normalizeIntent(value: string | null | undefined): CanonicalIntent {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'customer') return 'customer';
if (normalized === 'professional' || normalized === 'pro') return 'professional';
if (normalized === 'company' || normalized === 'employer') return 'company';
if (normalized === 'job_seeker' || normalized === 'job-seeker' || normalized === 'jobseeker') return 'job_seeker';
return null;
}
export function intentToOnboardingPath(intent: CanonicalIntent): string {
if (intent === 'company') return '/users/onboarding/company';
if (intent === 'job_seeker') return '/users/onboarding/job-seeker';
if (intent === 'professional') return '/users/onboarding/professional';
return '/users/onboarding/customer';
}
export function saveCanonicalIntent(intent: CanonicalIntent): void {
if (typeof window === 'undefined') return;
if (!intent) {
window.localStorage.removeItem(INTENT_KEY);
return;
}
window.localStorage.setItem(INTENT_KEY, intent);
}
export function readCanonicalIntent(): CanonicalIntent {
if (typeof window === 'undefined') return null;
return normalizeIntent(window.localStorage.getItem(INTENT_KEY));
}

83
src/lib/help-center.ts Normal file
View file

@ -0,0 +1,83 @@
export type HelpArticle = {
id: string;
slug: string;
title: string;
summary: string;
categoryKey: string;
role: 'ALL' | 'company' | 'jobSeeker' | 'professional' | 'customer' | 'platform';
tags: string[];
updatedAt: string;
content: string;
};
export const HELP_ARTICLES: HelpArticle[] = [
{
id: 'hc-1',
slug: 'how-verification-works',
title: 'How verification works',
summary: 'Understand document review steps, approval outcomes, and timeline.',
categoryKey: 'verification',
role: 'ALL',
tags: ['verification', 'documents', 'approval'],
updatedAt: '2026-03-17T00:00:00Z',
content: 'After signup, complete onboarding for one path and submit required documents. Admin review updates your status as pending, document required, approved, or rejected.',
},
{
id: 'hc-2',
slug: 'customer-post-requirement',
title: 'How customers post requirements',
summary: 'Choose profession intent, add requirements, and track verified responses.',
categoryKey: 'requirements',
role: 'customer',
tags: ['customer', 'requirements'],
updatedAt: '2026-03-17T00:00:00Z',
content: 'Customer flow starts with selecting the professional category, then requirement details, budget, and timeline. After review, qualified professionals can respond.',
},
{
id: 'hc-3',
slug: 'professional-onboarding-guide',
title: 'Professional onboarding guide',
summary: 'Choose your profession, upload portfolio, submit PDF ID documents, and wait for approval.',
categoryKey: 'onboarding',
role: 'professional',
tags: ['professional', 'onboarding', 'portfolio'],
updatedAt: '2026-03-17T00:00:00Z',
content: 'Each profession in Solid has its own onboarding and service configuration. Complete all steps and verification to unlock your full dashboard.',
},
];
export function listHelpCenterArticles(input: { role?: string; categoryKey?: string; q?: string }) {
const role = String(input.role || 'ALL');
const categoryKey = String(input.categoryKey || '').trim();
const q = String(input.q || '').trim().toLowerCase();
return HELP_ARTICLES.filter((article) => {
const roleOk = role === 'ALL' || article.role === 'ALL' || article.role === role;
const categoryOk = !categoryKey || article.categoryKey === categoryKey;
const queryOk = !q || `${article.title} ${article.summary} ${article.tags.join(' ')}`.toLowerCase().includes(q);
return roleOk && categoryOk && queryOk;
});
}
export function listHelpCenterCategories() {
const keys = new Map<string, string>();
for (const article of HELP_ARTICLES) {
if (!keys.has(article.categoryKey)) {
const title = article.categoryKey
.split('-')
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
.join(' ');
keys.set(article.categoryKey, title);
}
}
return Array.from(keys.entries()).map(([key, title], idx) => ({
id: `cat-${idx + 1}`,
key,
title,
}));
}
export function getArticleBySlug(slug: string) {
return HELP_ARTICLES.find((article) => article.slug === slug) || null;
}

View file

@ -0,0 +1,76 @@
import crypto from 'node:crypto';
type VerificationFlow = 'register' | 'login';
type VerificationRecord = {
codeHash: string;
expiresAt: number;
attempts: number;
};
const CHALLENGE_TTL_MS = 10 * 60 * 1000;
const MAX_ATTEMPTS = 5;
const records = new Map<string, VerificationRecord>();
function hashCode(code: string): string {
return crypto.createHash('sha256').update(code).digest('hex');
}
function keyFor(email: string, flow: VerificationFlow): string {
return `${flow}:${email.toLowerCase().trim()}`;
}
function cleanupExpired(): void {
const now = Date.now();
for (const [key, record] of records.entries()) {
if (record.expiresAt <= now) records.delete(key);
}
}
export function createVerificationCode(input: { email: string; flow: VerificationFlow }) {
cleanupExpired();
const email = input.email.toLowerCase().trim();
const flow = input.flow;
const code = String(crypto.randomInt(100000, 1000000));
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
records.set(keyFor(email, flow), {
codeHash: hashCode(code),
expiresAt,
attempts: 0,
});
return {
code,
expiresAt,
expiresInSeconds: Math.floor(CHALLENGE_TTL_MS / 1000),
};
}
export function consumeVerificationCode(input: { email: string; flow: VerificationFlow; code: string }):
| { ok: true }
| { ok: false; reason: 'NOT_FOUND' | 'EXPIRED' | 'INVALID_CODE' | 'TOO_MANY_ATTEMPTS' } {
cleanupExpired();
const key = keyFor(input.email, input.flow);
const record = records.get(key);
if (!record) return { ok: false, reason: 'NOT_FOUND' };
if (record.expiresAt <= Date.now()) {
records.delete(key);
return { ok: false, reason: 'EXPIRED' };
}
if (record.attempts >= MAX_ATTEMPTS) {
records.delete(key);
return { ok: false, reason: 'TOO_MANY_ATTEMPTS' };
}
if (record.codeHash !== hashCode(input.code.trim())) {
record.attempts += 1;
records.set(key, record);
return { ok: false, reason: 'INVALID_CODE' };
}
records.delete(key);
return { ok: true };
}

31
src/lib/server/gateway.ts Normal file
View file

@ -0,0 +1,31 @@
const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, '');
export function gatewayUrl(path: string) {
const normalized = path.startsWith('/') ? path : `/${path}`;
return `${gatewayBase}${normalized}`;
}
export function readAccessTokenFromRequest(request: Request): string | null {
const cookie = request.headers.get('cookie') || '';
if (!cookie) return null;
const parts = cookie.split(';').map((part) => part.trim());
const pair = parts.find((part) => part.startsWith('nxtgauge_access_token='));
if (!pair) return null;
const token = pair.split('=').slice(1).join('=').trim();
if (!token) return null;
try {
return decodeURIComponent(token);
} catch {
return token;
}
}
export function withAuthHeaders(request: Request, extra?: Record<string, string>) {
const token = readAccessTokenFromRequest(request);
const headers: Record<string, string> = { ...(extra || {}) };
if (token) headers.Authorization = `Bearer ${token}`;
return headers;
}

258
src/lib/server/smtp.ts Normal file
View file

@ -0,0 +1,258 @@
import net from 'node:net';
import tls from 'node:tls';
import { Buffer } from 'node:buffer';
type SocketLike = net.Socket | tls.TLSSocket;
const DEFAULT_TIMEOUT_MS = 10000;
function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function parsePort(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.floor(parsed);
}
function buildMessage(input: { from: string; to: string; subject: string; text: string }): string {
const headers = [
`From: ${input.from}`,
`To: ${input.to}`,
`Subject: ${input.subject}`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
];
return `${headers.join('\r\n')}\r\n\r\n${input.text}\r\n`;
}
class SmtpConnection {
private socket: SocketLike;
private buffer = '';
constructor(socket: SocketLike) {
this.socket = socket;
this.socket.setEncoding('utf8');
this.socket.on('data', (chunk: string) => {
this.buffer += chunk;
});
}
private waitForResponse(expectedCodes: number[]): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error('SMTP response timeout'));
}, DEFAULT_TIMEOUT_MS);
const onError = (error: Error) => {
cleanup();
reject(error);
};
const checkBuffer = () => {
const lines = this.buffer.split(/\r?\n/).filter(Boolean);
if (lines.length === 0) return;
const lastLine = lines[lines.length - 1];
const match = lastLine.match(/^(\d{3})([\s-])/);
if (!match) return;
if (match[2] === '-') return;
this.buffer = '';
cleanup();
const code = Number(match[1]);
if (!expectedCodes.includes(code)) {
reject(new Error(`SMTP error ${code}: ${lastLine}`));
return;
}
resolve(lastLine);
};
const interval = setInterval(checkBuffer, 20);
const cleanup = () => {
clearTimeout(timeout);
clearInterval(interval);
this.socket.off('error', onError);
};
this.socket.on('error', onError);
checkBuffer();
});
}
async expect(expectedCodes: number[]) {
await this.waitForResponse(expectedCodes);
}
async command(commandText: string, expectedCodes: number[]) {
this.socket.write(`${commandText}\r\n`);
await this.waitForResponse(expectedCodes);
}
async writeRaw(data: string, expectedCodes: number[]) {
this.socket.write(data);
await this.waitForResponse(expectedCodes);
}
end() {
this.socket.end();
}
}
function connectPlain(host: string, port: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port });
socket.setTimeout(DEFAULT_TIMEOUT_MS);
const onError = (error: Error) => {
socket.off('timeout', onTimeout);
socket.destroy();
reject(error);
};
const onTimeout = () => {
socket.off('error', onError);
socket.destroy();
reject(new Error('SMTP connection timeout'));
};
socket.once('error', onError);
socket.once('timeout', onTimeout);
socket.once('connect', () => {
socket.off('error', onError);
socket.off('timeout', onTimeout);
socket.setTimeout(0);
resolve(socket);
});
});
}
function connectSecure(host: string, port: number): Promise<tls.TLSSocket> {
return new Promise((resolve, reject) => {
const socket = tls.connect({ host, port, servername: host });
socket.setTimeout(DEFAULT_TIMEOUT_MS);
const onError = (error: Error) => {
socket.off('timeout', onTimeout);
socket.destroy();
reject(error);
};
const onTimeout = () => {
socket.off('error', onError);
socket.destroy();
reject(new Error('SMTP connection timeout'));
};
socket.once('error', onError);
socket.once('timeout', onTimeout);
socket.once('secureConnect', () => {
socket.off('error', onError);
socket.off('timeout', onTimeout);
socket.setTimeout(0);
resolve(socket);
});
});
}
function upgradeToStartTls(socket: net.Socket, host: string): Promise<tls.TLSSocket> {
return new Promise((resolve, reject) => {
const secureSocket = tls.connect({ socket, servername: host });
secureSocket.setTimeout(DEFAULT_TIMEOUT_MS);
const onError = (error: Error) => {
secureSocket.off('timeout', onTimeout);
secureSocket.destroy();
reject(error);
};
const onTimeout = () => {
secureSocket.off('error', onError);
secureSocket.destroy();
reject(new Error('SMTP STARTTLS timeout'));
};
secureSocket.once('error', onError);
secureSocket.once('timeout', onTimeout);
secureSocket.once('secureConnect', () => {
secureSocket.off('error', onError);
secureSocket.off('timeout', onTimeout);
secureSocket.setTimeout(0);
resolve(secureSocket);
});
});
}
export async function sendVerificationEmail(input: {
to: string;
code: string;
expiresInMinutes: number;
}) {
const host = process.env.MAIL_SMTP_HOST || '127.0.0.1';
const port = parsePort(process.env.MAIL_SMTP_PORT, 587);
const secure = parseBooleanEnv(process.env.MAIL_SMTP_SECURE, false);
const useStartTls = parseBooleanEnv(process.env.MAIL_SMTP_STARTTLS, !secure);
const user = process.env.MAIL_SMTP_USER || process.env.MAIL_FROM;
const pass = process.env.MAIL_SMTP_PASS;
const from = process.env.MAIL_FROM;
if (!from) throw new Error('MAIL_FROM is required');
if (!user || !pass) throw new Error('MAIL_SMTP_USER and MAIL_SMTP_PASS are required');
const subject = 'Your Nxtgauge Verification Code';
const text = [
'Use this code to complete your verification:',
'',
input.code,
'',
`This code expires in ${input.expiresInMinutes} minutes.`,
'If you did not request this code, please ignore this email.',
].join('\n');
const message = buildMessage({ from, to: input.to, subject, text });
let socket: SocketLike | null = null;
let client: SmtpConnection | null = null;
try {
if (secure) {
socket = await connectSecure(host, port);
client = new SmtpConnection(socket);
} else {
const plainSocket = await connectPlain(host, port);
socket = plainSocket;
client = new SmtpConnection(plainSocket);
}
await client.expect([220]);
await client.command('EHLO nxtgauge.com', [250]);
if (!secure && useStartTls && socket instanceof net.Socket && !(socket instanceof tls.TLSSocket)) {
await client.command('STARTTLS', [220]);
const tlsSocket = await upgradeToStartTls(socket, host);
socket = tlsSocket;
client = new SmtpConnection(tlsSocket);
await client.command('EHLO nxtgauge.com', [250]);
}
await client.command('AUTH LOGIN', [334]);
await client.command(Buffer.from(user).toString('base64'), [334]);
await client.command(Buffer.from(pass).toString('base64'), [235]);
await client.command(`MAIL FROM:<${from}>`, [250]);
await client.command(`RCPT TO:<${input.to}>`, [250, 251]);
await client.command('DATA', [354]);
await client.writeRaw(`${message}\r\n.\r\n`, [250]);
await client.command('QUIT', [221]);
} finally {
if (client) client.end();
else if (socket) socket.end();
}
}

View file

@ -1,34 +1,541 @@
import { A } from '@solidjs/router';
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import PublicHeader from '~/components/PublicHeader';
const chapters = [
{ id: 'chapter-problem', title: '01 The problem' },
{ id: 'chapter-built', title: '02 What we built' },
{ id: 'chapter-trust', title: '03 How trust works' },
{ id: 'chapter-principles', title: '04 Principles' },
{ id: 'chapter-timeline', title: '05 Timeline' },
] as const;
const trustSequence = [
{ num: '01', title: 'Submission', body: 'A user submits profile, job, or requirement details with required context.' },
{ num: '02', title: 'Human Review', body: 'A reviewer checks quality, relevance, and verification requirements.' },
{ num: '03', title: 'Approval', body: 'Validated submissions are approved and moved to publish-ready state.' },
{ num: '04', title: 'Visible to Marketplace', body: 'Approved entities become discoverable and can receive responses.' },
] as const;
const milestones = [
{ title: 'Research', body: 'Mapped trust breakdowns in hiring and service discovery journeys.' },
{ title: 'MVP', body: 'Built a marketplace core with structured onboarding and review signals.' },
{ title: 'Private pilot', body: 'Tested operational workflows with role-specific submissions.' },
{ title: 'Launch', body: 'Released trust-layered marketplace experience to wider users.' },
{ title: 'Next: expanding categories', body: 'Adding more categories while keeping quality controls strong.' },
] as const;
const chapterFourNarrative = [
'We didnt build another marketplace.',
'We built a filter.',
'A review layer.',
'Clarity replaces noise.',
] as const;
const chapterTwoRows = ['Profile status', 'Requirement status', 'Activity transparency'] as const;
export default function AboutPage() {
return (
<main class="page marketing-page">
<section class="card glass-dark">
<p class="eyebrow">Brand Story</p>
<h1 class="title light">Trust-first hiring and services.</h1>
<p class="subtitle light">
Nxtgauge is built to reduce noise, improve quality, and help people connect with confidence.
</p>
<div class="actions">
<A class="btn primary" href="/contact">Contact us</A>
<A class="btn ghost-dark" href="/">Back Home</A>
</div>
</section>
const [showBackToTop, setShowBackToTop] = createSignal(false);
const [reduceMotion, setReduceMotion] = createSignal(false);
const [activeChapter, setActiveChapter] = createSignal(0);
const [scrollY, setScrollY] = createSignal(0);
<section class="grid" style={{ 'margin-top': '14px' }}>
<article class="card glass-light">
<h3>Our Manifesto</h3>
<ul>
<li>Verify what matters</li>
<li>Reduce spam and guesswork</li>
<li>Match people faster</li>
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);
const [problemProgress, setProblemProgress] = createSignal(0);
const [trustProgress, setTrustProgress] = createSignal(0);
const [principleProgress, setPrincipleProgress] = createSignal(0);
const [builtTilt, setBuiltTilt] = createSignal({ x: 0, y: 0 });
let heroRef: HTMLElement | undefined;
let chapterProblemRef: HTMLElement | undefined;
let chapterBuiltRef: HTMLElement | undefined;
let chapterTrustRef: HTMLElement | undefined;
let chapterPrinciplesRef: HTMLElement | undefined;
let chapterTimelineRef: HTMLElement | undefined;
let closingRef: HTMLElement | undefined;
onMount(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
const syncMotion = () => setReduceMotion(media.matches);
syncMotion();
const chapterRefs = () => [chapterProblemRef, chapterBuiltRef, chapterTrustRef, chapterPrinciplesRef, chapterTimelineRef];
const revealMap = new Map<HTMLElement, (visible: boolean) => void>();
if (heroRef) revealMap.set(heroRef, setHeroVisible);
if (chapterProblemRef) revealMap.set(chapterProblemRef, setProblemVisible);
if (chapterBuiltRef) revealMap.set(chapterBuiltRef, setBuiltVisible);
if (chapterTrustRef) revealMap.set(chapterTrustRef, setTrustVisible);
if (chapterPrinciplesRef) revealMap.set(chapterPrinciplesRef, setPrinciplesVisible);
if (chapterTimelineRef) revealMap.set(chapterTimelineRef, setTimelineVisible);
if (closingRef) revealMap.set(closingRef, setClosingVisible);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const setter = revealMap.get(entry.target as HTMLElement);
if (setter) setter(true);
});
},
{ threshold: 0.12 }
);
revealMap.forEach((_, el) => observer.observe(el));
const onScroll = () => {
setShowBackToTop(window.scrollY > 500);
setScrollY(window.scrollY || 0);
const middle = window.innerHeight * 0.42;
let nextActive = 0;
chapterRefs().forEach((section, index) => {
if (!section) return;
const rect = section.getBoundingClientRect();
if (rect.top <= middle) nextActive = index;
});
setActiveChapter(nextActive);
if (chapterProblemRef && !reduceMotion()) {
const rect = chapterProblemRef.getBoundingClientRect();
const viewport = window.innerHeight;
const start = viewport * 0.9;
const end = viewport * 0.18;
const range = rect.height + (start - end);
const raw = (start - rect.top) / range;
setProblemProgress(Math.max(0, Math.min(1, raw)));
} else if (reduceMotion()) {
setProblemProgress(1);
}
if (chapterTrustRef && !reduceMotion()) {
const rect = chapterTrustRef.getBoundingClientRect();
const viewport = window.innerHeight;
const start = viewport * 0.75;
const end = viewport * 0.18;
const range = rect.height + (start - end);
const raw = (start - rect.top) / range;
setTrustProgress(Math.max(0, Math.min(1, raw)));
} else if (reduceMotion()) {
setTrustProgress(1);
}
if (chapterPrinciplesRef && !reduceMotion()) {
const rect = chapterPrinciplesRef.getBoundingClientRect();
const viewport = window.innerHeight;
const sectionTopAbs = window.scrollY + rect.top;
const start = sectionTopAbs - viewport * 0.9;
const end = sectionTopAbs + rect.height - viewport * 0.52;
const range = Math.max(1, end - start);
const raw = (window.scrollY - start) / range;
setPrincipleProgress(Math.max(0, Math.min(1, raw)));
} else if (reduceMotion()) {
setPrincipleProgress(1);
}
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
media.addEventListener('change', syncMotion);
onCleanup(() => {
window.removeEventListener('scroll', onScroll);
media.removeEventListener('change', syncMotion);
observer.disconnect();
});
});
const progress = createMemo(() => {
if (chapters.length <= 1) return 0;
return (activeChapter() / (chapters.length - 1)) * 100;
});
const progressBetween = (value: number, start: number, end: number) => {
const span = Math.max(0.001, end - start);
return Math.max(0, Math.min(1, (value - start) / span));
};
const effectiveProblemProgress = createMemo(() => (reduceMotion() ? 1 : problemProgress()));
const effectiveTrustProgress = createMemo(() => (reduceMotion() ? 1 : trustProgress()));
const effectivePrincipleProgress = createMemo(() => (reduceMotion() ? 1 : principleProgress()));
const chapterOneHeadlineIn = createMemo(() => progressBetween(effectiveProblemProgress(), 0.12, 0.42));
const chapterOneBodyIn = createMemo(() => progressBetween(effectiveProblemProgress(), 0.24, 0.56));
const chapterOneShapeFade = createMemo(() => (reduceMotion() ? 0 : 0.08 * (1 - effectiveProblemProgress())));
const principleStage = createMemo(() => {
const p = effectivePrincipleProgress();
if (p < 0.25) return 0;
if (p < 0.5) return 1;
if (p < 0.75) return 2;
return 3;
});
const stateTwoUnderline = createMemo(() => progressBetween(effectivePrincipleProgress(), 0.26, 0.46));
const stateThreeLine = createMemo(() => progressBetween(effectivePrincipleProgress(), 0.52, 0.74));
return (
<main class="lp-main about-page-root">
<div class="lp-bg" aria-hidden="true">
<div class="lp-dark-base" />
<div class="lp-mesh" style={{ transform: `translate3d(0, ${reduceMotion() ? 0 : Math.min(40, scrollY() * 0.12)}px, 0)` }} />
<div class="lp-ribbon" style={{ transform: `translate3d(0, ${reduceMotion() ? 0 : Math.min(55, scrollY() * 0.22)}px, 0)` }} />
<div class="lp-noise" />
</div>
<div class="lp-content about-content about-with-rail">
<PublicHeader />
<aside class="about-chapter-rail">
<div class="about-chapter-track">
<span class="about-chapter-progress" style={{ height: `${progress()}%` }} />
</div>
<ul class="about-chapter-list">
<For each={chapters}>
{(chapter, idx) => (
<li class={idx() === activeChapter() ? 'about-chapter-item-active' : 'about-chapter-item'}>
<a href={`#${chapter.id}`}>{chapter.title}</a>
</li>
)}
</For>
</ul>
</article>
<article class="card glass-light">
<h3>How trust works</h3>
<p>Submission Human review Approval Marketplace visibility.</p>
</article>
</section>
</aside>
<section ref={heroRef} class="about-hero">
<div class="container">
<div class={`about-hero-inner about-reveal-init ${heroVisible() ? 'about-reveal-show' : ''}`}>
<div>
<p class="about-kicker">Brand Story</p>
<h1 class="about-title">Trust-first hiring and services.</h1>
<p class="about-copy">
Nxtgauge is built to reduce noise, improve quality, and help people connect with confidence.
</p>
<div class="hero-actions">
<A class="lp-primary-btn" href="/contact">Contact us</A>
</div>
</div>
<aside class="about-manifesto-card">
<h2>Our manifesto</h2>
<ul>
<li>Verify what matters</li>
<li>Reduce spam and guesswork</li>
<li>Match people faster</li>
</ul>
<span class="about-sheen-sweep" />
</aside>
</div>
</div>
</section>
<section
id="chapter-problem"
ref={chapterProblemRef}
class="about-section-tight about-chapter-problem-section"
>
<div class="container">
<article class={`about-problem-stage about-reveal-init ${problemVisible() ? 'about-reveal-show' : ''}`}>
<span class="about-problem-halo" style={{ opacity: 0.16 + effectiveProblemProgress() * 0.32 }} />
<span
class="about-problem-shape-a"
style={{
opacity: chapterOneShapeFade(),
transform: `translate3d(${-14 * effectiveProblemProgress()}px, ${10 * effectiveProblemProgress()}px, 0)`,
}}
/>
<span
class="about-problem-shape-b"
style={{
opacity: chapterOneShapeFade(),
transform: `translate3d(${16 * effectiveProblemProgress()}px, ${-8 * effectiveProblemProgress()}px, 0)`,
}}
/>
<span
class="about-problem-shape-c"
style={{
opacity: chapterOneShapeFade(),
transform: `translate3d(${12 * effectiveProblemProgress()}px, ${11 * effectiveProblemProgress()}px, 0)`,
}}
/>
<span
class="about-problem-shape-d"
style={{
opacity: chapterOneShapeFade(),
transform: `translate3d(${-12 * effectiveProblemProgress()}px, ${-10 * effectiveProblemProgress()}px, 0)`,
}}
/>
<p class="about-chapter-label about-chapter-label-light">Chapter 01</p>
<h2 class="about-chapter-title about-chapter-title-dark">The Problem</h2>
<h3
class="about-problem-headline"
style={{
opacity: chapterOneHeadlineIn(),
filter: `blur(${(1 - chapterOneHeadlineIn()) * 5}px)`,
}}
>
The hardest part isnt finding options.
</h3>
<p
class="about-problem-body"
style={{
opacity: chapterOneBodyIn(),
filter: `blur(${(1 - chapterOneBodyIn()) * 3}px)`,
}}
>
Its knowing which one deserves your time.
<br />
Scrolling. Comparing. Second-guessing. Starting over.
<br />
The real cost isnt money.
<br />
Its momentum.
</p>
</article>
</div>
</section>
<section
id="chapter-built"
ref={chapterBuiltRef}
class="about-section-tight"
>
<div class="container">
<article class={`about-glass-light about-chapter-two-shell about-reveal-init ${builtVisible() ? 'about-reveal-show' : ''}`}>
<p class="about-chapter-label about-chapter-label-orange">Chapter 02</p>
<h2 class="about-chapter-title about-chapter-title-light">What We Built</h2>
<div class="about-chapter-two-grid">
<div
class="about-chapter-two-text"
style={{
opacity: builtVisible() ? 1 : 0,
transform: builtVisible() ? 'translate3d(0,0,0)' : 'translate3d(0,8px,0)',
}}
>
<h2 class="about-chapter-two-heading">We wanted to reduce hesitation.</h2>
<p class="about-chapter-two-body">
Not by adding more choices.
<br />
But by designing fewer, stronger ones.
<br />
Nxtgauge is built around one idea:
<br />
Confidence should come before commitment.
</p>
</div>
<aside
class="about-chapter-two-panel"
onMouseMove={(event) => {
if (reduceMotion()) return;
const rect = event.currentTarget.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width - 0.5;
const py = (event.clientY - rect.top) / rect.height - 0.5;
setBuiltTilt({ x: -(py * 2), y: px * 2 });
}}
onMouseLeave={() => setBuiltTilt({ x: 0, y: 0 })}
style={{
opacity: builtVisible() ? 1 : 0,
transform: reduceMotion()
? builtVisible()
? 'translate3d(0,0,0)'
: 'translate3d(0,8px,0)'
: builtVisible()
? `perspective(980px) rotateX(${builtTilt().x}deg) rotateY(${builtTilt().y}deg) scale(1)`
: 'perspective(980px) rotateX(0deg) rotateY(0deg) scale(0.95)',
filter: builtVisible() && !reduceMotion() ? 'blur(0px)' : 'blur(3px)',
transitionDelay: builtVisible() ? '150ms' : '0ms',
}}
>
<span class="about-chapter-two-panel-glow" />
<span class="about-chapter-two-reflection" style={{ animation: builtVisible() && !reduceMotion() ? 'chapterTwoSweep 900ms ease-out 1' : 'none' }} />
<div class="about-chapter-two-panel-inner">
<p class="about-chapter-two-panel-label">HOW IT SHOWS UP</p>
<h3 class="about-chapter-two-panel-title">
Before you decide,
<br />
you see signals.
</h3>
<span class="about-chapter-two-divider" />
<div class="about-chapter-two-rows">
<For each={chapterTwoRows}>
{(row, idx) => (
<div
class="about-chapter-two-row"
style={{
opacity: builtVisible() ? 1 : 0,
transform: builtVisible() ? 'translate3d(0,0,0)' : 'translate3d(0,8px,0)',
'transition-delay': builtVisible() ? `${200 + idx() * 100}ms` : '0ms',
}}
>
<span class="about-chapter-two-row-dot" />
{row}
</div>
)}
</For>
</div>
<p class="about-chapter-two-closing">Clarity before commitment.</p>
</div>
</aside>
</div>
</article>
</div>
</section>
<section
id="chapter-trust"
ref={chapterTrustRef}
class="about-section-tight"
>
<div class="container">
<article class={`about-glass-dark about-trust-shell about-reveal-init ${trustVisible() ? 'about-reveal-show' : ''}`}>
<p class="about-chapter-label about-chapter-label-light">Chapter 03</p>
<h2 class="about-chapter-title about-chapter-title-dark">The Trust Layer</h2>
<p class="about-trust-sub">Most reviews complete within 24-48 hours.</p>
<div class="about-trust-sequence">
<div class="about-trust-sequence-list">
<For each={trustSequence}>
{(step, idx) => (
<article
class="about-trust-sequence-card"
style={{
opacity: effectiveTrustProgress() >= (idx() / trustSequence.length) * 0.85 ? 1 : 0.3,
transform: effectiveTrustProgress() >= (idx() / trustSequence.length) * 0.85 ? 'translate3d(0,0,0)' : 'translate3d(0,10px,0)',
}}
>
<div class="about-trust-sequence-row">
<span class="about-trust-sequence-icon">
<span class="about-trust-sequence-dot" />
</span>
<div>
<p class="about-trust-num">{step.num}</p>
<h3>{step.title}</h3>
<p>{step.body}</p>
</div>
</div>
</article>
)}
</For>
</div>
</div>
</article>
</div>
</section>
<section
id="chapter-principles"
ref={chapterPrinciplesRef}
class="about-section-tight about-principles-section"
>
<div class="container">
<article class={`about-principle-narrative-section about-reveal-init ${principlesVisible() ? 'about-reveal-show' : ''}`}>
<p class="about-chapter-label about-chapter-label-light">Chapter 04</p>
<h2 class="about-chapter-title about-chapter-title-dark">Principles</h2>
<div class="about-narrative-stage-root">
<Show
when={!reduceMotion()}
fallback={
<div class="about-narrative-stack">
<p class="about-narrative-headline about-narrative-item-active">We didnt build another marketplace.</p>
<div>
<p class="about-narrative-headline about-narrative-item-active">We built a <span class="about-orange-word">filter</span>.</p>
<span class="about-filter-underline-static" />
</div>
<div>
<p class="about-narrative-headline about-narrative-item-active">A review layer.</p>
<p class="about-principles-subline">Profiles. Jobs. Requirements.</p>
<span class="about-review-line-static" />
</div>
<p class="about-narrative-headline about-narrative-item-active">Clarity replaces noise.</p>
</div>
}
>
<div class="about-narrative-viewport">
<span
class="about-narrative-glow"
style={{
opacity: 0.12 + effectivePrincipleProgress() * 0.54,
}}
/>
<div class="about-narrative-stack">
<For each={chapterFourNarrative}>
{(line, idx) => (
<div class={principleStage() === idx() ? 'about-narrative-item-active' : 'about-narrative-item-inactive'}>
<p class="about-narrative-headline">
{idx() === 1 ? (
<>
We built a <span class="about-orange-word">filter</span>.
</>
) : (
line
)}
</p>
<Show when={idx() === 1}>
<span class="about-filter-underline" style={{ transform: `scaleX(${stateTwoUnderline()})` }} />
</Show>
<Show when={idx() === 2}>
<>
<p class="about-principles-subline">Profiles. Jobs. Requirements.</p>
<span class="about-review-line" style={{ transform: `scaleX(${stateThreeLine()})` }} />
</>
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
</article>
</div>
</section>
<section
id="chapter-timeline"
ref={chapterTimelineRef}
class="about-section-tight about-timeline-section-tight"
>
<div class="container">
<div class={`about-glass-light about-timeline-mask-init ${timelineVisible() ? 'about-timeline-mask-show' : ''}`}>
<p class="about-kicker about-kicker-orange">Chapter 05</p>
<h2 class="about-section-title-light">Timeline</h2>
<div class="about-timeline-wrap">
<span class="about-timeline-spine-glow" />
<For each={milestones}>
{(milestone, index) => (
<article
class={`about-timeline-milestone ${timelineVisible() ? 'about-timeline-milestone-visible' : ''}`}
style={{ 'transition-delay': `${index() * 90}ms` }}
>
<span class="about-timeline-index">{index() + 1}</span>
<h3>{milestone.title}</h3>
<p>{milestone.body}</p>
</article>
)}
</For>
</div>
</div>
</div>
</section>
<section ref={closingRef} class="about-section-mid">
<div class="container">
<div class={`about-glass-dark about-closing-card about-reveal-init ${closingVisible() ? 'about-reveal-show' : ''}`}>
<h2>Have a question or want to partner?</h2>
<div class="hero-actions">
<A class="lp-primary-btn" href="/contact">Contact us</A>
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/">Back to home</A>
</div>
</div>
</div>
</section>
<Show when={showBackToTop()}>
<button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' })}>
</button>
</Show>
</div>
</main>
);
}

View file

@ -0,0 +1,38 @@
const FALLBACK_CONFIG = {
login: {
default: {
src: '/images/auth-company-1.jpg',
tag: 'Public Workspace',
title: 'Welcome Back To Nxtgauge',
subtitle: 'Sign in to manage requests, applications, and services with a unified, verified account.',
},
},
register: {
default: {
src: '/images/auth-company-1.jpg',
tag: 'Get Started',
title: 'Create Your Nxtgauge Account',
subtitle: 'Join verified opportunities for customers, professionals, companies, and job seekers.',
},
},
};
function getVisual(page: 'login' | 'register', intent: string) {
const visuals = FALLBACK_CONFIG[page];
return visuals.default;
}
export async function GET({ request }: { request: Request }) {
const url = new URL(request.url);
const pageParam = String(url.searchParams.get('page') || 'login').toLowerCase();
const page = pageParam === 'register' ? 'register' : 'login';
const intent = String(url.searchParams.get('intent') || 'default').toLowerCase();
return new Response(
JSON.stringify({ success: true, visual: getVisual(page, intent) }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
);
}

View file

@ -0,0 +1,44 @@
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 requiresApproval = body?.requiresApproval !== false;
const upstream = await fetch(gatewayUrl('/me/onboarding-state/complete'), {
method: 'POST',
headers: withAuthHeaders(request, {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-portal-target': 'public',
}),
body: JSON.stringify({
...(roleKey ? { roleKey } : {}),
requiresApproval,
}),
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 complete onboarding',
}),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ success: true, data: payload }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,48 @@
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 upstream = await fetch(gatewayUrl('/me/onboarding-state/progress'), {
method: 'POST',
headers: withAuthHeaders(request, {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-portal-target': 'public',
}),
body: JSON.stringify({
...(roleKey ? { roleKey } : {}),
currentStep: Number.isFinite(currentStep) ? currentStep : 0,
totalSteps: Number.isFinite(totalSteps) ? totalSteps : 0,
...(dataJson ? { dataJson } : {}),
}),
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 update onboarding progress',
}),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ success: true, data: payload }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,41 @@
import { gatewayUrl } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
try {
const url = new URL(request.url);
const schemaId = String(url.searchParams.get('schemaId') || '').trim();
if (!schemaId) {
return new Response(JSON.stringify({ success: false, error: 'schemaId is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const upstream = await fetch(gatewayUrl(`/external/onboarding-schemas/${encodeURIComponent(schemaId)}`), {
method: 'GET',
headers: { 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 schema',
}),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ success: true, data: payload }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,39 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
try {
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 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',
}),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ success: true, data: payload }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,32 @@
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
try {
const upstream = await fetch(gatewayUrl('/me/profile-status'), {
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 profile status',
}),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(JSON.stringify({ success: true, data: payload }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,39 @@
const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, '');
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 upstream = await fetch(`${gatewayBase}/users/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
cache: 'no-store',
});
const payload = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return new Response(JSON.stringify({ success: false, error: payload?.error || payload?.message || 'Failed to send reset link' }), {
status: upstream.status,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true, message: payload?.message || 'Reset instructions sent.' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Failed to send reset link' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,56 @@
const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, '');
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const { email, password } = body as { email?: string; password?: string };
const upstream = await fetch(`${gatewayBase}/users/auth/external/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-portal-target': 'public',
},
body: JSON.stringify({ email, password }),
cache: 'no-store',
});
const payload = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return new Response(
JSON.stringify({
success: false,
error: payload?.message || payload?.error || 'Invalid credentials',
error_code: payload?.error_code,
}),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
success: true,
data: {
token: payload.accessToken || payload.access_token,
refreshToken: payload.refreshToken || payload.refresh_token,
...(payload.user || {}),
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': [
`nxtgauge_access_token=${encodeURIComponent(String(payload.accessToken || payload.access_token || ''))}; Path=/; HttpOnly; SameSite=Lax`,
`nxtgauge_refresh_token=${encodeURIComponent(String(payload.refreshToken || payload.refresh_token || ''))}; Path=/; HttpOnly; SameSite=Lax`,
].join(', '),
},
},
);
} catch {
return new Response(JSON.stringify({ success: false, error: 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,55 @@
const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, '');
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const { name, email, password, userType } = body as {
name?: string;
email?: string;
password?: string;
userType?: number;
};
if (!name || !email || !password) {
return new Response(JSON.stringify({ success: false, error: 'Name, email and password are required.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const upstream = await fetch(`${gatewayBase}/users/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-portal-target': 'public',
},
body: JSON.stringify({ name, email, password, ...(typeof userType === 'number' ? { userType } : {}) }),
cache: 'no-store',
});
const data = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return new Response(
JSON.stringify({ success: false, error: data?.message || data?.error || 'Registration failed' }),
{ status: upstream.status, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
success: true,
data: {
userId: data?.id,
email: data?.email || email,
message: 'Registration successful',
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,44 @@
const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, '');
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json().catch(() => ({}));
const { email, token, newPassword } = body as {
email?: string;
token?: string;
newPassword?: string;
};
if (!email || !token || !newPassword) {
return new Response(JSON.stringify({ success: false, error: 'Email, token, and new password are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const upstream = await fetch(`${gatewayBase}/users/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, token, new_password: newPassword }),
cache: 'no-store',
});
const payload = await upstream.json().catch(() => ({}));
if (!upstream.ok) {
return new Response(JSON.stringify({ success: false, error: payload?.error || payload?.message || 'Failed to reset password' }), {
status: upstream.status,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true, message: payload?.message || 'Password reset successfully' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, error: error?.message || 'Failed to reset password' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,103 @@
import { createVerificationCode } from '~/lib/server/email-verification-store';
import { sendVerificationEmail } from '~/lib/server/smtp';
type VerificationFlow = 'register' | 'login';
function parseFlow(value: unknown): VerificationFlow {
return value === 'login' ? 'login' : 'register';
}
function maskEmail(email: string): string {
const [localPart, domain] = email.split('@');
if (!localPart || !domain) return 'your email';
if (localPart.length <= 2) return `${localPart[0] || '*'}*@${domain}`;
return `${localPart.slice(0, 2)}***@${domain}`;
}
export async function POST({ request }: { request: Request }) {
try {
const rawBody = await request.text().catch(() => '');
const url = new URL(request.url);
const query = url.searchParams;
let body: any = {};
if (rawBody) {
try {
body = JSON.parse(rawBody);
} catch {
const params = new URLSearchParams(rawBody);
body = {
email: params.get('email') || '',
flow: params.get('flow') || '',
};
}
}
const bodyData = body?.data && typeof body.data === 'object' ? body.data : {};
const email = String(body?.email || bodyData?.email || query.get('email') || '').trim().toLowerCase();
const flow = parseFlow(body?.flow || bodyData?.flow || query.get('flow'));
if (!email) {
return new Response(JSON.stringify({ success: false, error: 'Email is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const challenge = createVerificationCode({ email, flow });
const exposeDebugCode = process.env.NODE_ENV !== 'production';
const isDev = process.env.NODE_ENV !== 'production';
const strictDelivery = process.env.MAIL_REQUIRE_SUCCESS === 'true';
const mailTimeoutMs = 12000;
try {
await Promise.race([
sendVerificationEmail({
to: email,
code: challenge.code,
expiresInMinutes: Math.max(Math.floor(challenge.expiresInSeconds / 60), 1),
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('SMTP delivery timeout')), mailTimeoutMs),
),
]);
} catch (mailError: any) {
if (!isDev || strictDelivery) {
throw mailError;
}
return new Response(
JSON.stringify({
success: true,
data: {
maskedEmail: maskEmail(email),
expiresInSeconds: challenge.expiresInSeconds,
debugCode: challenge.code,
emailDelivery: 'failed',
},
message: `Verification code generated for ${maskEmail(email)} (email delivery failed in local dev).`,
warning: mailError?.message || 'Email delivery failed',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
success: true,
data: {
maskedEmail: maskEmail(email),
expiresInSeconds: challenge.expiresInSeconds,
...(exposeDebugCode ? { debugCode: challenge.code } : {}),
},
message: `Verification code sent to ${maskEmail(email)}`,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
} catch (error: any) {
return new Response(
JSON.stringify({ success: false, error: error?.message || 'Failed to send verification code' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
}

View file

@ -0,0 +1,82 @@
import { consumeVerificationCode } from '~/lib/server/email-verification-store';
const shouldSkipVerification = () => process.env.SKIP_EMAIL_VERIFICATION === 'true';
export async function POST({ request }: { request: Request }) {
try {
const rawBody = await request.text().catch(() => '');
const url = new URL(request.url);
const query = url.searchParams;
let body: any = {};
if (rawBody) {
try {
body = JSON.parse(rawBody);
} catch {
const params = new URLSearchParams(rawBody);
body = {
email: params.get('email') || '',
code: params.get('code') || params.get('verificationCode') || params.get('verification_code') || '',
flow: params.get('flow') || '',
};
}
}
const bodyData = body?.data && typeof body.data === 'object' ? body.data : {};
const email = String(body?.email || bodyData?.email || query.get('email') || '').trim().toLowerCase();
const code = String(
body?.code
|| body?.verificationCode
|| body?.verification_code
|| bodyData?.code
|| bodyData?.verificationCode
|| bodyData?.verification_code
|| query.get('code')
|| query.get('verificationCode')
|| query.get('verification_code')
|| '',
).trim();
const flowInput = body?.flow || bodyData?.flow || query.get('flow');
const flow = flowInput === 'login' ? 'login' : 'register';
if (!email || !code) {
return new Response(JSON.stringify({ success: false, message: 'Email and code are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (shouldSkipVerification()) {
return new Response(JSON.stringify({ success: true, message: 'Email verification skipped (development mode).' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const result = consumeVerificationCode({ email, flow, code });
if (!result.ok) {
const message =
result.reason === 'INVALID_CODE'
? 'Invalid verification code'
: result.reason === 'TOO_MANY_ATTEMPTS'
? 'Too many attempts. Please request a new code.'
: 'Verification code expired. Please request a new code.';
return new Response(JSON.stringify({ success: false, message }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true, message: 'Email verified successfully' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error: any) {
return new Response(JSON.stringify({ success: false, message: error?.message || 'Invalid code or email' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
}

View file

@ -0,0 +1,176 @@
import { A, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal } from 'solid-js';
function getPasswordChecks(password: string, confirmPassword: string) {
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.length > 0 && password === confirmPassword,
};
}
export default function ForgotPasswordPage() {
const [search] = useSearchParams();
const [mode, setMode] = createSignal<'request' | 'reset'>(search.token ? 'reset' : 'request');
const [email, setEmail] = createSignal(search.email || '');
const [token, setToken] = createSignal(search.token || '');
const [newPassword, setNewPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal('');
const [success, setSuccess] = createSignal('');
const checks = createMemo(() => getPasswordChecks(newPassword(), confirmPassword()));
const passwordStrong = createMemo(() => {
const c = checks();
return c.minLength && c.uppercase && c.lowercase && c.number && c.special;
});
const requestReset = async () => {
setError('');
setSuccess('');
if (!email().trim()) {
setError('Email is required.');
return;
}
setLoading(true);
try {
const response = await fetch('/api/users/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email().trim().toLowerCase() }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
setError(payload?.error || 'Failed to send reset email.');
return;
}
setSuccess(payload?.message || 'Reset instructions sent. Check your email.');
setMode('reset');
} catch {
setError('Failed to send reset email.');
} finally {
setLoading(false);
}
};
const resetPassword = async () => {
setError('');
setSuccess('');
if (!email().trim() || !token().trim() || !newPassword() || !confirmPassword()) {
setError('Email, token, and password fields are required.');
return;
}
if (!passwordStrong()) {
setError('Password must include uppercase, lowercase, number, special character, and be at least 8 characters.');
return;
}
if (!checks().match) {
setError('Passwords do not match.');
return;
}
setLoading(true);
try {
const response = await fetch('/api/users/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email().trim().toLowerCase(),
token: token().trim(),
newPassword: newPassword(),
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
setError(payload?.error || 'Failed to reset password.');
return;
}
setSuccess(payload?.message || 'Password reset successfully. You can now sign in.');
setNewPassword('');
setConfirmPassword('');
} catch {
setError('Failed to reset password.');
} finally {
setLoading(false);
}
};
return (
<main class="auth-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>
<div class="auth-layout auth-layout-single">
<section class="auth-form card glass-light">
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
<h2 class="title">Forgot Password</h2>
<p class="subtitle">Reset your external user password securely.</p>
<div class="actions" style={{ 'margin-top': '10px' }}>
<button class="btn" onClick={() => setMode('request')}>Request reset</button>
<button class="btn" onClick={() => setMode('reset')}>Set new password</button>
</div>
<div class="field">
<label class="label">Email</label>
<input class="input" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} placeholder="Enter your email" />
</div>
{mode() === 'reset' && (
<>
<div class="field">
<label class="label">Reset Token</label>
<input class="input" value={token()} onInput={(e) => setToken(e.currentTarget.value)} placeholder="Paste token from email" />
</div>
<div class="field">
<label class="label">New Password</label>
<input class="input" type="password" value={newPassword()} onInput={(e) => setNewPassword(e.currentTarget.value)} />
</div>
<div class="field">
<label class="label">Confirm Password</label>
<input class="input" type="password" value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} />
</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>
</>
)}
{error() && <p class="error">{error()}</p>}
{success() && <p class="note ok">{success()}</p>}
<button class="btn primary" onClick={mode() === 'request' ? requestReset : resetPassword} disabled={loading()}>
{loading() ? 'Please wait...' : mode() === 'request' ? 'Send reset email' : 'Reset password'}
</button>
<p class="note">Back to <A href="/auth/login">Sign In</A></p>
</section>
</div>
</main>
);
}

View file

@ -0,0 +1,5 @@
import VerificationPage from '~/routes/auth/verification/index';
export default function LoginVerificationPage() {
return <VerificationPage />;
}

View file

@ -1,16 +1,233 @@
import { A } from '@solidjs/router';
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, onMount } from 'solid-js';
import { intentToOnboardingPath, normalizeIntent, readCanonicalIntent, saveCanonicalIntent } from '~/lib/auth-intent';
import PublicHeader from '~/components/PublicHeader';
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 (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 3l18 18" />
<path d="M10.58 10.58a2 2 0 0 0 2.83 2.83" />
<path d="M9.88 5.09A11 11 0 0 1 12 4.9c5.5 0 10 4.1 10 7.1 0 1.2-.72 2.53-1.95 3.72" />
<path d="M6.1 6.1C3.54 7.58 2 9.79 2 12c0 3 4.48 7.1 10 7.1 1.72 0 3.36-.4 4.84-1.12" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export default function LoginPage() {
const navigate = useNavigate();
const [search] = useSearchParams();
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 [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [captcha, setCaptcha] = createSignal(makeCaptcha());
const [captchaInput, setCaptchaInput] = createSignal('');
const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [showPassword, setShowPassword] = createSignal(false);
const [visual, setVisual] = createSignal({
src: '/images/auth-company-1.jpg',
tag: 'Public Workspace',
title: 'Welcome Back To Nxtgauge',
subtitle: 'Sign in to manage requests, applications, and services with a unified, verified account.',
});
const emailValid = createMemo(() => isValidEmail(email()));
const signUpHref = createMemo(() => {
const next = new URLSearchParams();
if (intent) next.set('intent', intent);
next.set('redirect', postLoginTarget);
return `/auth/register?${next.toString()}`;
});
onMount(() => {
const query = new URLSearchParams({
page: 'login',
intent: intent || 'default',
});
fetch(`/api/runtime/auth-visuals?${query.toString()}`)
.then((res) => res.json())
.then((payload) => {
const next = payload?.visual;
if (!next?.src) return;
setVisual({
src: String(next.src),
tag: String(next.tag || 'Public Workspace'),
title: String(next.title || 'Welcome Back To Nxtgauge'),
subtitle: String(next.subtitle || 'Sign in to manage requests, applications, and services with a unified, verified account.'),
});
})
.catch(() => {});
});
const handleLogin = async () => {
setError('');
if (!email().trim() || !password().trim() || !captchaInput().trim()) {
setError('Please fill all fields.');
return;
}
if (!emailValid()) {
setError('Please enter a valid email address.');
return;
}
if (captchaInput().trim().toUpperCase() !== captcha()) {
setError('Captcha does not match.');
setCaptcha(makeCaptcha());
setCaptchaInput('');
return;
}
setLoading(true);
try {
saveCanonicalIntent(intent);
const loginResponse = await fetch('/api/users/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: email().trim().toLowerCase(), password: password() }),
});
const payload = await loginResponse.json().catch(() => ({}));
if (!loginResponse.ok || !payload?.success) {
setError(payload?.error || 'Invalid email or password.');
setLoading(false);
return;
}
const verificationResponse = await fetch('/api/users/auth/verification/request-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email().trim().toLowerCase(), flow: 'login' }),
});
const verificationPayload = await verificationResponse.json().catch(() => ({}));
if (!verificationResponse.ok || !verificationPayload?.success) {
setError(verificationPayload?.error || verificationPayload?.message || 'Failed to send verification code.');
setLoading(false);
return;
}
const next = new URLSearchParams({
email: email().trim().toLowerCase(),
flow: 'login',
redirect: postLoginTarget,
});
if (intent) next.set('intent', intent);
navigate(`/auth/verification?${next.toString()}`);
} catch {
setError('Login failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<main class="page marketing-page">
<section class="card glass-light">
<h1 class="title">Login</h1>
<p class="subtitle">Auth parity page scaffolded. Final auth integration to be connected in next migration stage.</p>
<div class="actions">
<A class="btn primary" href="/">Back Home</A>
<A class="btn" href="/auth/register">Register</A>
</div>
</section>
<main class="auth-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 signupHref={signUpHref()} />
<div class="auth-layout">
<section class="auth-visual card glass-dark">
<img class="auth-visual-img" src={visual().src} alt={visual().tag} />
<div class="auth-visual-overlay" />
<div class="auth-visual-content">
<p class="eyebrow">{visual().tag}</p>
<h1 class="title light">{visual().title}</h1>
<p class="subtitle light">{visual().subtitle}</p>
</div>
</section>
<section class="auth-form card glass-light">
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
<h2 class="title">Sign In</h2>
<p class="subtitle">Use your external account credentials to continue.</p>
<div class="field">
<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>
</div>
<div class="field">
<div class="auth-field-head">
<label class="label">Password</label>
<A class="auth-forgot-link" href="/auth/forgot-password">Forgot?</A>
</div>
<div class="auth-password-wrap">
<input
class="input"
type={showPassword() ? 'text' : 'password'}
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="Enter your password"
/>
<button
class="auth-toggle-visibility"
type="button"
onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword() ? 'Hide password' : 'Show password'}
>
<PasswordVisibilityIcon visible={showPassword()} />
</button>
</div>
</div>
<div class="field">
<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>
<input class="input" value={captchaInput()} onInput={(e) => setCaptchaInput(e.currentTarget.value)} placeholder="Enter captcha" />
</div>
</div>
{error() && <p class="error">{error()}</p>}
<button class="auth-submit-btn" onClick={handleLogin} disabled={loading()}>
{loading() ? 'Signing In...' : 'Sign In'}
</button>
<div class="auth-footer-row">
<p class="note">Secure login with email verification.</p>
<p class="note">New user? <A href={signUpHref()}>Sign Up</A></p>
</div>
</section>
</div>
</main>
);
}

View file

@ -1,16 +1,324 @@
import { A } from '@solidjs/router';
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';
const PENDING_REGISTER_KEY = 'nxtgauge_pending_register_v1';
const DEV_VERIFICATION_CODE_KEY = 'nxtgauge_dev_verification_code_v1';
const 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 normalizeProfessionalRole(value: string | null): string | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (!normalized) return null;
return normalized.replace(/[\s-]+/g, '_').replace(/[^a-z_]/g, '');
}
function getRegistrationExtras(intent: string | null) {
if (intent === 'company') return { userType: 1 };
return { userType: 3 };
}
function PasswordVisibilityIcon(props: { visible: boolean }) {
if (props.visible) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 3l18 18" />
<path d="M10.58 10.58a2 2 0 0 0 2.83 2.83" />
<path d="M9.88 5.09A11 11 0 0 1 12 4.9c5.5 0 10 4.1 10 7.1 0 1.2-.72 2.53-1.95 3.72" />
<path d="M6.1 6.1C3.54 7.58 2 9.79 2 12c0 3 4.48 7.1 10 7.1 1.72 0 3.36-.4 4.84-1.12" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export default function RegisterPage() {
const navigate = useNavigate();
const [search] = useSearchParams();
const intentParam = normalizeIntent(search.intent || search.intentRole);
const redirectParam = search.redirect;
const safeRedirect = redirectParam && redirectParam.startsWith('/') ? redirectParam : null;
const professionalRole = normalizeProfessionalRole(search.profession || search.role || null);
const resolvedIntent = intentParam || 'customer';
const onboardingTarget = createMemo(() => {
const base = intentToOnboardingPath(resolvedIntent);
if (resolvedIntent !== 'professional' || !professionalRole) return base;
return `${base}?profession=${encodeURIComponent(professionalRole)}`;
});
const resolvedRedirect = createMemo(() => {
if (!safeRedirect) return onboardingTarget();
if (
resolvedIntent === 'professional' &&
professionalRole &&
safeRedirect.startsWith('/users/onboarding/professional') &&
!safeRedirect.includes('profession=')
) {
return `${safeRedirect}?profession=${encodeURIComponent(professionalRole)}`;
}
return safeRedirect;
});
const [firstName, setFirstName] = createSignal('');
const [lastName, setLastName] = createSignal('');
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [captcha, setCaptcha] = createSignal('');
const [captchaInput, setCaptchaInput] = createSignal('');
const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [showPassword, setShowPassword] = createSignal(false);
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
const [visual, setVisual] = createSignal({
src: '/images/auth-company-1.jpg',
tag: 'Get Started',
title: 'Create Your Nxtgauge Account',
subtitle: 'Join verified opportunities for customers, professionals, companies, and job seekers.',
});
onMount(() => {
if (!captcha()) setCaptcha(makeCaptcha());
const query = new URLSearchParams({
page: 'register',
intent: resolvedIntent || 'default',
});
fetch(`/api/runtime/auth-visuals?${query.toString()}`)
.then((res) => res.json())
.then((payload) => {
const next = payload?.visual;
if (!next?.src) return;
setVisual({
src: String(next.src),
tag: String(next.tag || 'Get Started'),
title: String(next.title || 'Create Your Nxtgauge Account'),
subtitle: String(next.subtitle || 'Join verified opportunities for customers, professionals, companies, and job seekers.'),
});
})
.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 emailValid = createMemo(() => isValidEmail(email()));
const passwordStrong = createMemo(() => {
const c = checks();
return c.minLength && c.uppercase && c.lowercase && c.number && c.special;
});
const canSubmit = createMemo(() => {
return (
firstName().trim().length > 0 &&
lastName().trim().length > 0 &&
emailValid() &&
passwordStrong() &&
checks().match &&
captchaInput().trim().toUpperCase() === captcha()
);
});
const loginHref = createMemo(() =>
`/auth/login?intent=${encodeURIComponent(resolvedIntent || 'customer')}&redirect=${encodeURIComponent(resolvedRedirect())}`,
);
const refreshCaptcha = () => {
setCaptcha(makeCaptcha());
setCaptchaInput('');
};
const handleRegister = async () => {
setError('');
if (!canSubmit()) {
setError('Please complete all fields correctly.');
return;
}
if (captchaInput().trim().toUpperCase() !== captcha()) {
setError('Captcha does not match.');
refreshCaptcha();
return;
}
setLoading(true);
try {
const normalizedEmail = email().trim().toLowerCase();
saveCanonicalIntent(resolvedIntent);
const verificationResponse = await fetch('/api/users/auth/verification/request-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: normalizedEmail, flow: 'register' }),
});
const verificationPayload = await verificationResponse.json().catch(() => ({}));
if (!verificationResponse.ok || !verificationPayload?.success) {
setError(verificationPayload?.error || verificationPayload?.message || 'Failed to send verification code.');
setLoading(false);
return;
}
const debugCode = String(verificationPayload?.data?.debugCode || '').trim();
if (debugCode) {
window.localStorage.setItem(
DEV_VERIFICATION_CODE_KEY,
JSON.stringify({ email: normalizedEmail, flow: 'register', code: debugCode, createdAt: Date.now() }),
);
}
const fullName = `${firstName().trim()} ${lastName().trim()}`.trim();
window.localStorage.setItem(
PENDING_REGISTER_KEY,
JSON.stringify({
name: fullName,
email: normalizedEmail,
password: password(),
userType: getRegistrationExtras(resolvedIntent).userType,
intent: resolvedIntent,
redirect: resolvedRedirect(),
}),
);
const next = new URLSearchParams({
email: normalizedEmail,
flow: 'register',
intent: resolvedIntent || 'customer',
redirect: resolvedRedirect(),
});
navigate(`/auth/verification?${next.toString()}`);
return;
} catch {
setError('Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<main class="page marketing-page">
<section class="card glass-light">
<h1 class="title">Register</h1>
<p class="subtitle">Auth parity page scaffolded. Final OTP/signup integration to be connected in next migration stage.</p>
<div class="actions">
<A class="btn primary" href="/">Back Home</A>
<A class="btn" href="/auth/login">Login</A>
</div>
</section>
<main class="auth-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 loginHref={loginHref()} />
<div class="auth-layout">
<section class="auth-visual card glass-dark">
<img class="auth-visual-img" src={visual().src} alt={visual().tag} />
<div class="auth-visual-overlay" />
<div class="auth-visual-content">
<p class="eyebrow">{visual().tag}</p>
<h1 class="title light">{visual().title}</h1>
<p class="subtitle light">{visual().subtitle}</p>
</div>
</section>
<section class="auth-form card glass-light">
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
<h2 class="title">Create Your Account</h2>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<div class="field">
<label class="label">First Name</label>
<input class="input" value={firstName()} onInput={(e) => setFirstName(e.currentTarget.value)} />
</div>
<div class="field">
<label class="label">Last Name</label>
<input class="input" value={lastName()} onInput={(e) => setLastName(e.currentTarget.value)} />
</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>
</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>
</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>
<div class="auth-captcha-row">
<button class="auth-captcha-refresh" type="button" onClick={refreshCaptcha}></button>
<div class="auth-captcha-code">{captcha()}</div>
<input class="input" value={captchaInput()} onInput={(e) => setCaptchaInput(e.currentTarget.value)} placeholder="Enter captcha" />
</div>
</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>
{error() && <p class="error">{error()}</p>}
<button class="auth-submit-btn" disabled={!canSubmit() || loading()} onClick={handleRegister}>
{loading() ? 'Please wait...' : 'Create Account'}
</button>
<p class="note">Already have an account? <A href={loginHref()}>Sign In</A></p>
</section>
</div>
</main>
);
}

View file

@ -0,0 +1,278 @@
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, For, onMount } from 'solid-js';
import { intentToOnboardingPath, normalizeIntent, readCanonicalIntent, saveCanonicalIntent } from '~/lib/auth-intent';
const OTP_LENGTH = 6;
const PENDING_REGISTER_KEY = 'nxtgauge_pending_register_v1';
const DEV_VERIFICATION_CODE_KEY = 'nxtgauge_dev_verification_code_v1';
type PendingRegisterPayload = {
name: string;
email: string;
password: string;
userType: number;
intent?: string;
redirect?: string;
};
function readPendingRegisterPayload(): PendingRegisterPayload | null {
if (typeof window === 'undefined') return null;
const raw = window.localStorage.getItem(PENDING_REGISTER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as PendingRegisterPayload;
} catch {
return null;
}
}
function readDevVerificationCode(email: string, flow: string): string {
if (typeof window === 'undefined') return '';
const raw = window.localStorage.getItem(DEV_VERIFICATION_CODE_KEY);
if (!raw) return '';
try {
const parsed = JSON.parse(raw) as { email?: string; flow?: string; code?: string; createdAt?: number };
const code = String(parsed?.code || '').trim();
const savedEmail = String(parsed?.email || '').trim().toLowerCase();
const savedFlow = String(parsed?.flow || '').trim();
const createdAt = Number(parsed?.createdAt || 0);
const isFresh = Number.isFinite(createdAt) && createdAt > 0 && Date.now() - createdAt <= 15 * 60 * 1000;
if (!code || savedEmail !== email.toLowerCase() || savedFlow !== flow || !isFresh) return '';
return code;
} catch {
return '';
}
}
export default function VerificationPage() {
const navigate = useNavigate();
const [search] = useSearchParams();
const email = () => String(search.email || '');
const flow = () => String(search.flow || 'register');
const intent = () => normalizeIntent(search.intent || search.intentRole);
const redirect = () => {
const v = search.redirect;
return v && v.startsWith('/') ? v : null;
};
const resolvedIntent = createMemo(() => intent() || readCanonicalIntent());
const registerTarget = createMemo(() => intentToOnboardingPath(resolvedIntent()));
const [otp, setOtp] = createSignal(Array.from({ length: OTP_LENGTH }, () => ''));
const [timer, setTimer] = createSignal(30);
const [error, setError] = createSignal('');
const [info, setInfo] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [isResending, setIsResending] = createSignal(false);
let intervalId: ReturnType<typeof setInterval> | undefined;
const otpRefs: Array<HTMLInputElement | undefined> = [];
onMount(() => {
const debugCode = readDevVerificationCode(email(), flow());
if (debugCode) setInfo(`Local dev OTP: ${debugCode}`);
intervalId = setInterval(() => {
setTimer((v) => (v > 0 ? v - 1 : 0));
}, 1000);
});
const continueAfterVerification = async () => {
saveCanonicalIntent(resolvedIntent());
if (flow() === 'register') {
const pending = readPendingRegisterPayload();
if (!pending?.email || !pending?.password || !pending?.name) {
setError('Registration session expired. Please register again.');
setLoading(false);
return;
}
if (email() && pending.email.toLowerCase() !== email().toLowerCase()) {
setError('Verification email does not match registration details. Please register again.');
setLoading(false);
return;
}
try {
const registerResponse = await fetch('/api/users/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: pending.name,
email: pending.email,
password: pending.password,
...(typeof pending.userType === 'number' ? { userType: pending.userType } : {}),
}),
});
const registerPayload = await registerResponse.json().catch(() => ({}));
if (!registerResponse.ok || !registerPayload?.success) {
setError(String(registerPayload?.error || registerPayload?.message || 'Registration failed.'));
setLoading(false);
return;
}
} catch {
setError('Registration failed after verification. Please try again.');
setLoading(false);
return;
}
try {
const loginResponse = await fetch('/api/users/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: pending.email, password: pending.password }),
});
const loginPayload = await loginResponse.json().catch(() => ({}));
if (!loginResponse.ok || !loginPayload?.success) {
setError('Email verified and account created. Please sign in to continue.');
setLoading(false);
return;
}
} catch {
setError('Email verified and account created. Please sign in to continue.');
setLoading(false);
return;
}
window.localStorage.removeItem(PENDING_REGISTER_KEY);
window.localStorage.removeItem(DEV_VERIFICATION_CODE_KEY);
navigate(pending.redirect || redirect() || registerTarget(), { replace: true });
return;
}
navigate(redirect() || registerTarget(), { replace: true });
};
const handleChange = (index: number, value: string) => {
const digit = value.replace(/\D/g, '').slice(0, 1);
const next = [...otp()];
next[index] = digit;
setOtp(next);
if (digit && index < OTP_LENGTH - 1) otpRefs[index + 1]?.focus();
};
const handleVerify = async () => {
const code = otp().join('');
if (code.length !== OTP_LENGTH) {
setError(`Please enter ${OTP_LENGTH}-digit code.`);
return;
}
setLoading(true);
setError('');
setInfo('');
try {
const response = await fetch('/api/users/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email(), code, flow: flow() }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
setError(payload?.message || payload?.error || 'Invalid verification code.');
setLoading(false);
return;
}
await continueAfterVerification();
} catch {
setError('Unable to verify code. Please try again.');
setLoading(false);
}
};
const resend = async () => {
if (!email() || timer() > 0 || isResending()) return;
setIsResending(true);
setError('');
setInfo('');
try {
const response = await fetch('/api/users/auth/verification/request-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email(), flow: flow() }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
setError(payload?.message || payload?.error || 'Failed to resend verification code.');
setIsResending(false);
return;
}
const debugCode = String(payload?.data?.debugCode || '').trim();
if (debugCode) {
window.localStorage.setItem(
DEV_VERIFICATION_CODE_KEY,
JSON.stringify({ email: email(), flow: flow(), code: debugCode, createdAt: Date.now() }),
);
}
setOtp(Array.from({ length: OTP_LENGTH }, () => ''));
setTimer(30);
setInfo(debugCode ? `A new code was generated. Local dev OTP: ${debugCode}` : 'A new verification code was sent.');
otpRefs[0]?.focus();
} catch {
setError('Failed to resend verification code.');
} finally {
setIsResending(false);
}
};
return (
<main class="auth-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>
<div class="auth-layout auth-layout-single">
<section class="auth-form card glass-light">
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
<h2 class="title">Verify Your Email</h2>
<p class="subtitle">Enter the 6-digit code sent to <b>{email() || 'your email'}</b>.</p>
<div class="otp-row">
<For each={otp()}>
{(digit, idx) => (
<input
ref={(el) => {
otpRefs[idx()] = el;
}}
class="otp-input"
inputMode="numeric"
maxlength={1}
value={digit}
onInput={(e) => handleChange(idx(), e.currentTarget.value)}
/>
)}
</For>
</div>
{info() && <p class="note ok">{info()}</p>}
{error() && <p class="error">{error()}</p>}
<div class="actions">
<button class="btn primary" onClick={handleVerify} disabled={loading()}>
{loading() ? 'Verifying...' : 'Verify Code'}
</button>
<button class="btn" onClick={resend} disabled={timer() > 0 || isResending()}>
{timer() > 0 ? `Resend in ${timer()}s` : isResending() ? 'Resending...' : 'Resend code'}
</button>
</div>
<p class="note">Back to <A href="/auth/login">Sign In</A></p>
</section>
</div>
</main>
);
}

View file

@ -1,14 +1,5 @@
import { A } from '@solidjs/router';
import PublicLanding from '~/components/PublicLanding';
export default function CompaniesLandingPage() {
return (
<main class="page marketing-page">
<section class="card glass-dark">
<p class="eyebrow">For Companies</p>
<h1 class="title light">Post jobs with verified company identity</h1>
<p class="subtitle light">Complete company onboarding and manage recruitment workflows with trust signals.</p>
<A class="btn primary" href="/onboarding?schemaId=company_onboarding_v1">Start Company Onboarding</A>
</section>
</main>
);
export default function CompaniesPage() {
return <PublicLanding />;
}

View file

@ -1,69 +1,259 @@
import { createMemo, createSignal } from 'solid-js';
import { A } from '@solidjs/router';
import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import PublicHeader from '~/components/PublicHeader';
type FormValues = {
fullName: string;
email: string;
phone: string;
userType: string;
topic: string;
message: string;
attachment: File | null;
};
type FormErrors = Partial<Record<keyof FormValues, string>>;
const initialValues: FormValues = {
fullName: '',
email: '',
phone: '',
userType: '',
topic: '',
message: '',
attachment: null,
};
const userTypes = [
'Customer (Hire professional)',
'Company (Post job)',
'Professional (Provide services)',
'Job Seeker (Apply jobs)',
] as const;
const topics = [
'Account & Login',
'Verification',
'Posting a Job',
'Posting a Requirement',
'Leads / Matching',
'Payments / Credits',
'Bug Report',
'Other',
] as const;
function IconMail() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16v12H4z" />
<path d="m4 7 8 6 8-6" />
</svg>
);
}
function IconClock() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v6l4 2" />
</svg>
);
}
function IconPin() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 21s6-5.4 6-11a6 6 0 1 0-12 0c0 5.6 6 11 6 11Z" />
<circle cx="12" cy="10" r="2.2" />
</svg>
);
}
export default function ContactPage() {
const [name, setName] = createSignal('');
const [email, setEmail] = createSignal('');
const [topic, setTopic] = createSignal('');
const [message, setMessage] = createSignal('');
const [sent, setSent] = createSignal(false);
const [values, setValues] = createSignal<FormValues>(initialValues);
const [errors, setErrors] = createSignal<FormErrors>({});
const [submitted, setSubmitted] = createSignal(false);
const [showBackToTop, setShowBackToTop] = createSignal(false);
const canSubmit = createMemo(() => name().trim() && email().trim() && topic().trim() && message().trim().length >= 20);
const validate = (v: FormValues): FormErrors => {
const next: FormErrors = {};
if (!v.fullName.trim()) next.fullName = 'Full name is required.';
if (!v.email.trim()) next.email = 'Email is required.';
if (v.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim())) next.email = 'Enter a valid email.';
if (!v.userType.trim()) next.userType = 'Please select user type.';
if (!v.topic.trim()) next.topic = 'Please select a topic.';
if (!v.message.trim()) next.message = 'Message is required.';
if (v.message.trim() && v.message.trim().length < 20) next.message = 'Message must be at least 20 characters.';
if (v.attachment) {
if (v.attachment.size > 10 * 1024 * 1024) next.attachment = 'Attachment must be 10MB or smaller.';
const allowed = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg'];
if (!allowed.includes(v.attachment.type)) next.attachment = 'Allowed formats: PDF, PNG, JPG.';
}
return next;
};
const canSubmit = createMemo(() => Object.keys(validate(values())).length === 0);
const update = <K extends keyof FormValues>(key: K, next: FormValues[K]) => {
setValues((prev) => ({ ...prev, [key]: next }));
};
onMount(() => {
const onScroll = () => setShowBackToTop(window.scrollY > 500);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
onCleanup(() => window.removeEventListener('scroll', onScroll));
});
return (
<main class="page marketing-page">
<section class="card glass-dark">
<p class="eyebrow">Reach Out</p>
<h1 class="title light">Contact us</h1>
<p class="subtitle light">Tell us what you need well get back to you.</p>
</section>
<main class="lp-main">
<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>
<section class="grid" style={{ 'margin-top': '14px' }}>
<form
class="card glass-light"
onSubmit={(e) => {
e.preventDefault();
if (!canSubmit()) return;
setSent(true);
setTimeout(() => setSent(false), 2500);
setName('');
setEmail('');
setTopic('');
setMessage('');
}}
>
<div class="field">
<label class="label">Full Name</label>
<input class="input" value={name()} onInput={(e) => setName(e.currentTarget.value)} />
</div>
<div class="field">
<label class="label">Email</label>
<input class="input" type="email" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
</div>
<div class="field">
<label class="label">Topic</label>
<select class="select" value={topic()} onInput={(e) => setTopic(e.currentTarget.value)}>
<option value="">Select topic</option>
<option>Account & Login</option>
<option>Verification</option>
<option>Leads / Matching</option>
<option>Other</option>
</select>
</div>
<div class="field">
<label class="label">Message</label>
<textarea class="textarea" value={message()} onInput={(e) => setMessage(e.currentTarget.value)} />
</div>
<button class="btn primary" disabled={!canSubmit()} type="submit">Send message</button>
{sent() && <p class="note ok">Message sent. We&apos;ll reply soon.</p>}
</form>
<div class="lp-content">
<PublicHeader />
<aside class="card glass-dark">
<h3 class="light">Contact details</h3>
<p class="subtitle light">support@nxtgauge.com</p>
<p class="subtitle light">Typically within 2448 hours</p>
<p class="subtitle light">Chennai, India</p>
</aside>
</section>
<section class="public-section scene-dark">
<div class="container panel panel-dark contact-hero-panel">
<p class="eyebrow">Reach out</p>
<h1 class="lp-hero-title">Contact us</h1>
<p class="lp-hero-copy">Tell us what you need well get back to you.</p>
<div class="contact-pill-row">
<span class="contact-pill">Support</span>
<span class="contact-pill">Partnerships</span>
<span class="contact-pill">Verification</span>
<span class="contact-pill">General</span>
</div>
</div>
</section>
<section class="public-section scene-light">
<div class="container">
<div class="flow-card" style={{ 'grid-template-columns': '1.45fr 1fr' }}>
<form
class="card glass-light contact-form-card"
onSubmit={(event) => {
event.preventDefault();
const nextErrors = validate(values());
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
setSubmitted(true);
setValues(initialValues);
window.setTimeout(() => setSubmitted(false), 3200);
}}
>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<label class="field">
<span class="label">Full Name *</span>
<input class="input" value={values().fullName} onInput={(e) => update('fullName', e.currentTarget.value)} />
<Show when={errors().fullName}><p class="error">{errors().fullName}</p></Show>
</label>
<label class="field">
<span class="label">Email *</span>
<input class="input" type="email" value={values().email} onInput={(e) => update('email', e.currentTarget.value)} />
<Show when={errors().email}><p class="error">{errors().email}</p></Show>
</label>
</div>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<label class="field">
<span class="label">Phone</span>
<input class="input" value={values().phone} onInput={(e) => update('phone', e.currentTarget.value)} />
</label>
<label class="field">
<span class="label">User Type *</span>
<select class="select" value={values().userType} onInput={(e) => update('userType', e.currentTarget.value)}>
<option value="">Select user type</option>
{userTypes.map((type) => (
<option value={type}>{type}</option>
))}
</select>
<Show when={errors().userType}><p class="error">{errors().userType}</p></Show>
</label>
</div>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<label class="field">
<span class="label">Topic *</span>
<select class="select" value={values().topic} onInput={(e) => update('topic', e.currentTarget.value)}>
<option value="">Select topic</option>
{topics.map((topic) => (
<option value={topic}>{topic}</option>
))}
</select>
<Show when={errors().topic}><p class="error">{errors().topic}</p></Show>
</label>
<label class="field">
<span class="label">Attachment</span>
<label class="contact-upload">
<span class="contact-upload-icon" aria-hidden="true"></span>
<span class="contact-upload-text">{values().attachment ? values().attachment.name : 'Upload pdf/png/jpg (max 10MB)'}</span>
<input
class="contact-upload-input"
type="file"
accept=".pdf,.png,.jpg,.jpeg"
onChange={(e) => update('attachment', e.currentTarget.files?.[0] ?? null)}
/>
</label>
<Show when={errors().attachment}><p class="error">{errors().attachment}</p></Show>
</label>
</div>
<label class="field">
<span class="label">Message *</span>
<textarea class="textarea" value={values().message} onInput={(e) => update('message', e.currentTarget.value)} />
<Show when={errors().message}><p class="error">{errors().message}</p></Show>
</label>
<div class="hero-actions">
<button class="btn primary" type="submit" disabled={!canSubmit()}>Send message</button>
<button class="btn" type="button" onClick={() => { setValues(initialValues); setErrors({}); }}>Reset</button>
</div>
</form>
<aside class="card glass-dark contact-side-card">
<h3>Contact details</h3>
<p class="sub contact-detail"><span class="contact-icon"><IconMail /></span>support@nxtgauge.com</p>
<p class="sub contact-detail"><span class="contact-icon"><IconClock /></span>Typically within 2448 hours</p>
<p class="sub contact-detail"><span class="contact-icon"><IconPin /></span>Remote-first, India</p>
<div class="hero-actions">
<A class="btn" href="/about">About Us</A>
<A class="btn" href="/#faqs">FAQs</A>
</div>
</aside>
</div>
</div>
</section>
<section class="public-section scene-light">
<div class="container">
<h2 class="center">Common Questions</h2>
<p class="center sub">Quick clarity before you raise a ticket.</p>
<div class="contact-mini-faq-grid">
<article class="contact-mini-faq-card"><h3>Approval time</h3><p>Most profile and listing approvals are completed in 2448 hours.</p></article>
<article class="contact-mini-faq-card"><h3>Verification</h3><p>Verification is required to reduce spam and improve trust.</p></article>
<article class="contact-mini-faq-card"><h3>Posting flow</h3><p>You can submit multiple requirements and jobs after onboarding.</p></article>
</div>
</div>
</section>
<Show when={submitted()}>
<div style={{ position: 'fixed', right: '16px', top: '88px', 'z-index': 90, padding: '10px 14px', 'border-radius': '12px', border: '1px solid rgba(255,255,255,0.25)', background: 'rgba(16,11,47,0.88)', color: 'white', 'font-weight': 700 }}>
Message sent. We'll reply soon.
</div>
</Show>
<Show when={showBackToTop()}>
<button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
</button>
</Show>
</div>
</main>
);
}

View file

@ -0,0 +1,87 @@
import { A, useParams } from '@solidjs/router';
import { getArticleBySlug } from '~/lib/help-center';
import PublicHeader from '~/components/PublicHeader';
function categoryTitle(input: string) {
return input
.split('-')
.filter(Boolean)
.map((chunk) => chunk[0].toUpperCase() + chunk.slice(1))
.join(' ');
}
export default function HelpCenterArticlePage() {
const params = useParams();
const article = getArticleBySlug(params.slug || '');
if (!article) {
return (
<main class="lp-main">
<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>
<div class="lp-content">
<PublicHeader />
<section class="public-section scene-light">
<div class="container panel panel-light">
<h1 class="title">Article not found</h1>
<p class="subtitle">The requested Help Center article is unavailable.</p>
<div class="actions">
<A class="btn primary" href="/help-center">Back to Help Center</A>
</div>
</div>
</section>
</div>
</main>
);
}
return (
<main class="lp-main">
<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>
<div class="lp-content">
<PublicHeader />
<section class="public-section scene-light">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<p class="eyebrow">{categoryTitle(article.categoryKey)}</p>
<h1 class="title">{article.title}</h1>
<p class="subtitle">{article.summary}</p>
<div class="help-article-tags" style={{ 'margin-top': '10px' }}>
{article.tags.map((tag) => <span class="help-article-tag">{tag}</span>)}
</div>
<p class="note">Updated {new Date(article.updatedAt).toLocaleDateString()}</p>
<div class="help-article-body">
<p>{article.content}</p>
</div>
<div class="actions">
<A class="btn" href="/help-center">Back to Help Center</A>
<A class="btn primary" href="/auth/register?intent=customer&redirect=/users/onboarding/customer">Get Started</A>
</div>
</div>
</section>
<section class="public-section scene-light">
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
<h2>Need more help?</h2>
<p class="sub">If this article does not solve your issue, send your question with context to support.</p>
<div class="actions">
<a class="btn primary" href="mailto:support@nxtgauge.com?subject=Nxtgauge%20Help%20Center%20Question">Email support</a>
<A class="btn" href="/help-center">Browse more articles</A>
</div>
</div>
</section>
</div>
</main>
);
}

View file

@ -1,30 +1,5 @@
import { A } from '@solidjs/router';
const articles = [
{ slug: 'how-verification-works', title: 'How verification works' },
{ slug: 'how-to-post-a-requirement', title: 'How to post a requirement' },
{ slug: 'profile-approval-timeline', title: 'Profile approval timeline' },
];
import SupportPage from '~/routes/support/index';
export default function HelpCenterPage() {
return (
<main class="page marketing-page">
<section class="card glass-dark">
<p class="eyebrow">Help Center</p>
<h1 class="title light">Knowledge base and support guides</h1>
<p class="subtitle light">Find quick answers for onboarding, verification, and account workflows.</p>
</section>
<section class="card glass-light" style={{ 'margin-top': '14px' }}>
<h3>Popular Articles</h3>
<div class="faq-list">
{articles.map((item) => (
<A class="faq-item" href={`/support/article/${item.slug}`}>
<span class="faq-q"><span>{item.title}</span><span></span></span>
</A>
))}
</div>
</section>
</main>
);
return <SupportPage />;
}

View file

@ -1,14 +1,5 @@
import { A } from '@solidjs/router';
import PublicLanding from '~/components/PublicLanding';
export default function HirePage() {
return (
<main class="page marketing-page">
<section class="card glass-dark">
<p class="eyebrow">For Customers</p>
<h1 class="title light">Hire verified professionals in Chennai</h1>
<p class="subtitle light">Start with category selection, share requirements, and track verified responses.</p>
<A class="btn primary" href="/onboarding?schemaId=customer_onboarding_v1">Start Customer Onboarding</A>
</section>
</main>
);
return <PublicLanding />;
}

View file

@ -1,14 +1,5 @@
import { A } from '@solidjs/router';
import PublicLanding from '~/components/PublicLanding';
export default function JobSeekerLandingPage() {
return (
<main class="page marketing-page">
<section class="card glass-dark">
<p class="eyebrow">For Job Seekers</p>
<h1 class="title light">Onboard once and apply with confidence</h1>
<p class="subtitle light">Complete profile, experience, and identity verification to access opportunities.</p>
<A class="btn primary" href="/onboarding?schemaId=jobseeker_onboarding_v1">Start Jobseeker Onboarding</A>
</section>
</main>
);
export default function JobSeekerPage() {
return <PublicLanding />;
}

View file

@ -1,6 +1,5 @@
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import { ensureSeededRuntimeConfig, getRuntimeOnboardingSchema, saveOnboardingSubmission } from '~/lib/runtime/storage';
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeVisibilityCondition, UploadedFileMeta } from '~/lib/runtime/types';
function evaluateVisibility(conditions: RuntimeVisibilityCondition[] | undefined, values: Record<string, unknown>) {
@ -21,10 +20,7 @@ function isEmptyValue(value: unknown) {
}
function validateField(field: RuntimeOnboardingField, value: unknown): string | null {
if (field.required && isEmptyValue(value)) {
return `${field.label} is required.`;
}
if (field.required && isEmptyValue(value)) return `${field.label} is required.`;
if (isEmptyValue(value)) return null;
if (field.type === 'number') {
@ -41,9 +37,8 @@ function validateField(field: RuntimeOnboardingField, value: unknown): string |
if (typeof field.validation?.maxLength === 'number' && value.length > field.validation.maxLength) {
return `${field.label} must be at most ${field.validation.maxLength} characters.`;
}
if (field.validation?.pattern) {
const regex = new RegExp(field.validation.pattern);
if (!regex.test(value)) return `${field.label} format is invalid.`;
if (field.validation?.pattern && !new RegExp(field.validation.pattern).test(value)) {
return `${field.label} format is invalid.`;
}
}
@ -57,6 +52,88 @@ function validateField(field: RuntimeOnboardingField, value: unknown): string |
return null;
}
function normalizeRoleKey(value: string | null | undefined) {
return String(value || '')
.trim()
.toUpperCase()
.replace(/[-\s]+/g, '_');
}
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 '';
}
function normalizeSchemaPayload(payload: any, schemaId: string, roleKey: string): RuntimeOnboardingConfig | null {
const root = payload?.data || payload || {};
const schemaJson = root?.schemaJson || root;
const rawSteps = Array.isArray(schemaJson?.steps) ? schemaJson.steps : [];
const steps = rawSteps.map((step: any) => {
const stepId = String(step?.id || '');
let visibleWhen = Array.isArray(step?.visibleWhen) ? step.visibleWhen : undefined;
// Backend schema currently encodes customer profession-specific steps by id suffix.
const match = stepId.match(/^customer_(requirements|budget)_([a-z_]+)$/);
if (match) {
visibleWhen = [{ field: 'profession', equals: match[2] }];
}
const fields = Array.isArray(step?.fields) ? step.fields.map((field: any) => {
const rawType = String(field?.type || 'text').toLowerCase();
const type = rawType === 'upload' ? 'file' : rawType;
const options = Array.isArray(field?.options)
? field.options.map((option: any) =>
typeof option === 'string'
? { label: option, value: option }
: { label: String(option?.label || option?.value || ''), value: String(option?.value || option?.label || '') },
)
: undefined;
const validation = {
...(typeof field?.minLength === 'number' ? { minLength: field.minLength } : {}),
...(typeof field?.maxLength === 'number' ? { maxLength: field.maxLength } : {}),
...(typeof field?.min === 'number' ? { min: field.min } : {}),
...(typeof field?.max === 'number' ? { max: field.max } : {}),
...(field?.pattern ? { pattern: String(field.pattern) } : {}),
...(field?.validation && typeof field.validation === 'object' ? field.validation : {}),
};
return {
...field,
type,
options,
readOnly: Boolean(field?.readOnly ?? field?.readonly),
validation: Object.keys(validation).length > 0 ? validation : undefined,
};
}) : [];
return {
...step,
fields,
...(visibleWhen ? { visibleWhen } : {}),
};
});
if (!Array.isArray(steps) || steps.length === 0) return null;
return {
schemaId: String(root?.schemaId || schemaJson?.schemaId || schemaId),
roleKey: String(schemaJson?.roleKey || roleKey || 'CUSTOMER'),
version: Math.max(1, Number(schemaJson?.version || 1)),
steps,
};
}
export default function OnboardingPage() {
const [searchParams] = useSearchParams();
const [schema, setSchema] = createSignal<RuntimeOnboardingConfig | null>(null);
@ -65,30 +142,68 @@ export default function OnboardingPage() {
const [stepIndex, setStepIndex] = createSignal(0);
const [statusMessage, setStatusMessage] = createSignal('');
const [submitted, setSubmitted] = createSignal(false);
const [loading, setLoading] = createSignal(true);
const [profileStatus, setProfileStatus] = createSignal<string>('');
onMount(() => {
ensureSeededRuntimeConfig();
const requestedRoleKey = createMemo(() => normalizeRoleKey(searchParams.roleKey || ''));
const requestedProfession = createMemo(() => String(searchParams.profession || '').trim());
const requestedSchemaId = createMemo(() => {
const fromQuery = String(searchParams.schemaId || '').trim();
if (fromQuery) return fromQuery;
return schemaIdFromInput(requestedRoleKey(), requestedProfession());
});
const schemaId = searchParams.schemaId;
const roleKey = searchParams.roleKey;
const loaded = getRuntimeOnboardingSchema({ schemaId, roleKey });
setSchema(loaded);
onMount(async () => {
try {
setLoading(true);
const schemaId = requestedSchemaId();
if (!schemaId) {
setStatusMessage('Missing schemaId/roleKey. Unable to load runtime onboarding schema.');
return;
}
if (!loaded) return;
const schemaResponse = await fetch(`/api/runtime/onboarding/schema?${new URLSearchParams({ schemaId }).toString()}`);
const schemaPayload = await schemaResponse.json().catch(() => ({}));
if (!schemaResponse.ok || !schemaPayload?.success) {
setStatusMessage(schemaPayload?.error || 'Unable to load onboarding schema from backend.');
return;
}
const initialValues: Record<string, unknown> = {};
loaded.steps.forEach((step) => {
step.fields.forEach((field) => {
if (field.defaultValue !== undefined) {
initialValues[field.id] = field.defaultValue;
} else if (field.multiple) {
initialValues[field.id] = field.type === 'file' ? [] : [];
} else {
initialValues[field.id] = field.type === 'checkbox' ? false : '';
}
const normalized = normalizeSchemaPayload(schemaPayload, schemaId, requestedRoleKey() || 'CUSTOMER');
if (!normalized) {
setStatusMessage('Schema loaded but steps are missing.');
return;
}
setSchema(normalized);
const initialValues: Record<string, unknown> = {};
normalized.steps.forEach((step) => {
step.fields.forEach((field) => {
if (field.defaultValue !== undefined) initialValues[field.id] = field.defaultValue;
else if (field.multiple) initialValues[field.id] = [];
else initialValues[field.id] = field.type === 'checkbox' ? false : '';
});
});
});
setValues(initialValues);
setValues(initialValues);
const stateResponse = await fetch(`/api/runtime/onboarding/state?${new URLSearchParams({ roleKey: normalized.roleKey }).toString()}`);
const statePayload = await stateResponse.json().catch(() => ({}));
if (stateResponse.ok && statePayload?.success && statePayload?.data) {
const currentStep = Math.max(0, Number(statePayload.data.currentStep || 0));
if (currentStep > 0) setStepIndex(Math.min(currentStep, Math.max(0, normalized.steps.length - 1)));
const status = String(statePayload.data.status || '').toUpperCase();
if (['SUBMITTED', 'COMPLETED', 'APPROVED'].includes(status)) setSubmitted(true);
}
const profileResponse = await fetch('/api/runtime/profile-status');
const profilePayload = await profileResponse.json().catch(() => ({}));
if (profileResponse.ok && profilePayload?.success) {
setProfileStatus(String(profilePayload?.data?.profileStatus || ''));
}
} finally {
setLoading(false);
}
});
const visibleSteps = createMemo(() => {
@ -98,7 +213,6 @@ export default function OnboardingPage() {
});
const activeStep = createMemo(() => visibleSteps()[stepIndex()] || null);
const visibleFields = createMemo(() => {
const step = activeStep();
if (!step) return [];
@ -131,15 +245,32 @@ export default function OnboardingPage() {
return Object.keys(nextErrors).length === 0;
};
const goNext = () => {
const syncProgress = async (nextStep: number) => {
const currentSchema = schema();
if (!currentSchema) return;
await fetch('/api/runtime/onboarding/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
roleKey: currentSchema.roleKey,
currentStep: nextStep,
totalSteps: visibleSteps().length,
dataJson: values(),
}),
}).catch(() => {});
};
const goNext = async () => {
if (!validateCurrentStep()) {
setStatusMessage('Please fix the highlighted fields.');
return;
}
const total = visibleSteps().length;
if (stepIndex() < total - 1) {
setStepIndex(stepIndex() + 1);
const next = stepIndex() + 1;
setStepIndex(next);
setStatusMessage('');
await syncProgress(next);
}
};
@ -147,7 +278,7 @@ export default function OnboardingPage() {
if (stepIndex() > 0) setStepIndex(stepIndex() - 1);
};
const submit = () => {
const submit = async () => {
if (!validateCurrentStep()) {
setStatusMessage('Please fix the highlighted fields.');
return;
@ -155,17 +286,26 @@ export default function OnboardingPage() {
const currentSchema = schema();
if (!currentSchema) return;
saveOnboardingSubmission({
schemaId: currentSchema.schemaId,
roleKey: currentSchema.roleKey,
values: values(),
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(),
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
setStatusMessage(payload?.error || 'Failed to submit onboarding.');
return;
}
setSubmitted(true);
setStatusMessage('');
};
const renderField = (field: RuntimeOnboardingField) => {
const value = values()[field.id];
const error = errors()[field.id];
if (field.type === 'textarea') {
return (
@ -250,7 +390,7 @@ export default function OnboardingPage() {
const maxSizeBytes = (field.maxSizeMB || 2) * 1024 * 1024;
const oversized = selected.find((file) => file.size > maxSizeBytes);
if (oversized) {
setErrors((prev) => ({ ...prev, [field.id]: `\"${oversized.name}\" exceeds ${(field.maxSizeMB || 2)}MB.` }));
setErrors((prev) => ({ ...prev, [field.id]: `"${oversized.name}" exceeds ${(field.maxSizeMB || 2)}MB.` }));
return;
}
@ -299,44 +439,61 @@ export default function OnboardingPage() {
return (
<main class="page">
<h1 class="title">Runtime Onboarding</h1>
<p class="subtitle">Schema-driven form only. No hardcoded questions fallback.</p>
<p class="subtitle">Schema-driven and backend-connected flow.</p>
<Show when={profileStatus()}><p class="note">Profile status: {profileStatus()}</p></Show>
<Show when={schema()} fallback={<section class="card"><p>No onboarding schema found in runtime config. Publish one in admin builder or use `schemaId` query.</p></section>}>
<Show when={!submitted()} fallback={<section class="card"><h2>Verification in Progress</h2><p class="subtitle">Your documents have been submitted. Please wait for 24-48 hours for profile approval.</p></section>}>
<div class="grid">
<section class="card">
<span class="step-pill">Step {progressText()}</span>
<h2>{activeStep()?.title}</h2>
<p class="subtitle">{activeStep()?.description || 'Fill required details and continue.'}</p>
<Show when={!loading()} fallback={<section class="card"><p>Loading runtime onboarding schema...</p></section>}>
<Show
when={schema()}
fallback={<section class="card"><p>{statusMessage() || 'No onboarding schema available for this role yet.'}</p></section>}
>
<Show
when={!submitted()}
fallback={
<section class="card">
<h2>Verification in Progress</h2>
<p class="subtitle">Your documents have been submitted. Please wait for 24-48 hours for profile approval.</p>
</section>
}
>
<div class="grid">
<section class="card">
<span class="step-pill">Step {progressText()}</span>
<h2>{activeStep()?.title}</h2>
<p class="subtitle">{activeStep()?.description || 'Fill required details and continue.'}</p>
<For each={visibleFields()}>
{(field) => (
<div class="field">
<label class="label">{field.label}{field.required ? ' *' : ''}</label>
{renderField(field)}
<Show when={field.helperText}><p class="note">{field.helperText}</p></Show>
<Show when={errors()[field.id]}><p class="error">{errors()[field.id]}</p></Show>
</div>
)}
</For>
<For each={visibleFields()}>
{(field) => (
<div class="field">
<label class="label">
{field.label}
{field.required ? ' *' : ''}
</label>
{renderField(field)}
<Show when={field.helperText}><p class="note">{field.helperText}</p></Show>
<Show when={errors()[field.id]}><p class="error">{errors()[field.id]}</p></Show>
</div>
)}
</For>
<div class="actions">
<button class="btn" disabled={stepIndex() === 0} onClick={goBack}>Back</button>
<Show
when={stepIndex() < visibleSteps().length - 1}
fallback={<button class="btn primary" onClick={submit}>Submit</button>}
>
<button class="btn primary" onClick={goNext}>Next</button>
</Show>
</div>
<Show when={statusMessage()}><p class="error">{statusMessage()}</p></Show>
</section>
<div class="actions">
<button class="btn" disabled={stepIndex() === 0} onClick={goBack}>Back</button>
<Show
when={stepIndex() < visibleSteps().length - 1}
fallback={<button class="btn primary" onClick={submit}>Submit</button>}
>
<button class="btn primary" onClick={goNext}>Next</button>
</Show>
</div>
<Show when={statusMessage()}><p class="error">{statusMessage()}</p></Show>
</section>
<section class="card">
<h3>Runtime Schema</h3>
<pre class="preview">{JSON.stringify(schema(), null, 2)}</pre>
</section>
</div>
<section class="card">
<h3>Runtime Schema</h3>
<pre class="preview">{JSON.stringify(schema(), null, 2)}</pre>
</section>
</div>
</Show>
</Show>
</Show>
</main>

View file

@ -1,14 +1,5 @@
import { A } from '@solidjs/router';
import PublicLanding from '~/components/PublicLanding';
export default function ProfessionalsPage() {
return (
<main class="page marketing-page">
<section class="card glass-dark">
<p class="eyebrow">For Professionals</p>
<h1 class="title light">Build your verified professional profile</h1>
<p class="subtitle light">Upload portfolio (up to 6), submit PDF identity docs, and unlock lead flow.</p>
<A class="btn primary" href="/onboarding?schemaId=professional_onboarding_v1">Start Professional Onboarding</A>
</section>
</main>
);
return <PublicLanding />;
}

5
src/routes/sign-in.tsx Normal file
View file

@ -0,0 +1,5 @@
import LoginPage from '~/routes/auth/login/index';
export default function SignInAliasPage() {
return <LoginPage />;
}

5
src/routes/sign-up.tsx Normal file
View file

@ -0,0 +1,5 @@
import RegisterPage from '~/routes/auth/register/index';
export default function SignUpAliasPage() {
return <RegisterPage />;
}

5
src/routes/signin.tsx Normal file
View file

@ -0,0 +1,5 @@
import LoginPage from '~/routes/auth/login/index';
export default function SigninAliasPage() {
return <LoginPage />;
}

5
src/routes/signup.tsx Normal file
View file

@ -0,0 +1,5 @@
import RegisterPage from '~/routes/auth/register/index';
export default function SignupAliasPage() {
return <RegisterPage />;
}

View file

@ -1,17 +1,161 @@
import { A } from '@solidjs/router';
import { A, useSearchParams } from '@solidjs/router';
import { For, createMemo } from 'solid-js';
import { listHelpCenterArticles, listHelpCenterCategories } from '~/lib/help-center';
import PublicHeader from '~/components/PublicHeader';
const ROLE_LABELS: Record<string, string> = {
ALL: 'All roles',
company: 'Company',
jobSeeker: 'Job Seeker',
professional: 'Professional',
customer: 'Customer',
platform: 'Platform',
};
function categoryTitle(input: string) {
return input
.split('-')
.filter(Boolean)
.map((chunk) => chunk[0].toUpperCase() + chunk.slice(1))
.join(' ');
}
export default function SupportPage() {
const [search] = useSearchParams();
const role = createMemo(() => String(search.role || 'ALL'));
const category = createMemo(() => String(search.category || ''));
const q = createMemo(() => String(search.q || ''));
const categories = createMemo(() => listHelpCenterCategories());
const articles = createMemo(() =>
listHelpCenterArticles({ role: role(), categoryKey: category() || undefined, q: q() || undefined }),
);
const visibleCategories = createMemo(() => {
if (categories().length > 0) return categories();
const seen = new Set<string>();
return articles()
.filter((item) => {
if (seen.has(item.categoryKey)) return false;
seen.add(item.categoryKey);
return true;
})
.map((item, idx) => ({ id: `derived-${idx + 1}`, key: item.categoryKey, title: categoryTitle(item.categoryKey) }));
});
return (
<main class="page marketing-page">
<section class="card glass-dark">
<p class="eyebrow">Support</p>
<h1 class="title light">Need help with your account?</h1>
<p class="subtitle light">Open help guides or contact support directly for faster resolution.</p>
<div class="actions">
<A class="btn primary" href="/contact">Contact Support</A>
<A class="btn ghost-dark" href="/help-center">Open Help Center</A>
</div>
</section>
<main class="lp-main">
<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>
<div class="lp-content">
<PublicHeader />
<section class="public-section help-section-lg">
<div class="container panel panel-light help-hero-panel">
<p class="eyebrow">Help Center</p>
<h1 class="title">Get answers quickly</h1>
<p class="subtitle">
Articles are loaded at runtime from published Help Center management content so public users always see the latest approved guidance.
</p>
<form method="GET" action="/help-center" class="help-search-grid">
<input class="input" name="q" value={q()} placeholder="Search help articles" />
<select class="select" name="role" value={role()}>
<For each={Object.entries(ROLE_LABELS)}>
{(entry) => <option value={entry[0]}>{entry[1]}</option>}
</For>
</select>
<input type="hidden" name="category" value={category()} />
<button class="lp-primary-btn help-search-btn" type="submit">Search Help Center</button>
</form>
<div class="help-category-head">
<p class="help-category-kicker">Categories</p>
{category() && (
<A
class="help-clear-filter"
href={`/help-center?${new URLSearchParams({ ...(role() !== 'ALL' ? { role: role() } : {}), ...(q() ? { q: q() } : {}) }).toString()}`}
>
Clear category filter
</A>
)}
</div>
<div class="help-category-row">
<For each={visibleCategories()}>
{(cat) => (
<A
class={`help-category-pill ${category() === cat.key ? 'help-category-pill-active' : ''}`}
href={`/help-center?${new URLSearchParams({ ...(role() !== 'ALL' ? { role: role() } : {}), ...(q() ? { q: q() } : {}), category: cat.key }).toString()}`}
>
{cat.title}
</A>
)}
</For>
</div>
</div>
</section>
<section class="public-section help-solid-section help-section-mid">
<div class="container">
<div class="split help-article-list">
<For each={articles()}>
{(article) => (
<article class="help-article-card">
<p class="note">{categoryTitle(article.categoryKey)}</p>
<h3>
<A class="help-article-link" href={`/help-center/article/${article.slug}`}>{article.title}</A>
</h3>
<p class="help-article-summary">{article.summary}</p>
<div class="help-article-tags">
<For each={article.tags}>
{(tag) => <span class="help-article-tag">{tag}</span>}
</For>
</div>
<div class="help-article-meta">
<span>Updated {new Date(article.updatedAt).toLocaleDateString()}</span>
<A class="help-read-link" href={`/help-center/article/${article.slug}`}>Read article</A>
</div>
</article>
)}
</For>
{articles().length === 0 && <article class="help-empty-card">No Help Center articles matched your filters.</article>}
</div>
</div>
</section>
<section class="public-section help-section-lg">
<div class="container panel panel-light cta-row help-cta-panel">
<div>
<p class="eyebrow">Still have questions?</p>
<h2>Ask the support team</h2>
<p class="sub">Share your role, what you tried, and which article you checked so support can respond faster.</p>
</div>
<div class="hero-actions">
<a class="lp-primary-btn" href="mailto:support@nxtgauge.com?subject=Nxtgauge%20Help%20Center%20Question">Email support</a>
<A class="lp-ghost-btn" href="/contact">Contact page</A>
</div>
</div>
</section>
<footer class="public-footer">
<div class="container footer-row">
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
<p>© {new Date().getFullYear()} Nxtgauge. All rights reserved.</p>
<div class="footer-links">
<A href="/terms">Terms</A>
<A href="/privacy">Privacy</A>
<A href="/help-center">Help Center</A>
</div>
</div>
</footer>
</div>
</main>
);
}

View file

@ -0,0 +1,18 @@
import { onMount } from 'solid-js';
import { useNavigate, useSearchParams } from '@solidjs/router';
export default function CompanyOnboardingRoute() {
const navigate = useNavigate();
const [search] = useSearchParams();
onMount(() => {
const params = new URLSearchParams({
roleKey: 'COMPANY',
schemaId: 'company_onboarding_v1',
});
if (search.intent) params.set('intent', String(search.intent));
navigate(`/onboarding?${params.toString()}`, { replace: true });
});
return null;
}

View file

@ -0,0 +1,18 @@
import { onMount } from 'solid-js';
import { useNavigate, useSearchParams } from '@solidjs/router';
export default function CustomerOnboardingRoute() {
const navigate = useNavigate();
const [search] = useSearchParams();
onMount(() => {
const params = new URLSearchParams({
roleKey: 'CUSTOMER',
schemaId: 'customer_onboarding_v1',
});
if (search.intent) params.set('intent', String(search.intent));
navigate(`/onboarding?${params.toString()}`, { replace: true });
});
return null;
}

View file

@ -0,0 +1,18 @@
import { onMount } from 'solid-js';
import { useNavigate, useSearchParams } from '@solidjs/router';
export default function JobSeekerOnboardingRoute() {
const navigate = useNavigate();
const [search] = useSearchParams();
onMount(() => {
const params = new URLSearchParams({
roleKey: 'JOB_SEEKER',
schemaId: 'jobseeker_onboarding_v1',
});
if (search.intent) params.set('intent', String(search.intent));
navigate(`/onboarding?${params.toString()}`, { replace: true });
});
return null;
}

View file

@ -0,0 +1,12 @@
import { onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
export default function JobSeekerAliasRoute() {
const navigate = useNavigate();
onMount(() => {
navigate('/users/onboarding/job-seeker', { replace: true });
});
return null;
}

View file

@ -0,0 +1,29 @@
import { onMount } from 'solid-js';
import { useNavigate, useSearchParams } from '@solidjs/router';
function normalizeProfession(value: string | null | undefined) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[-\s]+/g, '_')
.replace(/[^a-z_]/g, '');
}
export default function ProfessionalOnboardingRoute() {
const navigate = useNavigate();
const [search] = useSearchParams();
onMount(() => {
const profession = normalizeProfession(search.profession || search.role || null);
const schemaId = profession ? `${profession}_onboarding_v1` : 'professional_onboarding_v1';
const params = new URLSearchParams({
roleKey: 'PROFESSIONAL',
schemaId,
});
if (profession) params.set('profession', profession);
if (search.intent) params.set('intent', String(search.intent));
navigate(`/onboarding?${params.toString()}`, { replace: true });
});
return null;
}