fix: update forms to match DB schema - first_name/last_name, role keys

This commit is contained in:
Tracewebstudio Dev 2026-04-15 06:23:28 +02:00
parent c2cbafc159
commit 5922b98c93
12 changed files with 1348 additions and 650 deletions

0
frontend-solid.dev.log Normal file
View file

1
frontend-solid.dev.pid Normal file
View file

@ -0,0 +1 @@
7995

View file

@ -16,10 +16,9 @@ type RequirementItem = {
title: string; title: string;
description?: string | null; description?: string | null;
status?: string; status?: string;
budget_min?: number | null; budget_inr?: number | null;
budget_max?: number | null;
area?: string | null; area?: string | null;
city?: string | null; location?: string | null;
created_at?: string; created_at?: string;
}; };
@ -44,7 +43,7 @@ export default function CustomerRequirementsPage() {
budget_min: "", budget_min: "",
budget_max: "", budget_max: "",
area: "", area: "",
city: "", location: "",
}); });
const loadRequirements = async () => { const loadRequirements = async () => {
@ -79,10 +78,12 @@ export default function CustomerRequirementsPage() {
const payload = { const payload = {
title: form().title.trim(), title: form().title.trim(),
description: form().description.trim() || undefined, description: form().description.trim() || undefined,
budget_min: form().budget_min ? Number(form().budget_min) : undefined, budget_inr:
budget_max: form().budget_max ? Number(form().budget_max) : undefined, form().budget_min || form().budget_max
? Number(form().budget_min) || Number(form().budget_max)
: undefined,
area: form().area.trim() || undefined, area: form().area.trim() || undefined,
city: form().city.trim() || undefined, location: form().city.trim() || undefined,
}; };
const res = await apiFetch("/api/customers/requirements", { const res = await apiFetch("/api/customers/requirements", {
method: "POST", method: "POST",
@ -94,7 +95,14 @@ export default function CustomerRequirementsPage() {
return; return;
} }
setMsg("Requirement created."); setMsg("Requirement created.");
setForm({ title: "", description: "", budget_min: "", budget_max: "", area: "", city: "" }); setForm({
title: "",
description: "",
budget_min: "",
budget_max: "",
area: "",
location: "",
});
await loadRequirements(); await loadRequirements();
} catch { } catch {
setErr("Network error while creating requirement."); setErr("Network error while creating requirement.");
@ -193,10 +201,10 @@ export default function CustomerRequirementsPage() {
/> />
</div> </div>
<div> <div>
<label style={LABEL}>City</label> <label style={LABEL}>Location</label>
<input <input
value={form().city} value={form().location}
onInput={(e) => setField("city", e.currentTarget.value)} onInput={(e) => setField("location", e.currentTarget.value)}
style={INPUT} style={INPUT}
placeholder="Chennai" placeholder="Chennai"
/> />
@ -303,7 +311,7 @@ export default function CustomerRequirementsPage() {
{row.title} {row.title}
</p> </p>
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}> <p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
{row.city || "—"} {row.area ? `${row.area}` : ""}{" "} {row.location || row.city || "—"} {row.area ? `${row.area}` : ""}{" "}
{row.created_at {row.created_at
? `${new Date(row.created_at).toLocaleString("en-IN")}` ? `${new Date(row.created_at).toLocaleString("en-IN")}`
: ""} : ""}

View file

@ -57,6 +57,26 @@ const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = {
skills: '', skills: '',
}; };
const JOB_SEEKER_FALLBACK_TABS = ['About', 'Education', 'Work Experience', 'Skills']; const JOB_SEEKER_FALLBACK_TABS = ['About', 'Education', 'Work Experience', 'Skills'];
const PROFESSIONAL_PORTFOLIO_TABS: Record<string, string[]> = {
DEVELOPER: ['About', 'Services & Pricing', 'Projects', 'Tech Stack & Experience', 'Testimonials', 'FAQs'],
default: ['About', 'Services & Pricing', 'Projects', 'Experience', 'Testimonials', 'FAQs'],
};
type ProfessionalPortfolioState = {
about: string;
services: string;
experience: string;
testimonials: string;
faqs: string;
};
const EMPTY_PROFESSIONAL_FORM: ProfessionalPortfolioState = {
about: '',
services: '',
experience: '',
testimonials: '',
faqs: '',
};
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@ -95,6 +115,9 @@ export default function PortfolioPage(props: Props) {
const [jobSeekerSaving, setJobSeekerSaving] = createSignal(false); const [jobSeekerSaving, setJobSeekerSaving] = createSignal(false);
const [jobSeekerMsg, setJobSeekerMsg] = createSignal(''); const [jobSeekerMsg, setJobSeekerMsg] = createSignal('');
const [jobSeekerErr, setJobSeekerErr] = createSignal(''); const [jobSeekerErr, setJobSeekerErr] = createSignal('');
const [professionalTab, setProfessionalTab] = createSignal('About');
const [professionalForm, setProfessionalForm] = createSignal<ProfessionalPortfolioState>({ ...EMPTY_PROFESSIONAL_FORM });
const [professionalMsg, setProfessionalMsg] = createSignal('');
const loadItems = async () => { const loadItems = async () => {
if (!isProfessional()) { setLoading(false); return; } if (!isProfessional()) { setLoading(false); return; }
@ -196,6 +219,54 @@ export default function PortfolioPage(props: Props) {
return grouped; return grouped;
}; };
const professionalTabs = () => {
const runtimeRaw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : [];
const fromRuntime = runtimeRaw
.map((tab) => toLabel(tab))
.filter(Boolean)
.map((tab) => {
const t = normalizeToken(tab);
if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About';
if (t.includes('service') || t.includes('pricing') || t.includes('package')) return 'Services & Pricing';
if (t.includes('project') || t.includes('portfolio') || t.includes('gallery') || t.includes('showreel')) return 'Projects';
if (t.includes('stack') || t.includes('experience') || t.includes('qualification') || t.includes('tool')) return props.roleKey === 'DEVELOPER' ? 'Tech Stack & Experience' : 'Experience';
if (t.includes('testimonial') || t.includes('review')) return 'Testimonials';
if (t.includes('faq') || t.includes('question')) return 'FAQs';
return '';
})
.filter(Boolean);
const uniqueRuntime = Array.from(new Set(fromRuntime));
if (uniqueRuntime.length >= 3) return uniqueRuntime;
return PROFESSIONAL_PORTFOLIO_TABS[props.roleKey] || PROFESSIONAL_PORTFOLIO_TABS.default;
};
const professionalFormStorageKey = () => `nxtgauge_portfolio_meta_${String(props.roleKey || 'professional').toLowerCase()}`;
const loadProfessionalForm = () => {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem(professionalFormStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw) as Partial<ProfessionalPortfolioState>;
setProfessionalForm({
about: String(parsed?.about || ''),
services: String(parsed?.services || ''),
experience: String(parsed?.experience || ''),
testimonials: String(parsed?.testimonials || ''),
faqs: String(parsed?.faqs || ''),
});
} catch {
// Ignore malformed local storage payloads.
}
};
const saveProfessionalForm = () => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(professionalFormStorageKey(), JSON.stringify(professionalForm()));
}
setProfessionalMsg('Portfolio section saved.');
window.setTimeout(() => setProfessionalMsg(''), 1800);
};
onMount(() => { onMount(() => {
if (isJobSeeker()) { if (isJobSeeker()) {
loadJobSeekerPortfolio(); loadJobSeekerPortfolio();
@ -204,6 +275,11 @@ export default function PortfolioPage(props: Props) {
setLoading(false); setLoading(false);
return; return;
} }
if (isProfessional()) {
const tabs = professionalTabs();
setProfessionalTab(tabs[0] || 'About');
loadProfessionalForm();
}
void loadItems(); void loadItems();
}); });
@ -418,188 +494,175 @@ export default function PortfolioPage(props: Props) {
); );
} }
const isProjectsTab = () => {
const key = normalizeToken(professionalTab());
return key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel');
};
const isServicesTab = () => {
const key = normalizeToken(professionalTab());
return key.includes('service') || key.includes('pricing') || key.includes('package');
};
const isTestimonialsTab = () => normalizeToken(professionalTab()).includes('testimonial');
const isFaqTab = () => normalizeToken(professionalTab()).includes('faq');
const sectionFieldKey = () => {
if (isServicesTab()) return 'services' as const;
if (isTestimonialsTab()) return 'testimonials' as const;
if (isFaqTab()) return 'faqs' as const;
const key = normalizeToken(professionalTab());
if (key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification')) return 'experience' as const;
return 'about' as const;
};
const sectionPlaceholder = () => {
if (isServicesTab()) return 'List your plans, pricing slabs, and deliverables...';
if (isTestimonialsTab()) return 'Add client quotes and project outcomes...';
if (isFaqTab()) return 'Add common questions and answers...';
if (sectionFieldKey() === 'experience') return 'Share stack, years of experience, and toolchain...';
return 'Write a short summary about your profile and strengths...';
};
return ( return (
<div style={{ 'max-width': '800px' }}> <div style={{ 'max-width': '800px' }}>
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '0 16px' }}>
{/* ── Header ────────────────────────────────────────────────────── */} <div style={{ display: 'flex', gap: '20px', 'border-bottom': '1px solid #E5E7EB', padding: '12px 0 0', 'flex-wrap': 'wrap' }}>
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'margin-bottom': '16px' }}> <For each={professionalTabs()}>
{(tab) => (
<button
type="button"
onClick={() => setProfessionalTab(tab)}
style={{
padding: '0 0 10px',
border: 'none',
background: 'none',
cursor: 'pointer',
'font-size': '13px',
'font-weight': professionalTab() === tab ? '700' : '500',
color: professionalTab() === tab ? '#FF5E13' : '#6B7280',
'border-bottom': professionalTab() === tab ? '2px solid #FF5E13' : '2px solid transparent',
'margin-bottom': '-1px',
}}
>
{tab}
</button>
)}
</For>
</div>
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'padding': '14px 0' }}>
<div> <div>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p> <p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}> <p style={{ margin: '6px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Showcase your work to attract clients. Runtime-config driven tab layout aligned with external dashboard preview.
</p> </p>
</div> </div>
<Show when={isProjectsTab()}>
<button type="button" onClick={openCreate} style={BTN_ORANGE}> <button type="button" onClick={openCreate} style={BTN_ORANGE}>
+ Add Item + Add Item
</button> </button>
</Show>
</div>
</div> </div>
{/* ── Create / Edit form ─────────────────────────────────────────── */} <Show
when={isProjectsTab()}
fallback={
<div style={{ ...CARD, display: 'grid', gap: '12px' }}>
<Show when={professionalMsg()}>
<div style={{ border: '1px solid #BBF7D0', background: '#ECFDF5', color: '#065F46', 'border-radius': '10px', padding: '10px 12px', 'font-size': '12px', 'font-weight': '600' }}>
{professionalMsg()}
</div>
</Show>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>{professionalTab()}</p>
<div>
<label style={LABEL}>Section Content</label>
<textarea
rows={8}
value={professionalForm()[sectionFieldKey()]}
onInput={(e) => setProfessionalForm((prev) => ({ ...prev, [sectionFieldKey()]: e.currentTarget.value }))}
placeholder={sectionPlaceholder()}
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
/>
</div>
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
<button type="button" onClick={() => setProfessionalForm({ ...EMPTY_PROFESSIONAL_FORM })} style={BTN_GHOST}>
Clear
</button>
<button type="button" onClick={saveProfessionalForm} style={BTN_ORANGE}>
Save Section
</button>
</div>
</div>
}
>
<div>
<Show when={showForm()}> <Show when={showForm()}>
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}> <div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
<p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}> <p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}>
{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'} {editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}
</p> </p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}> <div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div style={{ 'grid-column': 'span 2' }}> <div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label> <label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label>
<input <input type="text" placeholder="e.g. Developer dashboard rebuild" value={form().title} onInput={(e) => setField('title', e.currentTarget.value)} style={INPUT} />
type="text"
placeholder="e.g. Wedding shoot at Udaipur"
value={form().title}
onInput={(e) => setField('title', e.currentTarget.value)}
style={INPUT}
/>
</div> </div>
<div style={{ 'grid-column': 'span 2' }}> <div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Description</label> <label style={LABEL}>Description</label>
<textarea <textarea rows={3} placeholder="Brief description of the project..." value={form().description} onInput={(e) => setField('description', e.currentTarget.value)} style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }} />
rows={3}
placeholder="Brief description of the work…"
value={form().description}
onInput={(e) => setField('description', e.currentTarget.value)}
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
/>
</div> </div>
<div style={{ 'grid-column': 'span 2' }}> <div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Tags (comma separated)</label> <label style={LABEL}>Tags (comma separated)</label>
<input <input type="text" placeholder="e.g. solidjs, rust, dashboard" value={form().tags} onInput={(e) => setField('tags', e.currentTarget.value)} style={INPUT} />
type="text"
placeholder="e.g. wedding, outdoor, portrait"
value={form().tags}
onInput={(e) => setField('tags', e.currentTarget.value)}
style={INPUT}
/>
</div> </div>
</div> </div>
<Show when={error()}> <Show when={error()}>
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}> <p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>{error()}</p>
{error()}
</p>
</Show> </Show>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '16px' }}> <div style={{ display: 'flex', gap: '10px', 'margin-top': '16px' }}>
<button <button type="button" onClick={handleSave} disabled={saving()} style={{ ...BTN_PRIMARY, opacity: saving() ? '0.6' : '1' }}>
type="button"
onClick={handleSave}
disabled={saving()}
style={{ ...BTN_PRIMARY, opacity: saving() ? '0.6' : '1' }}
>
{saving() ? 'Saving…' : editId() ? 'Update Item' : 'Add Item'} {saving() ? 'Saving…' : editId() ? 'Update Item' : 'Add Item'}
</button> </button>
<button type="button" onClick={cancelForm} style={BTN_GHOST}> <button type="button" onClick={cancelForm} style={BTN_GHOST}>Cancel</button>
Cancel
</button>
</div> </div>
</div> </div>
</Show> </Show>
{/* ── Loading ─────────────────────────────────────────────────────── */}
<Show when={loading()}> <Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}> <div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
Loading portfolio Loading portfolio
</div> </div>
</Show> </Show>
{/* ── Empty state ─────────────────────────────────────────────────── */}
<Show when={!loading() && items().length === 0 && !showForm()}> <Show when={!loading() && items().length === 0 && !showForm()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}> <div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
<p style={{ margin: '0', 'font-size': '40px' }}>🗂</p> <p style={{ margin: '0', 'font-size': '40px' }}>🗂</p>
<p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}> <p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>No portfolio items yet</p>
No portfolio items yet <p style={{ margin: '0 0 16px', 'font-size': '13px', color: '#6B7280' }}>Add your first work sample to attract clients.</p>
</p> <button type="button" onClick={openCreate} style={BTN_ORANGE}>+ Add First Item</button>
<p style={{ margin: '0 0 16px', 'font-size': '13px', color: '#6B7280' }}>
Add your first work sample to attract clients.
</p>
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
+ Add First Item
</button>
</div> </div>
</Show> </Show>
{/* ── Portfolio grid ─────────────────────────────────────────────── */}
<Show when={!loading() && items().length > 0}> <Show when={!loading() && items().length > 0}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}> <div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<For each={items()}> <For each={items()}>
{(item) => ( {(item) => (
<div style={{ <div style={{ ...CARD, padding: '16px', display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
...CARD, <div style={{ height: '120px', 'border-radius': '8px', background: '#F3F4F6', display: 'flex', 'align-items': 'center', 'justify-content': 'center', color: '#D1D5DB', 'font-size': '28px' }}>🖼</div>
padding: '16px', <p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>{item.title}</p>
display: 'flex',
'flex-direction': 'column',
gap: '8px',
}}>
{/* Placeholder image area */}
<div style={{
height: '120px',
'border-radius': '8px',
background: '#F3F4F6',
display: 'flex',
'align-items': 'center',
'justify-content': 'center',
color: '#D1D5DB',
'font-size': '28px',
}}>
🖼
</div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
{item.title}
</p>
<Show when={item.description}> <Show when={item.description}>
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}> <p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>{item.description}</p>
{item.description}
</p>
</Show> </Show>
<Show when={item.tags && item.tags.length > 0}> <Show when={item.tags && item.tags.length > 0}>
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}> <div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
<For each={item.tags}> <For each={item.tags}>
{(tag) => ( {(tag) => <span style={{ 'font-size': '10px', 'font-weight': '700', color: '#6B7280', background: '#F3F4F6', border: '1px solid #E5E7EB', 'border-radius': '6px', padding: '2px 8px' }}>{tag}</span>}
<span style={{
'font-size': '10px',
'font-weight': '700',
color: '#6B7280',
background: '#F3F4F6',
border: '1px solid #E5E7EB',
'border-radius': '6px',
padding: '2px 8px',
}}>
{tag}
</span>
)}
</For> </For>
</div> </div>
</Show> </Show>
<div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}> <div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}>
<button <button type="button" onClick={() => openEdit(item)} style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}>Edit</button>
type="button"
onClick={() => openEdit(item)}
style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}
>
Edit
</button>
<button <button
type="button" type="button"
onClick={() => handleDelete(item.id)} onClick={() => handleDelete(item.id)}
disabled={deleting() === item.id} disabled={deleting() === item.id}
style={{ style={{ height: '30px', 'border-radius': '8px', border: '1px solid #FECACA', background: '#fff', color: '#EF4444', 'font-size': '11px', 'font-weight': '700', padding: '0 12px', cursor: 'pointer', flex: '1', opacity: deleting() === item.id ? '0.6' : '1' }}
height: '30px',
'border-radius': '8px',
border: '1px solid #FECACA',
background: '#fff',
color: '#EF4444',
'font-size': '11px',
'font-weight': '700',
padding: '0 12px',
cursor: 'pointer',
flex: '1',
opacity: deleting() === item.id ? '0.6' : '1',
}}
> >
{deleting() === item.id ? '…' : 'Delete'} {deleting() === item.id ? '…' : 'Delete'}
</button> </button>
@ -610,5 +673,7 @@ export default function PortfolioPage(props: Props) {
</div> </div>
</Show> </Show>
</div> </div>
</Show>
</div>
); );
} }

