feat: align solid public pages and auth flows with nextjs parity
BIN
public/images/about-team.png
Normal file
|
After Width: | Height: | Size: 685 KiB |
12
public/images/abstract-business.svg
Normal 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 |
12
public/images/abstract-creative.svg
Normal 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 |
10
public/images/abstract-dev.svg
Normal 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 |
10
public/images/abstract-photo.svg
Normal 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 |
13
public/images/abstract-tutor.svg
Normal 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
|
After Width: | Height: | Size: 339 KiB |
BIN
public/images/auth-company-1.jpg
Normal file
|
After Width: | Height: | Size: 423 KiB |
BIN
public/images/auth-company-2.jpg
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
public/images/expert-1.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/images/expert-2.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
public/images/expert-3.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
public/images/expert-4.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/images/fallback-pfp.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/images/hero-left.png
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
BIN
public/images/hero-right.png
Normal file
|
After Width: | Height: | Size: 4 MiB |
BIN
public/images/how-it-works.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/images/howITWorks.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
public/images/landing-hero.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
public/images/landing-hero1.png
Normal file
|
After Width: | Height: | Size: 5.5 MiB |
BIN
public/images/landing-servie-women.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/images/photo.png
Normal file
|
After Width: | Height: | Size: 939 KiB |
BIN
public/images/photographer.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
public/images/study.png
Normal file
|
After Width: | Height: | Size: 472 KiB |
BIN
public/images/tutor.png
Normal file
|
After Width: | Height: | Size: 493 KiB |
3052
src/app.css
204
src/components/OpportunityGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/PublicHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
76
src/lib/server/email-verification-store.ts
Normal 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
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 didn’t 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 isn’t finding options.
|
||||
</h3>
|
||||
<p
|
||||
class="about-problem-body"
|
||||
style={{
|
||||
opacity: chapterOneBodyIn(),
|
||||
filter: `blur(${(1 - chapterOneBodyIn()) * 3}px)`,
|
||||
}}
|
||||
>
|
||||
It’s knowing which one deserves your time.
|
||||
<br />
|
||||
Scrolling. Comparing. Second-guessing. Starting over.
|
||||
<br />
|
||||
The real cost isn’t money.
|
||||
<br />
|
||||
It’s 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 didn’t 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
38
src/routes/api/runtime/auth-visuals.ts
Normal 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' },
|
||||
},
|
||||
);
|
||||
}
|
||||
44
src/routes/api/runtime/onboarding/complete.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
48
src/routes/api/runtime/onboarding/progress.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
41
src/routes/api/runtime/onboarding/schema.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
39
src/routes/api/runtime/onboarding/state.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/routes/api/runtime/profile-status.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway';
|
||||
|
||||
export async function GET({ request }: { request: Request }) {
|
||||
try {
|
||||
const upstream = await fetch(gatewayUrl('/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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
39
src/routes/api/users/auth/forgot-password.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
56
src/routes/api/users/auth/login.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
55
src/routes/api/users/auth/register.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
44
src/routes/api/users/auth/reset-password.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
103
src/routes/api/users/auth/verification/request-code.ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/routes/api/users/auth/verify.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
176
src/routes/auth/forgot-password/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/routes/auth/login-verification/index.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import VerificationPage from '~/routes/auth/verification/index';
|
||||
|
||||
export default function LoginVerificationPage() {
|
||||
return <VerificationPage />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
278
src/routes/auth/verification/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 — we’ll 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'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 24–48 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 — we’ll 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 24–48 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 24–48 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
87
src/routes/help-center/article/[slug].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
import RegisterPage from '~/routes/auth/register/index';
|
||||
|
||||
export default function SignUpAliasPage() {
|
||||
return <RegisterPage />;
|
||||
}
|
||||
5
src/routes/signin.tsx
Normal 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
|
|
@ -0,0 +1,5 @@
|
|||
import RegisterPage from '~/routes/auth/register/index';
|
||||
|
||||
export default function SignupAliasPage() {
|
||||
return <RegisterPage />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
18
src/routes/users/onboarding/company.tsx
Normal 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;
|
||||
}
|
||||
18
src/routes/users/onboarding/customer.tsx
Normal 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;
|
||||
}
|
||||
18
src/routes/users/onboarding/job-seeker.tsx
Normal 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;
|
||||
}
|
||||
12
src/routes/users/onboarding/jobseeker.tsx
Normal 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;
|
||||
}
|
||||
29
src/routes/users/onboarding/professional.tsx
Normal 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;
|
||||
}
|
||||