-
+
+
+
+
Quick Actions
Ready to get started?
Pick your next action and continue with the correct role flow.
-
@@ -565,14 +728,17 @@ export default function PublicLanding() {
© {new Date().getFullYear()} Nxtgauge. All rights reserved.
+
+ window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' })}>
+ ↑
+
+
);
diff --git a/src/entry-client.tsx b/src/entry-client.tsx
index 7dac7a5..667cae5 100644
--- a/src/entry-client.tsx
+++ b/src/entry-client.tsx
@@ -1,4 +1,4 @@
// @refresh reload
import { mount, StartClient } from '@solidjs/start/client';
-mount(() =>
, document.getElementById('app')!);
+export default mount(() =>
, document.getElementById('app')!);
diff --git a/src/lib/auth-intent.ts b/src/lib/auth-intent.ts
new file mode 100644
index 0000000..837fe7d
--- /dev/null
+++ b/src/lib/auth-intent.ts
@@ -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));
+}
diff --git a/src/lib/help-center.ts b/src/lib/help-center.ts
new file mode 100644
index 0000000..88f5454
--- /dev/null
+++ b/src/lib/help-center.ts
@@ -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
();
+ 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;
+}
diff --git a/src/lib/server/email-verification-store.ts b/src/lib/server/email-verification-store.ts
new file mode 100644
index 0000000..e27e163
--- /dev/null
+++ b/src/lib/server/email-verification-store.ts
@@ -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();
+
+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 };
+}
diff --git a/src/lib/server/gateway.ts b/src/lib/server/gateway.ts
new file mode 100644
index 0000000..de6af14
--- /dev/null
+++ b/src/lib/server/gateway.ts
@@ -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) {
+ const token = readAccessTokenFromRequest(request);
+ const headers: Record = { ...(extra || {}) };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ return headers;
+}
diff --git a/src/lib/server/smtp.ts b/src/lib/server/smtp.ts
new file mode 100644
index 0000000..e8840c1
--- /dev/null
+++ b/src/lib/server/smtp.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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();
+ }
+}
diff --git a/src/routes/about.tsx b/src/routes/about.tsx
index 955e05a..bfda4a8 100644
--- a/src/routes/about.tsx
+++ b/src/routes/about.tsx
@@ -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 (
-
-
- Brand Story
- Trust-first hiring and services.
-
- Nxtgauge is built to reduce noise, improve quality, and help people connect with confidence.
-
-
-
+ const [showBackToTop, setShowBackToTop] = createSignal(false);
+ const [reduceMotion, setReduceMotion] = createSignal(false);
+ const [activeChapter, setActiveChapter] = createSignal(0);
+ const [scrollY, setScrollY] = createSignal(0);
-
-
- Our Manifesto
-
- Verify what matters
- Reduce spam and guesswork
- Match people faster
+ 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 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
Brand Story
+
Trust-first hiring and services.
+
+ Nxtgauge is built to reduce noise, improve quality, and help people connect with confidence.
+
+
+
+
+
+ Our manifesto
+
+ Verify what matters
+ Reduce spam and guesswork
+ Match people faster
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Chapter 01
+ The Problem
+
+ The hardest part isn’t finding options.
+
+
+ It’s knowing which one deserves your time.
+
+ Scrolling. Comparing. Second-guessing. Starting over.
+
+ The real cost isn’t money.
+
+ It’s momentum.
+
+
+
+
+
+
+
+
+ Chapter 02
+ What We Built
+
+
+
We wanted to reduce hesitation.
+
+ Not by adding more choices.
+
+ But by designing fewer, stronger ones.
+
+ Nxtgauge is built around one idea:
+
+ Confidence should come before commitment.
+
+
+
+
+
+
+
+
+
+
+
+ Chapter 03
+ The Trust Layer
+ Most reviews complete within 24-48 hours.
+
+
+
+ {(step, idx) => (
+ = (idx() / trustSequence.length) * 0.85 ? 1 : 0.3,
+ transform: effectiveTrustProgress() >= (idx() / trustSequence.length) * 0.85 ? 'translate3d(0,0,0)' : 'translate3d(0,10px,0)',
+ }}
+ >
+
+
+
+
+
+
{step.num}
+
{step.title}
+
{step.body}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Chapter 04
+ Principles
+
+
+ We didn’t build another marketplace.
+
+
+
A review layer.
+
Profiles. Jobs. Requirements.
+
+
+ Clarity replaces noise.
+
+ }
+ >
+
+
+
+
+ {(line, idx) => (
+
+
+ {idx() === 1 ? (
+ <>
+ We built a filter .
+ >
+ ) : (
+ line
+ )}
+
+
+
+
+
+ <>
+ Profiles. Jobs. Requirements.
+
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
Chapter 05
+
Timeline
+
+
+
+ {(milestone, index) => (
+
+ {index() + 1}
+ {milestone.title}
+ {milestone.body}
+
+ )}
+
+
+
+
+
+
+
+
+
+
Have a question or want to partner?
+
+
+
+
+
+
+ window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' })}>
+ ↑
+
+
+
);
}
diff --git a/src/routes/api/runtime/auth-visuals.ts b/src/routes/api/runtime/auth-visuals.ts
new file mode 100644
index 0000000..9a546fd
--- /dev/null
+++ b/src/routes/api/runtime/auth-visuals.ts
@@ -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' },
+ },
+ );
+}
diff --git a/src/routes/api/runtime/onboarding/complete.ts b/src/routes/api/runtime/onboarding/complete.ts
new file mode 100644
index 0000000..362fc08
--- /dev/null
+++ b/src/routes/api/runtime/onboarding/complete.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/runtime/onboarding/progress.ts b/src/routes/api/runtime/onboarding/progress.ts
new file mode 100644
index 0000000..602d79b
--- /dev/null
+++ b/src/routes/api/runtime/onboarding/progress.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/runtime/onboarding/schema.ts b/src/routes/api/runtime/onboarding/schema.ts
new file mode 100644
index 0000000..d5d5202
--- /dev/null
+++ b/src/routes/api/runtime/onboarding/schema.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/runtime/onboarding/state.ts b/src/routes/api/runtime/onboarding/state.ts
new file mode 100644
index 0000000..b7fa0ad
--- /dev/null
+++ b/src/routes/api/runtime/onboarding/state.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/runtime/profile-status.ts b/src/routes/api/runtime/profile-status.ts
new file mode 100644
index 0000000..f6d2710
--- /dev/null
+++ b/src/routes/api/runtime/profile-status.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/users/auth/forgot-password.ts b/src/routes/api/users/auth/forgot-password.ts
new file mode 100644
index 0000000..ccfa8d5
--- /dev/null
+++ b/src/routes/api/users/auth/forgot-password.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/users/auth/login.ts b/src/routes/api/users/auth/login.ts
new file mode 100644
index 0000000..ae2f41e
--- /dev/null
+++ b/src/routes/api/users/auth/login.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/users/auth/register.ts b/src/routes/api/users/auth/register.ts
new file mode 100644
index 0000000..8def506
--- /dev/null
+++ b/src/routes/api/users/auth/register.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/users/auth/reset-password.ts b/src/routes/api/users/auth/reset-password.ts
new file mode 100644
index 0000000..e9f596b
--- /dev/null
+++ b/src/routes/api/users/auth/reset-password.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/api/users/auth/verification/request-code.ts b/src/routes/api/users/auth/verification/request-code.ts
new file mode 100644
index 0000000..9c35cdd
--- /dev/null
+++ b/src/routes/api/users/auth/verification/request-code.ts
@@ -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' } },
+ );
+ }
+}
diff --git a/src/routes/api/users/auth/verify.ts b/src/routes/api/users/auth/verify.ts
new file mode 100644
index 0000000..df1a522
--- /dev/null
+++ b/src/routes/api/users/auth/verify.ts
@@ -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' },
+ });
+ }
+}
diff --git a/src/routes/auth/forgot-password/index.tsx b/src/routes/auth/forgot-password/index.tsx
new file mode 100644
index 0000000..553b5f3
--- /dev/null
+++ b/src/routes/auth/forgot-password/index.tsx
@@ -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 (
+
-
- Login
- Auth parity page scaffolded. Final auth integration to be connected in next migration stage.
-
-
+
+
+
+
+
+
+
+
+
+
+
{visual().tag}
+
{visual().title}
+
{visual().subtitle}
+
+
+
+
+
);
}
diff --git a/src/routes/auth/register/index.tsx b/src/routes/auth/register/index.tsx
index 0d3d203..7a117c8 100644
--- a/src/routes/auth/register/index.tsx
+++ b/src/routes/auth/register/index.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+ );
+}
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 (
-
-
- Register
- Auth parity page scaffolded. Final OTP/signup integration to be connected in next migration stage.
-
-
+
+
+
+
+
+
+
+
+
+
+
{visual().tag}
+
{visual().title}
+
{visual().subtitle}
+
+
+
+
+
);
}
diff --git a/src/routes/auth/verification/index.tsx b/src/routes/auth/verification/index.tsx
new file mode 100644
index 0000000..81490fa
--- /dev/null
+++ b/src/routes/auth/verification/index.tsx
@@ -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 | undefined;
+ const otpRefs: Array = [];
+
+ 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 (
+
+
+
+
+
+ );
+}
diff --git a/src/routes/companies/index.tsx b/src/routes/companies/index.tsx
index bdcdeb5..b24fb5a 100644
--- a/src/routes/companies/index.tsx
+++ b/src/routes/companies/index.tsx
@@ -1,14 +1,5 @@
-import { A } from '@solidjs/router';
+import PublicLanding from '~/components/PublicLanding';
-export default function CompaniesLandingPage() {
- return (
-
-
- For Companies
- Post jobs with verified company identity
- Complete company onboarding and manage recruitment workflows with trust signals.
- Start Company Onboarding
-
-
- );
+export default function CompaniesPage() {
+ return ;
}
diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx
index f770f10..23680e0 100644
--- a/src/routes/contact.tsx
+++ b/src/routes/contact.tsx
@@ -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>;
+
+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 (
+
+
+
+
+ );
+}
+
+function IconClock() {
+ return (
+
+
+
+
+ );
+}
+
+function IconPin() {
+ return (
+
+
+
+
+ );
+}
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(initialValues);
+ const [errors, setErrors] = createSignal({});
+ 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 = (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 (
-
-
- Reach Out
- Contact us
- Tell us what you need — we’ll get back to you.
-
+
+
-
-
+
+
-
-
+
+
+
+
+
+
{
+ event.preventDefault();
+ const nextErrors = validate(values());
+ setErrors(nextErrors);
+ if (Object.keys(nextErrors).length > 0) return;
+
+ setSubmitted(true);
+ setValues(initialValues);
+ window.setTimeout(() => setSubmitted(false), 3200);
+ }}
+ >
+
+
+
+
+ Phone
+ update('phone', e.currentTarget.value)} />
+
+
+ User Type *
+ update('userType', e.currentTarget.value)}>
+ Select user type
+ {userTypes.map((type) => (
+ {type}
+ ))}
+
+ {errors().userType}
+
+
+
+
+
+ Topic *
+ update('topic', e.currentTarget.value)}>
+ Select topic
+ {topics.map((topic) => (
+ {topic}
+ ))}
+
+ {errors().topic}
+
+
+ Attachment
+
+ ⌁
+ {values().attachment ? values().attachment.name : 'Upload pdf/png/jpg (max 10MB)'}
+ update('attachment', e.currentTarget.files?.[0] ?? null)}
+ />
+
+ {errors().attachment}
+
+
+
+
+ Message *
+ update('message', e.currentTarget.value)} />
+ {errors().message}
+
+
+
+ Send message
+ { setValues(initialValues); setErrors({}); }}>Reset
+
+
+
+
+
+
+
+
+
+
+
Common Questions
+
Quick clarity before you raise a ticket.
+
+
+
+
+
+
+ Message sent. We'll reply soon.
+
+
+
+
+ window.scrollTo({ top: 0, behavior: 'smooth' })}>
+ ↑
+
+
+
);
}
diff --git a/src/routes/help-center/article/[slug].tsx b/src/routes/help-center/article/[slug].tsx
new file mode 100644
index 0000000..527fa87
--- /dev/null
+++ b/src/routes/help-center/article/[slug].tsx
@@ -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 (
+
+
+
+
+
+
+
Article not found
+
The requested Help Center article is unavailable.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
{categoryTitle(article.categoryKey)}
+
{article.title}
+
{article.summary}
+
+
+ {article.tags.map((tag) => {tag} )}
+
+
Updated {new Date(article.updatedAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+
Need more help?
+
If this article does not solve your issue, send your question with context to support.
+
+
+
+
+
+ );
+}
diff --git a/src/routes/help-center/index.tsx b/src/routes/help-center/index.tsx
index d7deda8..6e3c528 100644
--- a/src/routes/help-center/index.tsx
+++ b/src/routes/help-center/index.tsx
@@ -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 (
-
-
- Help Center
- Knowledge base and support guides
- Find quick answers for onboarding, verification, and account workflows.
-
-
-
-
- );
+ return ;
}
diff --git a/src/routes/hire.tsx b/src/routes/hire.tsx
index 8b74e54..3ab92ca 100644
--- a/src/routes/hire.tsx
+++ b/src/routes/hire.tsx
@@ -1,14 +1,5 @@
-import { A } from '@solidjs/router';
+import PublicLanding from '~/components/PublicLanding';
export default function HirePage() {
- return (
-
-
- For Customers
- Hire verified professionals in Chennai
- Start with category selection, share requirements, and track verified responses.
- Start Customer Onboarding
-
-
- );
+ return ;
}
diff --git a/src/routes/job-seeker/index.tsx b/src/routes/job-seeker/index.tsx
index 7e0fb2c..9d3a46b 100644
--- a/src/routes/job-seeker/index.tsx
+++ b/src/routes/job-seeker/index.tsx
@@ -1,14 +1,5 @@
-import { A } from '@solidjs/router';
+import PublicLanding from '~/components/PublicLanding';
-export default function JobSeekerLandingPage() {
- return (
-
-
- For Job Seekers
- Onboard once and apply with confidence
- Complete profile, experience, and identity verification to access opportunities.
- Start Jobseeker Onboarding
-
-
- );
+export default function JobSeekerPage() {
+ return ;
}
diff --git a/src/routes/onboarding.tsx b/src/routes/onboarding.tsx
index 0f9659c..ef53436 100644
--- a/src/routes/onboarding.tsx
+++ b/src/routes/onboarding.tsx
@@ -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) {
@@ -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(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('');
- 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 = {};
- 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 = {};
+ 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 (
Runtime Onboarding
- Schema-driven form only. No hardcoded questions fallback.
+ Schema-driven and backend-connected flow.
+ Profile status: {profileStatus()}
- No onboarding schema found in runtime config. Publish one in admin builder or use `schemaId` query.