View file

@ -3,65 +3,86 @@
* Supports all 13 roles. Tabs: Basic Info · Documents. * Supports all 13 roles. Tabs: Basic Info · Documents.
* User fills and saves freely; "Submit for Verification" locks and queues for admin. * User fills and saves freely; "Submit for Verification" locks and queues for admin.
*/ */
import { For, Match, Show, Switch, createEffect, createSignal, onMount } from "solid-js";
import { import {
For, Match, Show, Switch, createEffect, createSignal, onMount, CARD,
} from 'solid-js'; BTN_ORANGE,
import { CARD, BTN_ORANGE, BTN_GHOST, INPUT, LABEL, BTN_PRIMARY } from '~/components/DashboardShell'; BTN_GHOST,
INPUT,
LABEL,
BTN_PRIMARY,
} from "~/components/DashboardShell";
const API = '/api/gateway'; const API = "/api/gateway";
// ── Role-specific field definitions ────────────────────────────────────────── // ── Role-specific field definitions ──────────────────────────────────────────
const BASIC_FIELDS: Record<string, Array<{ key: string; label: string; type?: string; required?: boolean; options?: string[] }>> = { const BASIC_FIELDS: Record<
string,
Array<{ key: string; label: string; type?: string; required?: boolean; options?: string[] }>
> = {
default: [ default: [
{ key: 'first_name', label: 'First Name', required: true }, { key: "first_name", label: "First Name", required: true },
{ key: 'last_name', label: 'Last Name', required: true }, { key: "last_name", label: "Last Name", required: true },
{ key: 'phone', label: 'Mobile Number', required: true }, { key: "phone", label: "Mobile Number", required: true },
{ key: 'gender', label: 'Gender', type: 'select', options: ['Male', 'Female', 'Other', 'Prefer not to say'] }, {
{ key: 'city', label: 'City', required: true }, key: "gender",
{ key: 'state', label: 'State', required: true }, label: "Gender",
{ key: 'pin_code', label: 'PIN Code' }, type: "select",
{ key: 'address', label: 'Address', type: 'textarea' }, options: ["Male", "Female", "Other", "Prefer not to say"],
},
{ key: "location", label: "City", required: true },
{ key: "state", label: "State", required: true },
{ key: "pin_code", label: "PIN Code" },
{ key: "address", label: "Address", type: "textarea" },
], ],
COMPANY: [ COMPANY: [
{ key: 'company_name', label: 'Company Name', required: true }, { key: "company_name", label: "Company Name", required: true },
{ key: 'company_email', label: 'Company Email', type: 'email', required: true }, { key: "company_email", label: "Company Email", type: "email", required: true },
{ key: 'company_phone', label: 'Company Phone' }, { key: "company_phone", label: "Company Phone" },
{ key: 'website', label: 'Website URL', type: 'url' }, { key: "website", label: "Website URL", type: "url" },
{ key: 'city', label: 'City', required: true }, { key: "location", label: "City", required: true },
{ key: 'state', label: 'State', required: true }, { key: "state", label: "State", required: true },
{ key: 'pin_code', label: 'PIN Code' }, { key: "pin_code", label: "PIN Code" },
{ key: 'address', label: 'Registered Address', type: 'textarea' }, { key: "address", label: "Registered Address", type: "textarea" },
{ key: 'gst_number', label: 'GST Number (optional)' }, { key: "gst_number", label: "GST Number (optional)" },
], ],
PHOTOGRAPHER: [ PHOTOGRAPHER: [
{ key: 'first_name', label: 'First Name', required: true }, { key: 'first_name', label: 'First Name', required: true },
{ key: 'last_name', label: 'Last Name', required: true }, { key: 'last_name', label: 'Last Name', required: true },
{ key: 'phone', label: 'Mobile Number', required: true }, { key: 'phone', label: 'Mobile Number', required: true },
{ key: 'city', label: 'City', required: true }, { key: 'location', label: 'City', required: true },
{ key: 'state', label: 'State', required: true }, { key: 'state', label: 'State', required: true },
{ key: 'pin_code', label: 'PIN Code' }, { key: 'pin_code', label: 'PIN Code' },
{ key: 'speciality', label: 'Photography Speciality', type: 'select', { key: 'speciality', label: 'Photography Speciality', type: 'select',
options: ['Wedding', 'Portrait', 'Commercial', 'Event', 'Wildlife', 'Fashion', 'Product', 'Other'] }, options: ['Wedding', 'Portrait', 'Commercial', 'Event', 'Wildlife', 'Fashion', 'Product', 'Other'] },
{ key: 'experience_years', label: 'Years of Experience', type: 'number' }, { key: 'experience_years', label: 'Years of Experience', type: 'number' },
{ key: 'bio', label: 'Short Bio', type: 'textarea' }, { key: 'bio', label: 'Short Bio', type: 'textarea' },
],
},
{ key: "experience_years", label: "Years of Experience", type: "number" },
{ key: "bio", label: "Short Bio", type: "textarea" },
], ],
FITNESS_TRAINER: [ FITNESS_TRAINER: [
{ key: 'first_name', label: 'First Name', required: true }, { key: 'first_name', label: 'First Name', required: true },
{ key: 'last_name', label: 'Last Name', required: true }, { key: 'last_name', label: 'Last Name', required: true },
{ key: 'phone', label: 'Mobile Number', required: true }, { key: 'phone', label: 'Mobile Number', required: true },
{ key: 'city', label: 'City', required: true }, { key: 'location', label: 'City', required: true },
{ key: 'state', label: 'State', required: true }, { key: 'state', label: 'State', required: true },
{ key: 'training_type', label: 'Training Type', type: 'select', { key: 'training_type', label: 'Training Type', type: 'select',
options: ['Personal Training', 'Group Fitness', 'Yoga', 'CrossFit', 'Zumba', 'Pilates', 'Other'] }, options: ['Personal Training', 'Group Fitness', 'Yoga', 'CrossFit', 'Zumba', 'Pilates', 'Other'] },
{ key: 'experience_years', label: 'Years of Experience', type: 'number' }, { key: 'experience_years', label: 'Years of Experience', type: 'number' },
{ key: 'bio', label: 'Short Bio', type: 'textarea' }, { key: 'bio', label: 'Short Bio', type: 'textarea' },
],
},
{ key: "experience_years", label: "Years of Experience", type: "number" },
{ key: "bio", label: "Short Bio", type: "textarea" },
], ],
TUTOR: [ TUTOR: [
{ key: 'first_name', label: 'First Name', required: true }, { key: 'first_name', label: 'First Name', required: true },
{ key: 'last_name', label: 'Last Name', required: true }, { key: 'last_name', label: 'Last Name', required: true },
{ key: 'phone', label: 'Mobile Number', required: true }, { key: 'phone', label: 'Mobile Number', required: true },
{ key: 'city', label: 'City', required: true }, { key: 'location', label: 'City', required: true },
{ key: 'state', label: 'State', required: true }, { key: 'state', label: 'State', required: true },
{ key: 'subjects', label: 'Subjects Taught (comma separated)' }, { key: 'subjects', label: 'Subjects Taught (comma separated)' },
{ key: 'experience_years', label: 'Years of Experience', type: 'number' }, { key: 'experience_years', label: 'Years of Experience', type: 'number' },
@ -71,43 +92,103 @@ const BASIC_FIELDS: Record<string, Array<{ key: string; label: string; type?: st
{ key: 'business_name', label: 'Business Name', required: true }, { key: 'business_name', label: 'Business Name', required: true },
{ key: 'owner_name', label: 'Owner Name', required: true }, { key: 'owner_name', label: 'Owner Name', required: true },
{ key: 'phone', label: 'Contact Number', required: true }, { key: 'phone', label: 'Contact Number', required: true },
{ key: 'city', label: 'City', required: true }, { key: 'location', label: 'City', required: true },
{ key: 'state', label: 'State', required: true }, { key: 'state', label: 'State', required: true },
{ key: 'cuisine_types', label: 'Cuisine Types (comma separated)' }, { key: 'cuisine_types', label: 'Cuisine Types (comma separated)' },
{ key: 'bio', label: 'About Your Service', type: 'textarea' }, { key: 'bio', label: 'About Your Service', type: 'textarea' },
], ],
}; };
const DOC_FIELDS: Record<string, Array<{ key: string; label: string; required?: boolean; hint?: string }>> = { const DOC_FIELDS: Record<
string,
Array<{ key: string; label: string; required?: boolean; hint?: string }>
> = {
default: [ default: [
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, {
hint: 'JPG, PNG or PDF · Max 10MB' }, key: "aadhar_doc",
label: "Aadhar / Government ID",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
], ],
COMPANY: [ COMPANY: [
{ key: 'registration_doc', label: 'Company Registration Certificate', required: true, {
hint: 'JPG, PNG or PDF · Max 10MB' }, key: "registration_doc",
{ key: 'gst_doc', label: 'GST Certificate (optional)', label: "Company Registration Certificate",
hint: 'JPG, PNG or PDF · Max 10MB' }, required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{ key: "gst_doc", label: "GST Certificate (optional)", hint: "JPG, PNG or PDF · Max 10MB" },
], ],
PHOTOGRAPHER: [ PHOTOGRAPHER: [
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' }, {
{ key: 'sample_work', label: 'Sample Work Photos (23 images)', required: true, hint: 'JPG or PNG · Max 5MB each' }, key: "aadhar_doc",
label: "Aadhar / Government ID",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "sample_work",
label: "Sample Work Photos (23 images)",
required: true,
hint: "JPG or PNG · Max 5MB each",
},
], ],
MAKEUP_ARTIST: [ MAKEUP_ARTIST: [
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' }, {
{ key: 'sample_work', label: 'Sample Work Photos (23 images)', required: true, hint: 'JPG or PNG · Max 5MB each' }, key: "aadhar_doc",
label: "Aadhar / Government ID",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "sample_work",
label: "Sample Work Photos (23 images)",
required: true,
hint: "JPG or PNG · Max 5MB each",
},
], ],
TUTOR: [ TUTOR: [
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' }, {
{ key: 'degree_certificate', label: 'Degree Certificate', required: true, hint: 'JPG, PNG or PDF · Max 10MB' }, key: "aadhar_doc",
label: "Aadhar / Government ID",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "degree_certificate",
label: "Degree Certificate",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
], ],
FITNESS_TRAINER: [ FITNESS_TRAINER: [
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' }, {
{ key: 'certification_doc', label: 'Fitness Certification', required: true, hint: 'JPG, PNG or PDF · Max 10MB' }, key: "aadhar_doc",
label: "Aadhar / Government ID",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "certification_doc",
label: "Fitness Certification",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
], ],
CATERING_SERVICES: [ CATERING_SERVICES: [
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' }, {
{ key: 'fssai_license', label: 'FSSAI License', required: true, hint: 'JPG, PNG or PDF · Max 10MB' }, key: "aadhar_doc",
label: "Aadhar / Government ID",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
{
key: "fssai_license",
label: "FSSAI License",
required: true,
hint: "JPG, PNG or PDF · Max 10MB",
},
], ],
}; };
@ -123,8 +204,8 @@ function getDocFields(roleKey: string) {
async function apiFetch(path: string, opts?: RequestInit) { async function apiFetch(path: string, opts?: RequestInit) {
return fetch(`${API}${path}`, { return fetch(`${API}${path}`, {
...opts, ...opts,
credentials: 'include', credentials: "include",
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) }, headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
}); });
} }
@ -134,17 +215,17 @@ interface Props {
roleKey: string; roleKey: string;
} }
type Tab = 'basic' | 'documents'; type Tab = "basic" | "documents";
export default function ProfilePage(props: Props) { export default function ProfilePage(props: Props) {
const [tab, setTab] = createSignal<Tab>('basic'); const [tab, setTab] = createSignal<Tab>("basic");
const [form, setForm] = createSignal<Record<string, string>>({}); const [form, setForm] = createSignal<Record<string, string>>({});
const [saving, setSaving] = createSignal(false); const [saving, setSaving] = createSignal(false);
const [saveMsg, setSaveMsg] = createSignal(''); const [saveMsg, setSaveMsg] = createSignal("");
const [verificationStatus, setVerificationStatus] = createSignal('NOT_SUBMITTED'); const [verificationStatus, setVerificationStatus] = createSignal("NOT_SUBMITTED");
const [docRequest, setDocRequest] = createSignal<string | null>(null); const [docRequest, setDocRequest] = createSignal<string | null>(null);
const [submitting, setSubmitting] = createSignal(false); const [submitting, setSubmitting] = createSignal(false);
const [submitMsg, setSubmitMsg] = createSignal(''); const [submitMsg, setSubmitMsg] = createSignal("");
// Load saved profile + verification status on mount // Load saved profile + verification status on mount
onMount(async () => { onMount(async () => {
@ -155,10 +236,10 @@ export default function ProfilePage(props: Props) {
if (profileRes.ok) { if (profileRes.ok) {
const data = await profileRes.json(); const data = await profileRes.json();
if (data.profile_data && typeof data.profile_data === 'object') { if (data.profile_data && typeof data.profile_data === "object") {
const flat: Record<string, string> = {}; const flat: Record<string, string> = {};
for (const [k, v] of Object.entries(data.profile_data)) { for (const [k, v] of Object.entries(data.profile_data)) {
flat[k] = String(v ?? ''); flat[k] = String(v ?? "");
} }
setForm(flat); setForm(flat);
} }
@ -166,158 +247,165 @@ export default function ProfilePage(props: Props) {
if (statusRes.ok) { if (statusRes.ok) {
const s = await statusRes.json(); const s = await statusRes.json();
setVerificationStatus(s.status ?? 'NOT_SUBMITTED'); setVerificationStatus(s.status ?? "NOT_SUBMITTED");
setDocRequest(s.document_request ?? null); setDocRequest(s.document_request ?? null);
} }
}); });
const isLocked = () => const isLocked = () => ["PENDING", "UNDER_REVIEW"].includes(verificationStatus());
['PENDING', 'UNDER_REVIEW'].includes(verificationStatus());
const setField = (key: string, val: string) => const setField = (key: string, val: string) => setForm((prev) => ({ ...prev, [key]: val }));
setForm((prev) => ({ ...prev, [key]: val }));
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setSaveMsg(''); setSaveMsg("");
try { try {
const res = await apiFetch('/api/profile', { const res = await apiFetch("/api/profile", {
method: 'PATCH', method: "PATCH",
body: JSON.stringify({ roleKey: props.roleKey, profile_data: form() }), body: JSON.stringify({ roleKey: props.roleKey, profile_data: form() }),
}); });
setSaveMsg(res.ok ? 'Saved successfully.' : 'Failed to save. Please try again.'); setSaveMsg(res.ok ? "Saved successfully." : "Failed to save. Please try again.");
} catch { } catch {
setSaveMsg('Network error. Please try again.'); setSaveMsg("Network error. Please try again.");
} finally { } finally {
setSaving(false); setSaving(false);
setTimeout(() => setSaveMsg(''), 3000); setTimeout(() => setSaveMsg(""), 3000);
} }
}; };
const handleSubmitForVerification = async () => { const handleSubmitForVerification = async () => {
setSubmitting(true); setSubmitting(true);
setSubmitMsg(''); setSubmitMsg("");
try { try {
const res = await apiFetch('/api/profile/submit-for-verification', { const res = await apiFetch("/api/profile/submit-for-verification", {
method: 'POST', method: "POST",
body: JSON.stringify({ roleKey: props.roleKey }), body: JSON.stringify({ roleKey: props.roleKey }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
setVerificationStatus('PENDING'); setVerificationStatus("PENDING");
setSubmitMsg('Submitted! We will review your profile and notify you.'); setSubmitMsg("Submitted! We will review your profile and notify you.");
} else if (res.status === 409) { } else if (res.status === 409) {
setSubmitMsg(data.error ?? 'A verification is already in progress.'); setSubmitMsg(data.error ?? "A verification is already in progress.");
} else { } else {
setSubmitMsg(data.error ?? 'Submission failed. Please try again.'); setSubmitMsg(data.error ?? "Submission failed. Please try again.");
} }
} catch { } catch {
setSubmitMsg('Network error. Please try again.'); setSubmitMsg("Network error. Please try again.");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const statusColor: Record<string, string> = { const statusColor: Record<string, string> = {
PENDING: '#F59E0B', PENDING: "#F59E0B",
UNDER_REVIEW: '#3B82F6', UNDER_REVIEW: "#3B82F6",
DOCUMENTS_REQUESTED: '#FF5E13', DOCUMENTS_REQUESTED: "#FF5E13",
REVISION_REQUESTED: '#FF5E13', REVISION_REQUESTED: "#FF5E13",
APPROVED: '#10B981', APPROVED: "#10B981",
REJECTED: '#EF4444', REJECTED: "#EF4444",
NOT_SUBMITTED: '#9CA3AF', NOT_SUBMITTED: "#9CA3AF",
}; };
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
PENDING: 'Pending Review', PENDING: "Pending Review",
UNDER_REVIEW: 'Under Review', UNDER_REVIEW: "Under Review",
DOCUMENTS_REQUESTED: 'Documents Requested', DOCUMENTS_REQUESTED: "Documents Requested",
REVISION_REQUESTED: 'Revision Requested', REVISION_REQUESTED: "Revision Requested",
APPROVED: 'Approved', APPROVED: "Approved",
REJECTED: 'Rejected', REJECTED: "Rejected",
NOT_SUBMITTED: 'Not Submitted', NOT_SUBMITTED: "Not Submitted",
}; };
return ( return (
<div style={{ 'max-width': '760px' }}> <div style={{ "max-width": "760px" }}>
{/* ── Verification status banner ─────────────────────────────────── */} {/* ── Verification status banner ─────────────────────────────────── */}
<div style={{ <div
style={{
...CARD, ...CARD,
'margin-bottom': '16px', "margin-bottom": "16px",
display: 'flex', display: "flex",
'align-items': 'center', "align-items": "center",
'justify-content': 'space-between', "justify-content": "space-between",
gap: '12px', gap: "12px",
'flex-wrap': 'wrap', "flex-wrap": "wrap",
}}> }}
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}> >
<span style={{ <div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
display: 'inline-flex', <span
'align-items': 'center', style={{
height: '22px', display: "inline-flex",
padding: '0 10px', "align-items": "center",
'border-radius': '999px', height: "22px",
background: `${statusColor[verificationStatus()] ?? '#9CA3AF'}22`, padding: "0 10px",
color: statusColor[verificationStatus()] ?? '#9CA3AF', "border-radius": "999px",
'font-size': '11px', background: `${statusColor[verificationStatus()] ?? "#9CA3AF"}22`,
'font-weight': '700', color: statusColor[verificationStatus()] ?? "#9CA3AF",
}}> "font-size": "11px",
"font-weight": "700",
}}
>
{statusLabel[verificationStatus()] ?? verificationStatus()} {statusLabel[verificationStatus()] ?? verificationStatus()}
</span> </span>
<Show when={docRequest()}> <Show when={docRequest()}>
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}> <p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
<strong style={{ color: '#FF5E13' }}>Action needed:</strong> {docRequest()} <strong style={{ color: "#FF5E13" }}>Action needed:</strong> {docRequest()}
</p> </p>
</Show> </Show>
</div> </div>
<Show when={!isLocked() && verificationStatus() !== 'APPROVED'}> <Show when={!isLocked() && verificationStatus() !== "APPROVED"}>
<button <button
type="button" type="button"
onClick={handleSubmitForVerification} onClick={handleSubmitForVerification}
disabled={submitting()} disabled={submitting()}
style={{ ...BTN_ORANGE, opacity: submitting() ? '0.7' : '1' }} style={{ ...BTN_ORANGE, opacity: submitting() ? "0.7" : "1" }}
> >
{submitting() ? 'Submitting…' : 'Submit for Verification'} {submitting() ? "Submitting…" : "Submit for Verification"}
</button> </button>
</Show> </Show>
</div> </div>
<Show when={submitMsg()}> <Show when={submitMsg()}>
<div style={{ <div
style={{
...CARD, ...CARD,
'margin-bottom': '16px', "margin-bottom": "16px",
padding: '12px 16px', padding: "12px 16px",
background: submitMsg().includes('Submitted') ? '#ECFDF5' : '#FEF2F2', background: submitMsg().includes("Submitted") ? "#ECFDF5" : "#FEF2F2",
border: `1px solid ${submitMsg().includes('Submitted') ? '#6EE7B7' : '#FECACA'}`, border: `1px solid ${submitMsg().includes("Submitted") ? "#6EE7B7" : "#FECACA"}`,
color: submitMsg().includes('Submitted') ? '#065F46' : '#B91C1C', color: submitMsg().includes("Submitted") ? "#065F46" : "#B91C1C",
'font-size': '13px', "font-size": "13px",
'font-weight': '600', "font-weight": "600",
}}> }}
>
{submitMsg()} {submitMsg()}
</div> </div>
</Show> </Show>
{/* ── Tabs ──────────────────────────────────────────────────────── */} {/* ── Tabs ──────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', gap: '4px', 'margin-bottom': '16px' }}> <div style={{ display: "flex", gap: "4px", "margin-bottom": "16px" }}>
<For each={[ <For
{ key: 'basic', label: 'Basic Information' }, each={
{ key: 'documents', label: 'Documents' }, [
] as Array<{ key: Tab; label: string }>}> { key: "basic", label: "Basic Information" },
{ key: "documents", label: "Documents" },
] as Array<{ key: Tab; label: string }>
}
>
{(t) => ( {(t) => (
<button <button
type="button" type="button"
onClick={() => setTab(t.key)} onClick={() => setTab(t.key)}
style={{ style={{
height: '36px', height: "36px",
padding: '0 16px', padding: "0 16px",
'border-radius': '8px', "border-radius": "8px",
border: tab() === t.key ? '1px solid #FF5E13' : '1px solid #E5E7EB', border: tab() === t.key ? "1px solid #FF5E13" : "1px solid #E5E7EB",
background: tab() === t.key ? '#FFF3EE' : '#fff', background: tab() === t.key ? "#FFF3EE" : "#fff",
color: tab() === t.key ? '#FF5E13' : '#6B7280', color: tab() === t.key ? "#FF5E13" : "#6B7280",
'font-size': '13px', "font-size": "13px",
'font-weight': tab() === t.key ? '700' : '500', "font-weight": tab() === t.key ? "700" : "500",
cursor: 'pointer', cursor: "pointer",
}} }}
> >
{t.label} {t.label}
@ -329,41 +417,40 @@ export default function ProfilePage(props: Props) {
{/* ── Tab content ───────────────────────────────────────────────── */} {/* ── Tab content ───────────────────────────────────────────────── */}
<div style={CARD}> <div style={CARD}>
<Switch> <Switch>
{/* Basic Info */} {/* Basic Info */}
<Match when={tab() === 'basic'}> <Match when={tab() === "basic"}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '16px' }}> <div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "16px" }}>
<For each={getBasicFields(props.roleKey)}> <For each={getBasicFields(props.roleKey)}>
{(field) => ( {(field) => (
<div style={{ 'grid-column': field.type === 'textarea' ? 'span 2' : 'span 1' }}> <div style={{ "grid-column": field.type === "textarea" ? "span 2" : "span 1" }}>
<label style={LABEL}> <label style={LABEL}>
{field.label} {field.label}
<Show when={field.required}> <Show when={field.required}>
<span style={{ color: '#EF4444' }}> *</span> <span style={{ color: "#EF4444" }}> *</span>
</Show> </Show>
</label> </label>
<Switch> <Switch>
<Match when={field.type === 'textarea'}> <Match when={field.type === "textarea"}>
<textarea <textarea
rows={3} rows={3}
disabled={isLocked()} disabled={isLocked()}
value={form()[field.key] ?? ''} value={form()[field.key] ?? ""}
onInput={(e) => setField(field.key, e.currentTarget.value)} onInput={(e) => setField(field.key, e.currentTarget.value)}
style={{ style={{
...INPUT, ...INPUT,
height: 'auto', height: "auto",
padding: '10px 12px', padding: "10px 12px",
resize: 'vertical', resize: "vertical",
opacity: isLocked() ? '0.6' : '1', opacity: isLocked() ? "0.6" : "1",
}} }}
/> />
</Match> </Match>
<Match when={field.type === 'select'}> <Match when={field.type === "select"}>
<select <select
disabled={isLocked()} disabled={isLocked()}
value={form()[field.key] ?? ''} value={form()[field.key] ?? ""}
onChange={(e) => setField(field.key, e.currentTarget.value)} onChange={(e) => setField(field.key, e.currentTarget.value)}
style={{ ...INPUT, opacity: isLocked() ? '0.6' : '1' }} style={{ ...INPUT, opacity: isLocked() ? "0.6" : "1" }}
> >
<option value="">Select</option> <option value="">Select</option>
<For each={field.options ?? []}> <For each={field.options ?? []}>
@ -373,11 +460,11 @@ export default function ProfilePage(props: Props) {
</Match> </Match>
<Match when={true}> <Match when={true}>
<input <input
type={field.type ?? 'text'} type={field.type ?? "text"}
disabled={isLocked()} disabled={isLocked()}
value={form()[field.key] ?? ''} value={form()[field.key] ?? ""}
onInput={(e) => setField(field.key, e.currentTarget.value)} onInput={(e) => setField(field.key, e.currentTarget.value)}
style={{ ...INPUT, opacity: isLocked() ? '0.6' : '1' }} style={{ ...INPUT, opacity: isLocked() ? "0.6" : "1" }}
/> />
</Match> </Match>
</Switch> </Switch>
@ -387,35 +474,50 @@ export default function ProfilePage(props: Props) {
</div> </div>
<Show when={isLocked()}> <Show when={isLocked()}>
<p style={{ margin: '16px 0 0', 'font-size': '12px', color: '#9CA3AF' }}> <p style={{ margin: "16px 0 0", "font-size": "12px", color: "#9CA3AF" }}>
Profile is locked while verification is in progress. Profile is locked while verification is in progress.
</p> </p>
</Show> </Show>
</Match> </Match>
{/* Documents */} {/* Documents */}
<Match when={tab() === 'documents'}> <Match when={tab() === "documents"}>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}> <div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
<For each={getDocFields(props.roleKey)}> <For each={getDocFields(props.roleKey)}>
{(doc) => ( {(doc) => (
<div style={{ 'border': '1px dashed #E5E7EB', 'border-radius': '10px', padding: '16px' }}> <div
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}> style={{
border: "1px dashed #E5E7EB",
"border-radius": "10px",
padding: "16px",
}}
>
<p
style={{
margin: "0",
"font-size": "13px",
"font-weight": "700",
color: "#111827",
}}
>
{doc.label} {doc.label}
<Show when={doc.required}> <Show when={doc.required}>
<span style={{ color: '#EF4444' }}> *</span> <span style={{ color: "#EF4444" }}> *</span>
</Show> </Show>
</p> </p>
<Show when={doc.hint}> <Show when={doc.hint}>
<p style={{ margin: '2px 0 10px', 'font-size': '11px', color: '#9CA3AF' }}>{doc.hint}</p> <p style={{ margin: "2px 0 10px", "font-size": "11px", color: "#9CA3AF" }}>
{doc.hint}
</p>
</Show> </Show>
<Show <Show
when={form()[doc.key]} when={form()[doc.key]}
fallback={ fallback={
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}> <div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
<input <input
type="file" type="file"
id={`file-${doc.key}`} id={`file-${doc.key}`}
style={{ display: 'none' }} style={{ display: "none" }}
disabled={isLocked()} disabled={isLocked()}
onChange={(e) => { onChange={(e) => {
const file = e.currentTarget.files?.[0]; const file = e.currentTarget.files?.[0];
@ -426,31 +528,44 @@ export default function ProfilePage(props: Props) {
for={`file-${doc.key}`} for={`file-${doc.key}`}
style={{ style={{
...BTN_GHOST, ...BTN_GHOST,
display: 'inline-flex', display: "inline-flex",
'align-items': 'center', "align-items": "center",
'line-height': '1', "line-height": "1",
opacity: isLocked() ? '0.5' : '1', opacity: isLocked() ? "0.5" : "1",
cursor: isLocked() ? 'not-allowed' : 'pointer', cursor: isLocked() ? "not-allowed" : "pointer",
}} }}
> >
Choose File Choose File
</label> </label>
<span style={{ 'font-size': '12px', color: '#9CA3AF' }}>No file chosen</span> <span style={{ "font-size": "12px", color: "#9CA3AF" }}>
No file chosen
</span>
</div> </div>
} }
> >
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}> <div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
<span style={{ <span
'font-size': '12px', 'font-weight': '600', color: '#10B981', style={{
background: '#ECFDF5', padding: '4px 10px', 'border-radius': '6px', "font-size": "12px",
}}> "font-weight": "600",
color: "#10B981",
background: "#ECFDF5",
padding: "4px 10px",
"border-radius": "6px",
}}
>
{form()[doc.key]} {form()[doc.key]}
</span> </span>
<Show when={!isLocked()}> <Show when={!isLocked()}>
<button <button
type="button" type="button"
onClick={() => setField(doc.key, '')} onClick={() => setField(doc.key, "")}
style={{ ...BTN_GHOST, height: '28px', 'font-size': '11px', padding: '0 10px' }} style={{
...BTN_GHOST,
height: "28px",
"font-size": "11px",
padding: "0 10px",
}}
> >
Remove Remove
</button> </button>
@ -462,26 +577,27 @@ export default function ProfilePage(props: Props) {
</For> </For>
</div> </div>
</Match> </Match>
</Switch> </Switch>
</div> </div>
{/* ── Save button ─────────────────────────────────────────────── */} {/* ── Save button ─────────────────────────────────────────────── */}
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px', 'margin-top': '16px' }}> <div style={{ display: "flex", "align-items": "center", gap: "12px", "margin-top": "16px" }}>
<button <button
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={saving() || isLocked()} disabled={saving() || isLocked()}
style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? '0.6' : '1' }} style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? "0.6" : "1" }}
> >
{saving() ? 'Saving…' : 'Save Changes'} {saving() ? "Saving…" : "Save Changes"}
</button> </button>
<Show when={saveMsg()}> <Show when={saveMsg()}>
<span style={{ <span
'font-size': '13px', style={{
'font-weight': '600', "font-size": "13px",
color: saveMsg().includes('success') ? '#10B981' : '#EF4444', "font-weight": "600",
}}> color: saveMsg().includes("success") ? "#10B981" : "#EF4444",
}}
>
{saveMsg()} {saveMsg()}
</span> </span>
</Show> </Show>

View file

@ -35,6 +35,41 @@ function clearAuthStorage() {
localStorage.removeItem('nxtgauge_signup_profile_v1'); localStorage.removeItem('nxtgauge_signup_profile_v1');
} }
function normalizeRoleValue(value: unknown): string {
return String(value || '').trim().toUpperCase().replace(/\s+/g, '_');
}
function getStoredPreferredRole(emailHint?: string): string | null {
if (typeof window === 'undefined') return null;
const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user'];
for (const key of keys) {
const raw = window.localStorage.getItem(key);
if (!raw) continue;
try {
const parsed = JSON.parse(raw) as Record<string, any>;
const storedEmail = String(parsed?.email || '').trim().toLowerCase();
if (emailHint && storedEmail && storedEmail !== emailHint.trim().toLowerCase()) continue;
const selectedProfessionalRole = normalizeRoleValue(parsed?.selectedProfessionalRole);
if (selectedProfessionalRole) return selectedProfessionalRole;
const activeRole = normalizeRoleValue(parsed?.active_role || parsed?.role);
if (activeRole) return activeRole;
} catch {
// Ignore malformed local storage payloads.
}
}
return null;
}
function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string {
const backendRole = normalizeRoleValue(rawBackendRole);
if (backendRole) return backendRole;
const preferredRole = getStoredPreferredRole(emailHint);
if (preferredRole) return preferredRole;
return preferredRole || 'JOB_SEEKER';
}
async function fetchSession(): Promise<AuthUser | null> { async function fetchSession(): Promise<AuthUser | null> {
const token = getToken(); const token = getToken();
if (!token) return null; if (!token) return null;
@ -49,11 +84,12 @@ async function fetchSession(): Promise<AuthUser | null> {
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json(); const data = await res.json();
if (!data?.id && !data?.user_id) return null; if (!data?.id && !data?.user_id) return null;
const resolvedActiveRole = resolveActiveRole(data.active_role || data.role, data.email || '');
return { return {
id: data.id || data.user_id, id: data.id || data.user_id,
email: data.email || '', email: data.email || '',
full_name: data.full_name || data.name || '', full_name: data.full_name || data.name || '',
active_role: data.active_role || data.role || 'JOB_SEEKER', active_role: resolvedActiveRole,
email_verified: data.email_verified || false, email_verified: data.email_verified || false,
}; };
} catch { } catch {
@ -66,7 +102,7 @@ export function AuthProvider(props: ParentProps) {
const [session] = createResource(fetchSession); const [session] = createResource(fetchSession);
const isLoading = () => session.loading; const isLoading = () => session.loading;
const isAuthenticated = () => !!user() || (!!session() && !session.error); const isAuthenticated = () => !!user() || !!getToken() || (!!session() && !session.error);
if (session()) { if (session()) {
setUser(session() as AuthUser | null); setUser(session() as AuthUser | null);

View file

@ -33,7 +33,11 @@ async function proxyRequest(method: string, request: Request, params: any) {
pathArray = [pathArray]; pathArray = [pathArray];
} }
const path = `/${pathArray.join('/')}`; const rawPath = `/${pathArray.join('/')}`;
// Normalize all forwarded routes to the Rust gateway's /api/* contract.
const path = rawPath.startsWith('/api/') || rawPath === '/api'
? rawPath
: `/api${rawPath}`;
// Preserve query string // Preserve query string
const url = new URL(request.url); const url = new URL(request.url);

View file

@ -1,8 +1,8 @@
import { A } from '@solidjs/router'; import { A } from "@solidjs/router";
import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { Show, createMemo, createSignal, onCleanup, onMount } from "solid-js";
import PublicBackground from '~/components/PublicBackground'; import PublicBackground from "~/components/PublicBackground";
import PublicHeader from '~/components/PublicHeader'; import PublicHeader from "~/components/PublicHeader";
import PublicFooter from '~/components/PublicFooter'; import PublicFooter from "~/components/PublicFooter";
type FormValues = { type FormValues = {
fullName: string; fullName: string;
@ -17,31 +17,31 @@ type FormValues = {
type FormErrors = Partial<Record<keyof FormValues, string>>; type FormErrors = Partial<Record<keyof FormValues, string>>;
const initialValues: FormValues = { const initialValues: FormValues = {
fullName: '', fullName: "",
email: '', email: "",
phone: '', phone: "",
userType: '', userType: "",
topic: '', topic: "",
message: '', message: "",
attachment: null, attachment: null,
}; };
const userTypes = [ const userTypes = [
'Customer (Hire professional)', "Customer (Hire professional)",
'Company (Post job)', "Company (Post job)",
'Professional (Provide services)', "Professional (Provide services)",
'Job Seeker (Apply jobs)', "Job Seeker (Apply jobs)",
] as const; ] as const;
const topics = [ const topics = [
'Account & Login', "Account & Login",
'Verification', "Verification",
'Posting a Job', "Posting a Job",
'Posting a Requirement', "Posting a Requirement",
'Leads / Matching', "Leads / Matching",
'Payments / Credits', "Payments / Credits",
'Bug Report', "Bug Report",
'Other', "Other",
] as const; ] as const;
function IconMail() { function IconMail() {
@ -75,22 +75,27 @@ export default function ContactPage() {
const [values, setValues] = createSignal<FormValues>(initialValues); const [values, setValues] = createSignal<FormValues>(initialValues);
const [errors, setErrors] = createSignal<FormErrors>({}); const [errors, setErrors] = createSignal<FormErrors>({});
const [submitted, setSubmitted] = createSignal(false); const [submitted, setSubmitted] = createSignal(false);
const [submitting, setSubmitting] = createSignal(false);
const [showBackToTop, setShowBackToTop] = createSignal(false); const [showBackToTop, setShowBackToTop] = createSignal(false);
const [scrollY, setScrollY] = createSignal(0); const [scrollY, setScrollY] = createSignal(0);
const [err, setErr] = createSignal("");
const validate = (v: FormValues): FormErrors => { const validate = (v: FormValues): FormErrors => {
const next: FormErrors = {}; const next: FormErrors = {};
if (!v.fullName.trim()) next.fullName = 'Full name is required.'; if (!v.fullName.trim()) next.fullName = "Full name is required.";
if (!v.email.trim()) next.email = 'Email 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.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim()))
if (!v.userType.trim()) next.userType = 'Please select user type.'; next.email = "Enter a valid email.";
if (!v.topic.trim()) next.topic = 'Please select a topic.'; if (!v.userType.trim()) next.userType = "Please select user type.";
if (!v.message.trim()) next.message = 'Message is required.'; if (!v.topic.trim()) next.topic = "Please select a topic.";
if (v.message.trim() && v.message.trim().length < 20) next.message = 'Message must be at least 20 characters.'; 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) {
if (v.attachment.size > 10 * 1024 * 1024) next.attachment = 'Attachment must be 10MB or smaller.'; if (v.attachment.size > 10 * 1024 * 1024)
const allowed = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg']; next.attachment = "Attachment must be 10MB or smaller.";
if (!allowed.includes(v.attachment.type)) next.attachment = 'Allowed formats: PDF, PNG, JPG.'; 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; return next;
}; };
@ -107,8 +112,8 @@ export default function ContactPage() {
setScrollY(window.scrollY || 0); setScrollY(window.scrollY || 0);
}; };
onScroll(); onScroll();
window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true });
onCleanup(() => window.removeEventListener('scroll', onScroll)); onCleanup(() => window.removeEventListener("scroll", onScroll));
}); });
return ( return (
@ -137,94 +142,208 @@ export default function ContactPage() {
<div class="contact-layout-grid"> <div class="contact-layout-grid">
<form <form
class="card glass-light contact-form-card" class="card glass-light contact-form-card"
onSubmit={(event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
const nextErrors = validate(values()); const nextErrors = validate(values());
setErrors(nextErrors); setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return; if (Object.keys(nextErrors).length > 0) return;
setSubmitting(true);
setErr("");
try {
const userTypeToCategory: Record<string, string> = {
"Customer (Hire professional)": "GENERAL",
"Company (Post job)": "ACCOUNT",
"Professional (Provide services)": "GENERAL",
"Job Seeker (Apply jobs)": "GENERAL",
};
const category = userTypeToCategory[values().userType] || "GENERAL";
const res = await fetch("/api/gateway/api/support/tickets", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "include",
body: JSON.stringify({
subject: values().topic,
description: values().message,
category: category,
requester_name: values().fullName,
requester_email: values().email,
phone: values().phone,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error || data.message || "Failed to submit ticket.");
return;
}
setSubmitted(true); setSubmitted(true);
setValues(initialValues); setValues(initialValues);
window.setTimeout(() => setSubmitted(false), 3200); window.setTimeout(() => {
setSubmitted(false);
setErr("");
}, 3200);
} catch {
setErr("Network error while submitting ticket.");
} finally {
setSubmitting(false);
}
}} }}
> >
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}> <div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
<label class="field"> <label class="field">
<span class="label">Full Name *</span> <span class="label">Full Name *</span>
<input class="input" value={values().fullName} onInput={(e) => update('fullName', e.currentTarget.value)} /> <input
<Show when={errors().fullName}><p class="error">{errors().fullName}</p></Show> 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>
<label class="field"> <label class="field">
<span class="label">Email *</span> <span class="label">Email *</span>
<input class="input" type="email" value={values().email} onInput={(e) => update('email', e.currentTarget.value)} /> <input
<Show when={errors().email}><p class="error">{errors().email}</p></Show> 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> </label>
</div> </div>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}> <div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
<label class="field"> <label class="field">
<span class="label">Phone</span> <span class="label">Phone</span>
<input class="input" value={values().phone} onInput={(e) => update('phone', e.currentTarget.value)} /> <input
class="input"
value={values().phone}
onInput={(e) => update("phone", e.currentTarget.value)}
/>
</label> </label>
<label class="field"> <label class="field">
<span class="label">User Type *</span> <span class="label">User Type *</span>
<select class="select" value={values().userType} onInput={(e) => update('userType', e.currentTarget.value)}> <select
class="select"
value={values().userType}
onInput={(e) => update("userType", e.currentTarget.value)}
>
<option value="">Select user type</option> <option value="">Select user type</option>
{userTypes.map((type) => ( {userTypes.map((type) => (
<option value={type}>{type}</option> <option value={type}>{type}</option>
))} ))}
</select> </select>
<Show when={errors().userType}><p class="error">{errors().userType}</p></Show> <Show when={errors().userType}>
<p class="error">{errors().userType}</p>
</Show>
</label> </label>
</div> </div>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}> <div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
<label class="field"> <label class="field">
<span class="label">Topic *</span> <span class="label">Topic *</span>
<select class="select" value={values().topic} onInput={(e) => update('topic', e.currentTarget.value)}> <select
class="select"
value={values().topic}
onInput={(e) => update("topic", e.currentTarget.value)}
>
<option value="">Select topic</option> <option value="">Select topic</option>
{topics.map((topic) => ( {topics.map((topic) => (
<option value={topic}>{topic}</option> <option value={topic}>{topic}</option>
))} ))}
</select> </select>
<Show when={errors().topic}><p class="error">{errors().topic}</p></Show> <Show when={errors().topic}>
<p class="error">{errors().topic}</p>
</Show>
</label> </label>
<label class="field"> <label class="field">
<span class="label">Attachment</span> <span class="label">Attachment</span>
<label class="contact-upload"> <label class="contact-upload">
<span class="contact-upload-icon" aria-hidden="true"></span> <span class="contact-upload-icon" aria-hidden="true">
<span class="contact-upload-text">{values().attachment ? values().attachment.name : 'Upload pdf/png/jpg (max 10MB)'}</span>
</span>
<span class="contact-upload-text">
{values().attachment
? values().attachment.name
: "Upload pdf/png/jpg (max 10MB)"}
</span>
<input <input
class="contact-upload-input" class="contact-upload-input"
type="file" type="file"
accept=".pdf,.png,.jpg,.jpeg" accept=".pdf,.png,.jpg,.jpeg"
onChange={(e) => update('attachment', e.currentTarget.files?.[0] ?? null)} onChange={(e) => update("attachment", e.currentTarget.files?.[0] ?? null)}
/> />
</label> </label>
<Show when={errors().attachment}><p class="error">{errors().attachment}</p></Show> <Show when={errors().attachment}>
<p class="error">{errors().attachment}</p>
</Show>
</label> </label>
</div> </div>
<label class="field"> <label class="field">
<span class="label">Message *</span> <span class="label">Message *</span>
<textarea class="textarea" value={values().message} onInput={(e) => update('message', e.currentTarget.value)} /> <textarea
<Show when={errors().message}><p class="error">{errors().message}</p></Show> class="textarea"
value={values().message}
onInput={(e) => update("message", e.currentTarget.value)}
/>
<Show when={errors().message}>
<p class="error">{errors().message}</p>
</Show>
</label> </label>
<div class="hero-actions"> <div class="hero-actions">
<button class="lp-primary-btn" type="submit" disabled={!canSubmit()}>Send message</button> <button
<button class="lp-ghost-btn" type="button" onClick={() => { setValues(initialValues); setErrors({}); }}>Reset</button> class="lp-primary-btn"
type="submit"
disabled={!canSubmit() || submitting()}
>
{submitting() ? "Sending..." : "Send message"}
</button>
<button
class="lp-ghost-btn"
type="button"
onClick={() => {
setValues(initialValues);
setErrors({});
setErr("");
}}
>
Reset
</button>
</div> </div>
</form> </form>
<aside class="card glass-dark contact-side-card"> <aside class="card glass-dark contact-side-card">
<h3>Contact details</h3> <h3>Contact details</h3>
<p class="sub contact-detail"><span class="contact-icon"><IconMail /></span>support@nxtgauge.com</p> <p class="sub contact-detail">
<p class="sub contact-detail"><span class="contact-icon"><IconClock /></span>Typically within 2448 hours</p> <span class="contact-icon">
<p class="sub contact-detail"><span class="contact-icon"><IconPin /></span>Remote-first, India</p> <IconMail />
</span>
support@nxtgauge.com
</p>
<p class="sub contact-detail">
<span class="contact-icon">
<IconClock />
</span>
Typically within 2448 hours
</p>
<p class="sub contact-detail">
<span class="contact-icon">
<IconPin />
</span>
Remote-first, India
</p>
<div class="hero-actions"> <div class="hero-actions">
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/about">About Us</A> <A class="lp-ghost-btn lp-ghost-btn-dark" href="/about">
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/#faqs">FAQs</A> About Us
</A>
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/#faqs">
FAQs
</A>
</div> </div>
</aside> </aside>
</div> </div>
@ -236,23 +355,63 @@ export default function ContactPage() {
<h2 class="center">Common Questions</h2> <h2 class="center">Common Questions</h2>
<p class="center sub contact-quick-clarity">Quick clarity before you raise a query.</p> <p class="center sub contact-quick-clarity">Quick clarity before you raise a query.</p>
<div class="contact-mini-faq-grid"> <div class="contact-mini-faq-grid">
<article class="contact-mini-faq-card"><h3>Approval time</h3><p>Most profile and listing approvals are completed in 2448 hours.</p></article> <article class="contact-mini-faq-card">
<article class="contact-mini-faq-card"><h3>Verification</h3><p>Verification is required to reduce spam and improve trust.</p></article> <h3>Approval time</h3>
<article class="contact-mini-faq-card"><h3>Posting flow</h3><p>You can submit multiple requirements and jobs after onboarding.</p></article> <p>Most profile and listing approvals are completed in 2448 hours.</p>
</article>
<article class="contact-mini-faq-card">
<h3>Verification</h3>
<p>Verification is required to reduce spam and improve trust.</p>
</article>
<article class="contact-mini-faq-card">
<h3>Posting flow</h3>
<p>You can submit multiple requirements and jobs after onboarding.</p>
</article>
</div> </div>
</div> </div>
</section> </section>
<Show when={submitted()}> <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 }}> <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. Message sent. We'll reply soon.
</div> </div>
</Show> </Show>
<Show when={err()}>
<div
style={{
position: "fixed",
right: "16px",
top: "88px",
"z-index": 90,
padding: "10px 14px",
"border-radius": "12px",
border: "1px solid rgba(239,68,68,0.25)",
background: "rgba(220,38,38,0.1)",
color: "#DC2626",
"font-weight": 700,
}}
>
{err()}
</div>
</Show>
<PublicFooter /> <PublicFooter />
<Show when={showBackToTop()}> <Show when={showBackToTop()}>
<button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}> <button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>
</button> </button>
</Show> </Show>

View file

@ -0,0 +1 @@
export { default } from "../dashboard";

View file

@ -8,6 +8,43 @@ import { isValidEmail } from '~/lib/form-validation';
type RoleKey = 'company' | 'job_seeker' | 'professional' | 'customer'; type RoleKey = 'company' | 'job_seeker' | 'professional' | 'customer';
function normalizeRoleValue(value: unknown): string {
return String(value || '').trim().toUpperCase().replace(/\s+/g, '_');
}
function getStoredPreferredRole(emailHint?: string): string | null {
if (typeof window === 'undefined') return null;
const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user'];
for (const key of keys) {
const raw = window.localStorage.getItem(key);
if (!raw) continue;
try {
const parsed = JSON.parse(raw) as Record<string, any>;
const storedEmail = String(parsed?.email || '').trim().toLowerCase();
if (emailHint && storedEmail && storedEmail !== emailHint.trim().toLowerCase()) continue;
const selectedProfessionalRole = normalizeRoleValue(parsed?.selectedProfessionalRole);
if (selectedProfessionalRole) return selectedProfessionalRole;
const activeRole = normalizeRoleValue(parsed?.active_role || parsed?.role);
if (activeRole) return activeRole;
} catch {
// Ignore malformed local storage payloads.
}
}
return null;
}
function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string {
const backendRole = normalizeRoleValue(rawBackendRole);
if (backendRole) return backendRole;
const preferredRole = getStoredPreferredRole(emailHint);
if (preferredRole) return preferredRole;
return preferredRole || 'JOB_SEEKER';
}
function makeCaptcha() { function makeCaptcha() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
@ -45,9 +82,61 @@ export default function LoginRoute() {
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [submitting, setSubmitting] = createSignal(false); const [submitting, setSubmitting] = createSignal(false);
const [roleGuess, setRoleGuess] = createSignal<RoleKey>('job_seeker'); const [roleGuess, setRoleGuess] = createSignal<RoleKey>('job_seeker');
const [roleHint, setRoleHint] = createSignal('');
const [checkingRole, setCheckingRole] = createSignal(false);
const otpCode = createMemo(() => otp().join('')); const otpCode = createMemo(() => otp().join(''));
const formatRoleLabel = (value: string): string =>
String(value || '')
.trim()
.replace(/[_\s]+/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (ch) => ch.toUpperCase());
const lookupRoleByEmail = async (emailValue: string) => {
const normalized = emailValue.trim().toLowerCase();
if (!normalized || !isValidEmail(normalized)) {
setRoleHint('');
return;
}
setCheckingRole(true);
try {
const response = await fetch('/api/gateway/api/auth/check-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: normalized }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.exists) {
setRoleHint('');
return;
}
const detectedRole = normalizeRoleValue(
payload?.active_role || payload?.role || payload?.roles?.[0]
);
if (!detectedRole) {
const fallbackRole = normalizeRoleValue(getStoredPreferredRole(normalized));
setRoleHint(
fallbackRole
? `Role: ${formatRoleLabel(fallbackRole)}`
: 'Role: Not assigned'
);
return;
}
setRoleHint(`Role: ${formatRoleLabel(detectedRole)}`);
const roleLower = detectedRole.toLowerCase();
if (roleLower === 'company' || roleLower === 'customer' || roleLower === 'job_seeker' || roleLower === 'professional') {
setRoleGuess(roleLower as RoleKey);
}
} catch {
setRoleHint('');
} finally {
setCheckingRole(false);
}
};
const setOtpDigit = (index: number, value: string) => { const setOtpDigit = (index: number, value: string) => {
const clean = value.replace(/\D/g, '').slice(0, 1); const clean = value.replace(/\D/g, '').slice(0, 1);
setOtp((prev) => { setOtp((prev) => {
@ -65,13 +154,14 @@ export default function LoginRoute() {
const fullName = String(user?.full_name || user?.fullName || '').trim(); const fullName = String(user?.full_name || user?.fullName || '').trim();
const [firstName, ...rest] = fullName.split(' '); const [firstName, ...rest] = fullName.split(' ');
const lastName = rest.join(' '); const lastName = rest.join(' ');
const normalizedRole = String(user?.active_role || user?.role || roleGuess() || '') const normalizedRole = resolveActiveRole(
.trim() user?.active_role || user?.role || roleGuess(),
.toUpperCase() String(user?.email || email())
.replace(/\s+/g, '_'); );
const storedRole = normalizedRole const storedRole = normalizedRole
? normalizedRole.toLowerCase() ? normalizedRole.toLowerCase()
: roleGuess(); : roleGuess();
const selectedProfessionalRole = getStoredPreferredRole(String(user?.email || email()));
const payload = { const payload = {
firstName: firstName || '', firstName: firstName || '',
lastName: lastName || '', lastName: lastName || '',
@ -82,6 +172,7 @@ export default function LoginRoute() {
roleKey: storedRole, roleKey: storedRole,
role: storedRole, role: storedRole,
active_role: normalizedRole || 'JOB_SEEKER', active_role: normalizedRole || 'JOB_SEEKER',
selectedProfessionalRole: selectedProfessionalRole || null,
user, user,
}; };
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -92,6 +183,7 @@ export default function LoginRoute() {
}; };
const login = async () => { const login = async () => {
if (submitting()) return;
setError(''); setError('');
if (!isValidEmail(email())) { if (!isValidEmail(email())) {
setError('Enter a valid email address.'); setError('Enter a valid email address.');
@ -135,23 +227,32 @@ export default function LoginRoute() {
window.sessionStorage.setItem('nxtgauge_access_token', accessToken); window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken); window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
} }
saveUser(data?.user || {}); const resolvedActiveRole = resolveActiveRole(
data?.user?.active_role || data?.user?.role,
data?.user?.email || email().trim().toLowerCase()
);
const normalizedEmail = email().trim().toLowerCase();
const userPayload = {
id: String(data?.user?.id || ''),
email: String(data?.user?.email || normalizedEmail),
full_name: String(data?.user?.full_name || ''),
active_role: resolvedActiveRole,
email_verified: Boolean(data?.user?.email_verified ?? true),
};
saveUser({ ...(data?.user || {}), ...userPayload });
if (auth.setUser) { if (auth.setUser) {
auth.setUser({ auth.setUser(userPayload);
id: data?.user?.id || '',
email: data?.user?.email || email().trim().toLowerCase(),
full_name: data?.user?.full_name || '',
active_role: data?.user?.active_role || 'JOB_SEEKER',
email_verified: data?.user?.email_verified || false,
});
} }
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
} catch {
setError('Network error during login. Please try again.');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const resendOtp = async () => { const resendOtp = async () => {
if (submitting()) return;
setError(''); setError('');
setSubmitting(true); setSubmitting(true);
try { try {
@ -171,6 +272,7 @@ export default function LoginRoute() {
}; };
const verifyThenLogin = async () => { const verifyThenLogin = async () => {
if (submitting()) return;
setError(''); setError('');
if (otpCode().length !== 6) { if (otpCode().length !== 6) {
setError('Enter a valid 6-digit OTP.'); setError('Enter a valid 6-digit OTP.');
@ -216,10 +318,29 @@ export default function LoginRoute() {
<div class="field"> <div class="field">
<label class="label" for="login-email">EMAIL</label> <label class="label" for="login-email">EMAIL</label>
<input id="login-email" type="email" class="input" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} placeholder="Enter your email" /> <input
id="login-email"
type="email"
class="input"
value={email()}
onInput={(e) => {
const value = e.currentTarget.value;
setEmail(value);
void lookupRoleByEmail(value);
}}
onBlur={(e) => {
void lookupRoleByEmail(e.currentTarget.value);
}}
placeholder="Enter your email"
/>
<p class="validation-note" style={{ color: email().trim() && isValidEmail(email()) ? '#fd6116' : '#6e7591' }}> <p class="validation-note" style={{ color: email().trim() && isValidEmail(email()) ? '#fd6116' : '#6e7591' }}>
{email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format'} {email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format'}
</p> </p>
<Show when={roleHint() || checkingRole()}>
<p class="validation-note" style={{ color: '#0f766e' }}>
{checkingRole() ? 'Checking account role...' : `${roleHint()}`}
</p>
</Show>
</div> </div>
<div class="field"> <div class="field">

View file

@ -1,8 +1,8 @@
import { A, useNavigate, useSearchParams } from '@solidjs/router'; import { A, useNavigate, useSearchParams } from "@solidjs/router";
import { createMemo, createSignal, For, onMount, Show } from 'solid-js'; import { createMemo, createSignal, For, onMount, Show } from "solid-js";
import PublicBackground from '~/components/PublicBackground'; import PublicBackground from "~/components/PublicBackground";
import PublicHeader from '~/components/PublicHeader'; import PublicHeader from "~/components/PublicHeader";
import CaptchaCanvas from '~/components/CaptchaCanvas'; import CaptchaCanvas from "~/components/CaptchaCanvas";
import { import {
checkPasswordStrength, checkPasswordStrength,
isPasswordStrong, isPasswordStrong,
@ -10,24 +10,36 @@ import {
isValidEmail, isValidEmail,
isValidName, isValidName,
validateRegisterForm, validateRegisterForm,
} from '~/lib/form-validation'; } from "~/lib/form-validation";
type RoleKey = 'company' | 'job_seeker' | 'professional' | 'customer'; type RoleKey = "company" | "job_seeker" | "professional" | "customer";
type RegisterErrors = Record<string, string>; type RegisterErrors = Record<string, string>;
function normalizeIntent(intent: string | null | undefined): RoleKey { function normalizeIntent(intent: string | null | undefined): RoleKey {
const v = String(intent || '').toLowerCase(); const v = String(intent || "").toLowerCase();
if (v.includes('company')) return 'company'; if (v.includes("company")) return "company";
if (v.includes('professional')) return 'professional'; if (v.includes("professional")) return "professional";
if (v.includes('developer') || v.includes('photographer') || v.includes('makeup') || v.includes('tutor') || v.includes('video') || v.includes('graphic') || v.includes('social') || v.includes('fitness') || v.includes('catering') || v.includes('ugc')) return 'professional'; if (
if (v.includes('customer')) return 'customer'; v.includes("developer") ||
return 'job_seeker'; v.includes("photographer") ||
v.includes("makeup") ||
v.includes("tutor") ||
v.includes("video") ||
v.includes("graphic") ||
v.includes("social") ||
v.includes("fitness") ||
v.includes("catering") ||
v.includes("ugc")
)
return "professional";
if (v.includes("customer")) return "customer";
return "job_seeker";
} }
function randomCaptcha(length = 6): string { function randomCaptcha(length = 6): string {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = ''; let out = "";
for (let i = 0; i < length; i += 1) { for (let i = 0; i < length; i += 1) {
out += alphabet[Math.floor(Math.random() * alphabet.length)]; out += alphabet[Math.floor(Math.random() * alphabet.length)];
} }
@ -61,33 +73,38 @@ export default function SignupRoute() {
// If no intent/role provided, normalizeIntent will default to job_seeker. // If no intent/role provided, normalizeIntent will default to job_seeker.
}); });
const [step, setStep] = createSignal<'register' | 'verify'>('register'); const [step, setStep] = createSignal<"register" | "verify">("register");
const [firstName, setFirstName] = createSignal(''); const [firstName, setFirstName] = createSignal("");
const [lastName, setLastName] = createSignal(''); const [lastName, setLastName] = createSignal("");
const [email, setEmail] = createSignal(''); const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal(''); const [password, setPassword] = createSignal("");
const [confirmPassword, setConfirmPassword] = createSignal(''); const [confirmPassword, setConfirmPassword] = createSignal("");
const role = createMemo<RoleKey>(() => normalizeIntent(search.intent || search.role)); const role = createMemo<RoleKey>(() => normalizeIntent(search.intent || search.role));
const selectedProfessionalRole = createMemo(() => String(search.role || '').trim().toUpperCase()); const selectedProfessionalRole = createMemo(() =>
String(search.role || "")
.trim()
.toUpperCase()
);
const [termsAccepted, setTermsAccepted] = createSignal(false); const [termsAccepted, setTermsAccepted] = createSignal(false);
const [captcha, setCaptcha] = createSignal(''); const [captcha, setCaptcha] = createSignal("");
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha()); const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
const [otp, setOtp] = createSignal(['', '', '', '', '', '']); const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
const [errors, setErrors] = createSignal<RegisterErrors>({}); const [errors, setErrors] = createSignal<RegisterErrors>({});
const [serverError, setServerError] = createSignal(''); const [serverError, setServerError] = createSignal("");
const [emailExists, setEmailExists] = createSignal(false); const [emailExists, setEmailExists] = createSignal(false);
const [submitting, setSubmitting] = createSignal(false); const [submitting, setSubmitting] = createSignal(false);
const [pendingEmail, setPendingEmail] = createSignal(''); const [pendingEmail, setPendingEmail] = createSignal("");
const [verifiedSuccess, setVerifiedSuccess] = createSignal(false); const [verifiedSuccess, setVerifiedSuccess] = createSignal(false);
const [showPassword, setShowPassword] = createSignal(false); const [showPassword, setShowPassword] = createSignal(false);
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false); const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
const passwordChecks = createMemo(() => checkPasswordStrength(password(), confirmPassword())); const passwordChecks = createMemo(() => checkPasswordStrength(password(), confirmPassword()));
const otpCode = createMemo(() => otp().join('')); const otpCode = createMemo(() => otp().join(""));
const firstNameValid = createMemo(() => !firstName().trim() || isValidName(firstName())); const firstNameValid = createMemo(() => !firstName().trim() || isValidName(firstName()));
const lastNameValid = createMemo(() => !lastName().trim() || isValidName(lastName())); const lastNameValid = createMemo(() => !lastName().trim() || isValidName(lastName()));
const emailValid = createMemo(() => !email().trim() || isValidEmail(email())); const emailValid = createMemo(() => !email().trim() || isValidEmail(email()));
const canSubmit = createMemo(() => const canSubmit = createMemo(
() =>
firstName().trim().length > 0 && firstName().trim().length > 0 &&
firstNameValid() && firstNameValid() &&
lastName().trim().length > 0 && lastName().trim().length > 0 &&
@ -102,7 +119,7 @@ export default function SignupRoute() {
); );
const refreshCaptcha = () => { const refreshCaptcha = () => {
setCaptcha(''); setCaptcha("");
setCaptchaCode(randomCaptcha()); setCaptchaCode(randomCaptcha());
}; };
@ -114,10 +131,10 @@ export default function SignupRoute() {
} }
try { try {
const response = await fetch('/api/gateway/api/auth/check-email', { const response = await fetch("/api/gateway/api/auth/check-email", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ email: normalized }), body: JSON.stringify({ email: normalized }),
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
@ -131,7 +148,7 @@ export default function SignupRoute() {
}; };
const setOtpDigit = (index: number, value: string) => { const setOtpDigit = (index: number, value: string) => {
const clean = value.replace(/\D/g, '').slice(0, 1); const clean = value.replace(/\D/g, "").slice(0, 1);
setOtp((prev) => { setOtp((prev) => {
const next = prev.slice(); const next = prev.slice();
next[index] = clean; next[index] = clean;
@ -143,7 +160,13 @@ export default function SignupRoute() {
} }
}; };
const saveUserForDashboard = (input: { firstName: string; lastName: string; email: string; roleKey: RoleKey; user?: any }) => { const saveUserForDashboard = (input: {
firstName: string;
lastName: string;
email: string;
roleKey: RoleKey;
user?: any;
}) => {
const fullName = `${input.firstName} ${input.lastName}`.trim(); const fullName = `${input.firstName} ${input.lastName}`.trim();
const payload = { const payload = {
firstName: input.firstName, firstName: input.firstName,
@ -157,15 +180,15 @@ export default function SignupRoute() {
selectedProfessionalRole: selectedProfessionalRole() || null, selectedProfessionalRole: selectedProfessionalRole() || null,
user: input.user || null, user: input.user || null,
}; };
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
window.localStorage.setItem('nxtgauge_signup_profile_v1', JSON.stringify(payload)); window.localStorage.setItem("nxtgauge_signup_profile_v1", JSON.stringify(payload));
window.localStorage.setItem('nxtgauge_auth_user', JSON.stringify(payload)); window.localStorage.setItem("nxtgauge_auth_user", JSON.stringify(payload));
window.localStorage.setItem('nxtgauge_user', JSON.stringify(payload)); window.localStorage.setItem("nxtgauge_user", JSON.stringify(payload));
} }
}; };
const register = async () => { const register = async () => {
setServerError(''); setServerError("");
const validation = validateRegisterForm({ const validation = validateRegisterForm({
firstName: firstName(), firstName: firstName(),
lastName: lastName(), lastName: lastName(),
@ -181,23 +204,23 @@ export default function SignupRoute() {
setSubmitting(true); setSubmitting(true);
try { try {
const res = await fetch('/api/gateway/api/auth/register', { const res = await fetch("/api/auth/register", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
full_name: `${firstName().trim()} ${lastName().trim()}`.trim(), full_name: `${firstName().trim()} ${lastName().trim()}`.trim(),
email: email().trim().toLowerCase(), email: email().trim().toLowerCase(),
password: password(), password: password(),
phone: null, phone: "",
intent: role(), intent: role(),
profession: selectedProfessionalRole() || undefined, role_key: selectedProfessionalRole() || undefined,
}), }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
setServerError(String(data?.error || data?.message || 'Unable to create account.')); setServerError(String(data?.error || data?.message || "Unable to create account."));
refreshCaptcha(); refreshCaptcha();
return; return;
} }
@ -211,52 +234,52 @@ export default function SignupRoute() {
email: cleanEmail, email: cleanEmail,
roleKey: role(), roleKey: role(),
}); });
setStep('verify'); setStep("verify");
setOtp(['', '', '', '', '', '']); setOtp(["", "", "", "", "", ""]);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const verifyOtp = async () => { const verifyOtp = async () => {
setServerError(''); setServerError("");
if (otpCode().length !== 6) { if (otpCode().length !== 6) {
setServerError('Enter the 6-digit code sent to your email.'); setServerError("Enter the 6-digit code sent to your email.");
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const verifyRes = await fetch('/api/gateway/api/auth/verify-email', { const verifyRes = await fetch("/api/gateway/api/auth/verify-email", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ otp: otpCode() }), body: JSON.stringify({ otp: otpCode() }),
}); });
const verifyData = await verifyRes.json().catch(() => ({})); const verifyData = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) { if (!verifyRes.ok) {
setServerError(String(verifyData?.error || verifyData?.message || 'Verification failed.')); setServerError(String(verifyData?.error || verifyData?.message || "Verification failed."));
return; return;
} }
setVerifiedSuccess(true); setVerifiedSuccess(true);
setTimeout(() => navigate('/login?verified=1', { replace: true }), 1400); setTimeout(() => navigate("/login?verified=1", { replace: true }), 1400);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const resendOtp = async () => { const resendOtp = async () => {
setServerError(''); setServerError("");
setSubmitting(true); setSubmitting(true);
try { try {
const res = await fetch('/api/gateway/api/auth/resend-otp', { const res = await fetch("/api/gateway/api/auth/resend-otp", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: 'include', credentials: "include",
body: JSON.stringify({ email: pendingEmail() || email().trim().toLowerCase() }), body: JSON.stringify({ email: pendingEmail() || email().trim().toLowerCase() }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
setServerError(String(data?.error || data?.message || 'Unable to resend OTP right now.')); setServerError(String(data?.error || data?.message || "Unable to resend OTP right now."));
} }
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@ -274,23 +297,46 @@ export default function SignupRoute() {
<div class="auth-visual-content"> <div class="auth-visual-content">
<p class="eyebrow">Get Started</p> <p class="eyebrow">Get Started</p>
<h1 class="title light">Create Your Nxtgauge Account</h1> <h1 class="title light">Create Your Nxtgauge Account</h1>
<p class="subtitle light">Join verified opportunities and continue directly to your dashboard after signup.</p> <p class="subtitle light">
Join verified opportunities and continue directly to your dashboard after signup.
</p>
</div> </div>
</section> </section>
<section class="auth-form card glass-light"> <section class="auth-form card glass-light">
<Show when={step() === 'register'} fallback={ <Show
when={step() === "register"}
fallback={
<> <>
<h2 class="title">Verify Email</h2> <h2 class="title">Verify Email</h2>
<p class="subtitle">Enter the 6-digit code sent to <strong>{pendingEmail() || email()}</strong>.</p> <p class="subtitle">
Enter the 6-digit code sent to <strong>{pendingEmail() || email()}</strong>.
</p>
<Show when={!verifiedSuccess()} fallback={ <Show
<div style={{ 'margin-top': '12px', 'border-radius': '12px', border: '1px solid #FED7AA', background: '#FFF7ED', padding: '14px 16px', color: '#C2410C', 'text-align': 'center' }}> when={!verifiedSuccess()}
<div style={{ 'font-size': '30px', 'line-height': '1' }}></div> fallback={
<p style={{ margin: '8px 0 0', 'font-weight': '700', 'font-size': '14px' }}>Your email has been verified.</p> <div
<p style={{ margin: '6px 0 0', 'font-size': '13px' }}>Redirecting to login...</p> style={{
"margin-top": "12px",
"border-radius": "12px",
border: "1px solid #FED7AA",
background: "#FFF7ED",
padding: "14px 16px",
color: "#C2410C",
"text-align": "center",
}}
>
<div style={{ "font-size": "30px", "line-height": "1" }}></div>
<p style={{ margin: "8px 0 0", "font-weight": "700", "font-size": "14px" }}>
Your email has been verified.
</p>
<p style={{ margin: "6px 0 0", "font-size": "13px" }}>
Redirecting to login...
</p>
</div> </div>
}> }
>
<div class="otp-row"> <div class="otp-row">
<For each={Array.from({ length: 6 }, (_, index) => index)}> <For each={Array.from({ length: 6 }, (_, index) => index)}>
{(index) => ( {(index) => (
@ -306,41 +352,80 @@ export default function SignupRoute() {
</For> </For>
</div> </div>
<button class="auth-submit-btn" type="button" disabled={submitting()} onClick={() => void verifyOtp()}> <button
{submitting() ? 'Verifying...' : 'Verify and Continue'} class="auth-submit-btn"
type="button"
disabled={submitting()}
onClick={() => void verifyOtp()}
>
{submitting() ? "Verifying..." : "Verify and Continue"}
</button> </button>
<div class="auth-footer-row"> <div class="auth-footer-row">
<p class="note">Didnt receive code?</p> <p class="note">Didnt receive code?</p>
<button class="auth-forgot-link" type="button" onClick={() => void resendOtp()} disabled={submitting()}> <button
class="auth-forgot-link"
type="button"
onClick={() => void resendOtp()}
disabled={submitting()}
>
Resend OTP Resend OTP
</button> </button>
</div> </div>
</Show> </Show>
</> </>
}> }
>
<h2 class="title">Create Your Account</h2> <h2 class="title">Create Your Account</h2>
<p class="subtitle">Sign up first, then go directly to dashboard after email verification.</p> <p class="subtitle">
Sign up first, then go directly to dashboard after email verification.
</p>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}> <div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
<div class="field"> <div class="field">
<label class="label" for="first-name">FULL NAME</label> <label class="label" for="first-name">
<input id="first-name" class="input" value={firstName()} onInput={(e) => setFirstName(e.currentTarget.value)} /> FULL NAME
<p class="validation-note" style={{ color: firstName().trim() && firstNameValid() ? '#fd6116' : '#6e7591' }}> </label>
{firstName().trim() && firstNameValid() ? '✓ First name looks good' : '• First name is required'} <input
id="first-name"
class="input"
value={firstName()}
onInput={(e) => setFirstName(e.currentTarget.value)}
/>
<p
class="validation-note"
style={{ color: firstName().trim() && firstNameValid() ? "#fd6116" : "#6e7591" }}
>
{firstName().trim() && firstNameValid()
? "✓ First name looks good"
: "• First name is required"}
</p> </p>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="last-name">LAST NAME</label> <label class="label" for="last-name">
<input id="last-name" class="input" value={lastName()} onInput={(e) => setLastName(e.currentTarget.value)} /> LAST NAME
<p class="validation-note" style={{ color: lastName().trim() && lastNameValid() ? '#fd6116' : '#6e7591' }}> </label>
{lastName().trim() && lastNameValid() ? '✓ Last name looks good' : '• Last name is required'} <input
id="last-name"
class="input"
value={lastName()}
onInput={(e) => setLastName(e.currentTarget.value)}
/>
<p
class="validation-note"
style={{ color: lastName().trim() && lastNameValid() ? "#fd6116" : "#6e7591" }}
>
{lastName().trim() && lastNameValid()
? "✓ Last name looks good"
: "• Last name is required"}
</p> </p>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="email">EMAIL ADDRESS</label> <label class="label" for="email">
EMAIL ADDRESS
</label>
<input <input
id="email" id="email"
type="email" type="email"
@ -350,82 +435,168 @@ export default function SignupRoute() {
setEmail(e.currentTarget.value); setEmail(e.currentTarget.value);
setEmailExists(false); setEmailExists(false);
}} }}
onBlur={() => { void checkEmailExists(email()); }} onBlur={() => {
void checkEmailExists(email());
}}
/> />
<p class="validation-note" style={{ color: emailExists() ? '#dc2626' : (email().trim() && emailValid() ? '#fd6116' : '#6e7591') }}> <p
class="validation-note"
style={{
color: emailExists()
? "#dc2626"
: email().trim() && emailValid()
? "#fd6116"
: "#6e7591",
}}
>
{emailExists() {emailExists()
? '• This email is already registered' ? "• This email is already registered"
: (email().trim() && emailValid() ? '✓ Valid email format' : '• Enter a valid email format')} : email().trim() && emailValid()
? "✓ Valid email format"
: "• Enter a valid email format"}
</p> </p>
</div> </div>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}> <div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
<div class="field"> <div class="field">
<label class="label" for="password">PASSWORD</label> <label class="label" for="password">
PASSWORD
</label>
<div class="auth-password-wrap"> <div class="auth-password-wrap">
<input id="password" type={showPassword() ? 'text' : 'password'} class="input" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} /> <input
id="password"
type={showPassword() ? "text" : "password"}
class="input"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
/>
<button <button
class="auth-toggle-visibility" class="auth-toggle-visibility"
type="button" type="button"
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword() ? 'Hide password' : 'Show password'} aria-label={showPassword() ? "Hide password" : "Show password"}
> >
<PasswordVisibilityIcon visible={showPassword()} /> <PasswordVisibilityIcon visible={showPassword()} />
</button> </button>
</div> </div>
<div class="password-strength-grid"> <div class="password-strength-grid">
<p style={{ color: passwordChecks().minLength ? '#fd6116' : '#6e7591' }}>{passwordChecks().minLength ? '✓' : '•'} 8+ chars</p> <p style={{ color: passwordChecks().minLength ? "#fd6116" : "#6e7591" }}>
<p style={{ color: passwordChecks().uppercase ? '#fd6116' : '#6e7591' }}>{passwordChecks().uppercase ? '✓' : '•'} Uppercase</p> {passwordChecks().minLength ? "✓" : "•"} 8+ chars
<p style={{ color: passwordChecks().special ? '#fd6116' : '#6e7591' }}>{passwordChecks().special ? '✓' : '•'} Special</p> </p>
<p style={{ color: passwordChecks().lowercase ? '#fd6116' : '#6e7591' }}>{passwordChecks().lowercase ? '✓' : '•'} Lowercase</p> <p style={{ color: passwordChecks().uppercase ? "#fd6116" : "#6e7591" }}>
<p style={{ color: passwordChecks().number ? '#fd6116' : '#6e7591' }}>{passwordChecks().number ? '✓' : '•'} Number</p> {passwordChecks().uppercase ? "✓" : "•"} Uppercase
</p>
<p style={{ color: passwordChecks().special ? "#fd6116" : "#6e7591" }}>
{passwordChecks().special ? "✓" : "•"} Special
</p>
<p style={{ color: passwordChecks().lowercase ? "#fd6116" : "#6e7591" }}>
{passwordChecks().lowercase ? "✓" : "•"} Lowercase
</p>
<p style={{ color: passwordChecks().number ? "#fd6116" : "#6e7591" }}>
{passwordChecks().number ? "✓" : "•"} Number
</p>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="confirm-password">CONFIRM PASSWORD</label> <label class="label" for="confirm-password">
CONFIRM PASSWORD
</label>
<div class="auth-password-wrap"> <div class="auth-password-wrap">
<input id="confirm-password" type={showConfirmPassword() ? 'text' : 'password'} class="input" value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} /> <input
id="confirm-password"
type={showConfirmPassword() ? "text" : "password"}
class="input"
value={confirmPassword()}
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
/>
<button <button
class="auth-toggle-visibility" class="auth-toggle-visibility"
type="button" type="button"
onClick={() => setShowConfirmPassword((prev) => !prev)} onClick={() => setShowConfirmPassword((prev) => !prev)}
aria-label={showConfirmPassword() ? 'Hide password' : 'Show password'} aria-label={showConfirmPassword() ? "Hide password" : "Show password"}
> >
<PasswordVisibilityIcon visible={showConfirmPassword()} /> <PasswordVisibilityIcon visible={showConfirmPassword()} />
</button> </button>
</div> </div>
<p class="validation-note" style={{ color: confirmPassword() && passwordChecks().match ? '#fd6116' : '#6e7591' }}> <p
{confirmPassword() && passwordChecks().match ? '✓ Passwords match' : '• Passwords do not match'} class="validation-note"
style={{
color: confirmPassword() && passwordChecks().match ? "#fd6116" : "#6e7591",
}}
>
{confirmPassword() && passwordChecks().match
? "✓ Passwords match"
: "• Passwords do not match"}
</p> </p>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label" for="captcha">CAPTCHA</label> <label class="label" for="captcha">
CAPTCHA
</label>
<div class="auth-captcha-row"> <div class="auth-captcha-row">
<button type="button" class="auth-captcha-refresh" onClick={refreshCaptcha} aria-label="Refresh captcha"></button> <button
type="button"
class="auth-captcha-refresh"
onClick={refreshCaptcha}
aria-label="Refresh captcha"
>
</button>
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" /> <CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
<input id="captcha" class="input" value={captcha()} onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())} placeholder="Enter captcha" /> <input
id="captcha"
class="input"
value={captcha()}
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
placeholder="Enter captcha"
/>
</div> </div>
<p class="validation-note" style={{ color: captcha() && isValidCaptcha(captcha(), captchaCode()) ? '#fd6116' : '#6e7591' }}> <p
{captcha() ? (isValidCaptcha(captcha(), captchaCode()) ? '✓ Captcha matched' : '• Captcha does not match') : '• Enter captcha to continue'} class="validation-note"
style={{
color:
captcha() && isValidCaptcha(captcha(), captchaCode()) ? "#fd6116" : "#6e7591",
}}
>
{captcha()
? isValidCaptcha(captcha(), captchaCode())
? "✓ Captcha matched"
: "• Captcha does not match"
: "• Enter captcha to continue"}
</p> </p>
</div> </div>
<div class="field" style={{ 'margin-top': '16px' }}> <div class="field" style={{ "margin-top": "16px" }}>
<label class="auth-checkbox-wrapper"> <label class="auth-checkbox-wrapper">
<input class="auth-checkbox" type="checkbox" checked={termsAccepted()} onChange={(e) => setTermsAccepted(e.currentTarget.checked)} /> <input
<span class="auth-checkbox-label">I agree to the <A href="/terms">Terms and Conditions</A> and <A href="/privacy">Privacy Policy</A></span> class="auth-checkbox"
type="checkbox"
checked={termsAccepted()}
onChange={(e) => setTermsAccepted(e.currentTarget.checked)}
/>
<span class="auth-checkbox-label">
I agree to the <A href="/terms">Terms and Conditions</A> and{" "}
<A href="/privacy">Privacy Policy</A>
</span>
</label> </label>
</div> </div>
<button class="auth-submit-btn" type="button" disabled={submitting() || !canSubmit()} onClick={() => void register()}> <button
{submitting() ? 'Creating Account...' : 'Sign Up'} class="auth-submit-btn"
type="button"
disabled={submitting() || !canSubmit()}
onClick={() => void register()}
>
{submitting() ? "Creating Account..." : "Sign Up"}
</button> </button>
<div class="auth-footer-row"> <div class="auth-footer-row">
<p class="footer-text">We will send a verification code to your email.</p> <p class="footer-text">We will send a verification code to your email.</p>
<p class="note">Already have an account? <A href="/login">Sign In</A></p> <p class="note">
Already have an account? <A href="/login">Sign In</A>
</p>
</div> </div>
</Show> </Show>

View file

@ -1,8 +1,24 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { defineConfig } from '@solidjs/start/config'; import { defineConfig } from "@solidjs/start/config";
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ export default defineConfig({
vite: { vite: {
plugins: [tailwindcss()] plugins: [tailwindcss()],
} server: {
proxy: {
"/api/gateway": {
target: "http://localhost:9100",
changeOrigin: true,
rewrite: (path) =>
path
.replace(/^\/api\/gateway\/api(\/|$)/, "/api$1")
.replace(/^\/api\/gateway(\/|$)/, "/api$1"),
},
"/api": {
target: "http://localhost:9100",
changeOrigin: true,
},
},
},
},
}); });