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

View file

@ -57,6 +57,26 @@ const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = {
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 ───────────────────────────────────────────────────────────────────
@ -95,6 +115,9 @@ export default function PortfolioPage(props: Props) {
const [jobSeekerSaving, setJobSeekerSaving] = createSignal(false);
const [jobSeekerMsg, setJobSeekerMsg] = 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 () => {
if (!isProfessional()) { setLoading(false); return; }
@ -196,6 +219,54 @@ export default function PortfolioPage(props: Props) {
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(() => {
if (isJobSeeker()) {
loadJobSeekerPortfolio();
@ -204,6 +275,11 @@ export default function PortfolioPage(props: Props) {
setLoading(false);
return;
}
if (isProfessional()) {
const tabs = professionalTabs();
setProfessionalTab(tabs[0] || 'About');
loadProfessionalForm();
}
void loadItems();
});
@ -418,195 +494,184 @@ 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 (
<div style={{ 'max-width': '800px' }}>
{/* ── Header ────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'margin-bottom': '16px' }}>
<div>
<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' }}>
Showcase your work to attract clients.
</p>
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '0 16px' }}>
<div style={{ display: 'flex', gap: '20px', 'border-bottom': '1px solid #E5E7EB', padding: '12px 0 0', 'flex-wrap': 'wrap' }}>
<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>
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p>
<p style={{ margin: '6px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Runtime-config driven tab layout aligned with external dashboard preview.
</p>
</div>
<Show when={isProjectsTab()}>
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
+ Add Item
</button>
</Show>
</div>
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
+ Add Item
</button>
</div>
{/* ── Create / Edit form ─────────────────────────────────────────── */}
<Show when={showForm()}>
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
<p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}>
{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}
</p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label>
<input
type="text"
placeholder="e.g. Wedding shoot at Udaipur"
value={form().title}
onInput={(e) => setField('title', e.currentTarget.value)}
style={INPUT}
/>
</div>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Description</label>
<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={3}
placeholder="Brief description of the work…"
value={form().description}
onInput={(e) => setField('description', e.currentTarget.value)}
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={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Tags (comma separated)</label>
<input
type="text"
placeholder="e.g. wedding, outdoor, portrait"
value={form().tags}
onInput={(e) => setField('tags', e.currentTarget.value)}
style={INPUT}
/>
<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>
<Show when={error()}>
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>
{error()}
</p>
</Show>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '16px' }}>
<button
type="button"
onClick={handleSave}
disabled={saving()}
style={{ ...BTN_PRIMARY, opacity: saving() ? '0.6' : '1' }}
>
{saving() ? 'Saving…' : editId() ? 'Update Item' : 'Add Item'}
</button>
<button type="button" onClick={cancelForm} style={BTN_GHOST}>
Cancel
</button>
</div>
</div>
</Show>
{/* ── Loading ─────────────────────────────────────────────────────── */}
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
Loading portfolio
</div>
</Show>
{/* ── Empty state ─────────────────────────────────────────────────── */}
<Show when={!loading() && items().length === 0 && !showForm()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
<p style={{ margin: '0', 'font-size': '40px' }}>🗂</p>
<p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>
No portfolio items yet
</p>
<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>
</Show>
{/* ── Portfolio grid ─────────────────────────────────────────────── */}
<Show when={!loading() && items().length > 0}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<For each={items()}>
{(item) => (
<div style={{
...CARD,
padding: '16px',
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>
<Show when={showForm()}>
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
<p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}>
{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}
</p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label>
<input type="text" placeholder="e.g. Developer dashboard rebuild" value={form().title} onInput={(e) => setField('title', e.currentTarget.value)} style={INPUT} />
</div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
{item.title}
</p>
<Show when={item.description}>
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>
{item.description}
</p>
</Show>
<Show when={item.tags && item.tags.length > 0}>
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
<For each={item.tags}>
{(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>
)}
</For>
</div>
</Show>
<div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}>
<button
type="button"
onClick={() => openEdit(item)}
style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}
>
Edit
</button>
<button
type="button"
onClick={() => handleDelete(item.id)}
disabled={deleting() === item.id}
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',
}}
>
{deleting() === item.id ? '…' : 'Delete'}
</button>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Description</label>
<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' }} />
</div>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Tags (comma separated)</label>
<input type="text" placeholder="e.g. solidjs, rust, dashboard" value={form().tags} onInput={(e) => setField('tags', e.currentTarget.value)} style={INPUT} />
</div>
</div>
)}
</For>
<Show when={error()}>
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>{error()}</p>
</Show>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '16px' }}>
<button type="button" onClick={handleSave} disabled={saving()} style={{ ...BTN_PRIMARY, opacity: saving() ? '0.6' : '1' }}>
{saving() ? 'Saving…' : editId() ? 'Update Item' : 'Add Item'}
</button>
<button type="button" onClick={cancelForm} style={BTN_GHOST}>Cancel</button>
</div>
</div>
</Show>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
Loading portfolio
</div>
</Show>
<Show when={!loading() && items().length === 0 && !showForm()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
<p style={{ margin: '0', 'font-size': '40px' }}>🗂</p>
<p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>No portfolio items yet</p>
<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>
</Show>
<Show when={!loading() && items().length > 0}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<For each={items()}>
{(item) => (
<div style={{ ...CARD, padding: '16px', display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
<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}>
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>{item.description}</p>
</Show>
<Show when={item.tags && item.tags.length > 0}>
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
<For each={item.tags}>
{(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>}
</For>
</div>
</Show>
<div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}>
<button type="button" onClick={() => openEdit(item)} style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}>Edit</button>
<button
type="button"
onClick={() => handleDelete(item.id)}
disabled={deleting() === item.id}
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' }}
>
{deleting() === item.id ? '…' : 'Delete'}
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>

View file

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

View file

@ -35,6 +35,41 @@ function clearAuthStorage() {
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> {
const token = getToken();
if (!token) return null;
@ -49,11 +84,12 @@ async function fetchSession(): Promise<AuthUser | null> {
if (!res.ok) return null;
const data = await res.json();
if (!data?.id && !data?.user_id) return null;
const resolvedActiveRole = resolveActiveRole(data.active_role || data.role, data.email || '');
return {
id: data.id || data.user_id,
email: data.email || '',
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,
};
} catch {
@ -66,7 +102,7 @@ export function AuthProvider(props: ParentProps) {
const [session] = createResource(fetchSession);
const isLoading = () => session.loading;
const isAuthenticated = () => !!user() || (!!session() && !session.error);
const isAuthenticated = () => !!user() || !!getToken() || (!!session() && !session.error);
if (session()) {
setUser(session() as AuthUser | null);

View file

@ -33,7 +33,11 @@ async function proxyRequest(method: string, request: Request, params: any) {
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
const url = new URL(request.url);

View file

@ -1,8 +1,8 @@
import { A } from '@solidjs/router';
import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader';
import PublicFooter from '~/components/PublicFooter';
import { A } from "@solidjs/router";
import { Show, createMemo, createSignal, onCleanup, onMount } from "solid-js";
import PublicBackground from "~/components/PublicBackground";
import PublicHeader from "~/components/PublicHeader";
import PublicFooter from "~/components/PublicFooter";
type FormValues = {
fullName: string;
@ -17,31 +17,31 @@ type FormValues = {
type FormErrors = Partial<Record<keyof FormValues, string>>;
const initialValues: FormValues = {
fullName: '',
email: '',
phone: '',
userType: '',
topic: '',
message: '',
fullName: "",
email: "",
phone: "",
userType: "",
topic: "",
message: "",
attachment: null,
};
const userTypes = [
'Customer (Hire professional)',
'Company (Post job)',
'Professional (Provide services)',
'Job Seeker (Apply jobs)',
"Customer (Hire professional)",
"Company (Post job)",
"Professional (Provide services)",
"Job Seeker (Apply jobs)",
] as const;
const topics = [
'Account & Login',
'Verification',
'Posting a Job',
'Posting a Requirement',
'Leads / Matching',
'Payments / Credits',
'Bug Report',
'Other',
"Account & Login",
"Verification",
"Posting a Job",
"Posting a Requirement",
"Leads / Matching",
"Payments / Credits",
"Bug Report",
"Other",
] as const;
function IconMail() {
@ -75,22 +75,27 @@ export default function ContactPage() {
const [values, setValues] = createSignal<FormValues>(initialValues);
const [errors, setErrors] = createSignal<FormErrors>({});
const [submitted, setSubmitted] = createSignal(false);
const [submitting, setSubmitting] = createSignal(false);
const [showBackToTop, setShowBackToTop] = createSignal(false);
const [scrollY, setScrollY] = createSignal(0);
const [err, setErr] = createSignal("");
const validate = (v: FormValues): FormErrors => {
const next: FormErrors = {};
if (!v.fullName.trim()) next.fullName = 'Full name is required.';
if (!v.email.trim()) next.email = 'Email is required.';
if (v.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim())) next.email = 'Enter a valid email.';
if (!v.userType.trim()) next.userType = 'Please select user type.';
if (!v.topic.trim()) next.topic = 'Please select a topic.';
if (!v.message.trim()) next.message = 'Message is required.';
if (v.message.trim() && v.message.trim().length < 20) next.message = 'Message must be at least 20 characters.';
if (!v.fullName.trim()) next.fullName = "Full name is required.";
if (!v.email.trim()) next.email = "Email is required.";
if (v.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim()))
next.email = "Enter a valid email.";
if (!v.userType.trim()) next.userType = "Please select user type.";
if (!v.topic.trim()) next.topic = "Please select a topic.";
if (!v.message.trim()) next.message = "Message is required.";
if (v.message.trim() && v.message.trim().length < 20)
next.message = "Message must be at least 20 characters.";
if (v.attachment) {
if (v.attachment.size > 10 * 1024 * 1024) next.attachment = 'Attachment must be 10MB or smaller.';
const allowed = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg'];
if (!allowed.includes(v.attachment.type)) next.attachment = 'Allowed formats: PDF, PNG, JPG.';
if (v.attachment.size > 10 * 1024 * 1024)
next.attachment = "Attachment must be 10MB or smaller.";
const allowed = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
if (!allowed.includes(v.attachment.type)) next.attachment = "Allowed formats: PDF, PNG, JPG.";
}
return next;
};
@ -107,8 +112,8 @@ export default function ContactPage() {
setScrollY(window.scrollY || 0);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
onCleanup(() => window.removeEventListener('scroll', onScroll));
window.addEventListener("scroll", onScroll, { passive: true });
onCleanup(() => window.removeEventListener("scroll", onScroll));
});
return (
@ -137,94 +142,208 @@ export default function ContactPage() {
<div class="contact-layout-grid">
<form
class="card glass-light contact-form-card"
onSubmit={(event) => {
onSubmit={async (event) => {
event.preventDefault();
const nextErrors = validate(values());
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
setSubmitted(true);
setValues(initialValues);
window.setTimeout(() => setSubmitted(false), 3200);
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);
setValues(initialValues);
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">
<span class="label">Full Name *</span>
<input class="input" value={values().fullName} onInput={(e) => update('fullName', e.currentTarget.value)} />
<Show when={errors().fullName}><p class="error">{errors().fullName}</p></Show>
<input
class="input"
value={values().fullName}
onInput={(e) => update("fullName", e.currentTarget.value)}
/>
<Show when={errors().fullName}>
<p class="error">{errors().fullName}</p>
</Show>
</label>
<label class="field">
<span class="label">Email *</span>
<input class="input" type="email" value={values().email} onInput={(e) => update('email', e.currentTarget.value)} />
<Show when={errors().email}><p class="error">{errors().email}</p></Show>
<input
class="input"
type="email"
value={values().email}
onInput={(e) => update("email", e.currentTarget.value)}
/>
<Show when={errors().email}>
<p class="error">{errors().email}</p>
</Show>
</label>
</div>
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
<div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
<label class="field">
<span class="label">Phone</span>
<input class="input" value={values().phone} onInput={(e) => update('phone', e.currentTarget.value)} />
<input
class="input"
value={values().phone}
onInput={(e) => update("phone", e.currentTarget.value)}
/>
</label>
<label class="field">
<span class="label">User Type *</span>
<select class="select" value={values().userType} onInput={(e) => update('userType', e.currentTarget.value)}>
<select
class="select"
value={values().userType}
onInput={(e) => update("userType", e.currentTarget.value)}
>
<option value="">Select user type</option>
{userTypes.map((type) => (
<option value={type}>{type}</option>
))}
</select>
<Show when={errors().userType}><p class="error">{errors().userType}</p></Show>
<Show when={errors().userType}>
<p class="error">{errors().userType}</p>
</Show>
</label>
</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">
<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>
{topics.map((topic) => (
<option value={topic}>{topic}</option>
))}
</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 class="field">
<span class="label">Attachment</span>
<label class="contact-upload">
<span class="contact-upload-icon" aria-hidden="true"></span>
<span class="contact-upload-text">{values().attachment ? values().attachment.name : 'Upload pdf/png/jpg (max 10MB)'}</span>
<span class="contact-upload-icon" aria-hidden="true">
</span>
<span class="contact-upload-text">
{values().attachment
? values().attachment.name
: "Upload pdf/png/jpg (max 10MB)"}
</span>
<input
class="contact-upload-input"
type="file"
accept=".pdf,.png,.jpg,.jpeg"
onChange={(e) => update('attachment', e.currentTarget.files?.[0] ?? null)}
onChange={(e) => update("attachment", e.currentTarget.files?.[0] ?? null)}
/>
</label>
<Show when={errors().attachment}><p class="error">{errors().attachment}</p></Show>
<Show when={errors().attachment}>
<p class="error">{errors().attachment}</p>
</Show>
</label>
</div>
<label class="field">
<span class="label">Message *</span>
<textarea class="textarea" value={values().message} onInput={(e) => update('message', e.currentTarget.value)} />
<Show when={errors().message}><p class="error">{errors().message}</p></Show>
<textarea
class="textarea"
value={values().message}
onInput={(e) => update("message", e.currentTarget.value)}
/>
<Show when={errors().message}>
<p class="error">{errors().message}</p>
</Show>
</label>
<div class="hero-actions">
<button class="lp-primary-btn" type="submit" disabled={!canSubmit()}>Send message</button>
<button class="lp-ghost-btn" type="button" onClick={() => { setValues(initialValues); setErrors({}); }}>Reset</button>
<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>
</form>
<aside class="card glass-dark contact-side-card">
<h3>Contact details</h3>
<p class="sub contact-detail"><span class="contact-icon"><IconMail /></span>support@nxtgauge.com</p>
<p class="sub contact-detail"><span class="contact-icon"><IconClock /></span>Typically within 2448 hours</p>
<p class="sub contact-detail"><span class="contact-icon"><IconPin /></span>Remote-first, India</p>
<p class="sub contact-detail">
<span class="contact-icon">
<IconMail />
</span>
support@nxtgauge.com
</p>
<p class="sub contact-detail">
<span class="contact-icon">
<IconClock />
</span>
Typically within 2448 hours
</p>
<p class="sub contact-detail">
<span class="contact-icon">
<IconPin />
</span>
Remote-first, India
</p>
<div class="hero-actions">
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/about">About Us</A>
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/#faqs">FAQs</A>
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/about">
About Us
</A>
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/#faqs">
FAQs
</A>
</div>
</aside>
</div>
@ -236,23 +355,63 @@ export default function ContactPage() {
<h2 class="center">Common Questions</h2>
<p class="center sub contact-quick-clarity">Quick clarity before you raise a query.</p>
<div class="contact-mini-faq-grid">
<article class="contact-mini-faq-card"><h3>Approval time</h3><p>Most profile and listing approvals are completed in 2448 hours.</p></article>
<article class="contact-mini-faq-card"><h3>Verification</h3><p>Verification is required to reduce spam and improve trust.</p></article>
<article class="contact-mini-faq-card"><h3>Posting flow</h3><p>You can submit multiple requirements and jobs after onboarding.</p></article>
<article class="contact-mini-faq-card">
<h3>Approval time</h3>
<p>Most profile and listing approvals are completed in 2448 hours.</p>
</article>
<article class="contact-mini-faq-card">
<h3>Verification</h3>
<p>Verification is required to reduce spam and improve trust.</p>
</article>
<article class="contact-mini-faq-card">
<h3>Posting flow</h3>
<p>You can submit multiple requirements and jobs after onboarding.</p>
</article>
</div>
</div>
</section>
<Show when={submitted()}>
<div style={{ position: 'fixed', right: '16px', top: '88px', 'z-index': 90, padding: '10px 14px', 'border-radius': '12px', border: '1px solid rgba(255,255,255,0.25)', background: 'rgba(16,11,47,0.88)', color: 'white', 'font-weight': 700 }}>
<div
style={{
position: "fixed",
right: "16px",
top: "88px",
"z-index": 90,
padding: "10px 14px",
"border-radius": "12px",
border: "1px solid rgba(255,255,255,0.25)",
background: "rgba(16,11,47,0.88)",
color: "white",
"font-weight": 700,
}}
>
Message sent. We'll reply soon.
</div>
</Show>
<Show when={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 />
<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>
</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';
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() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
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 [submitting, setSubmitting] = createSignal(false);
const [roleGuess, setRoleGuess] = createSignal<RoleKey>('job_seeker');
const [roleHint, setRoleHint] = createSignal('');
const [checkingRole, setCheckingRole] = createSignal(false);
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 clean = value.replace(/\D/g, '').slice(0, 1);
setOtp((prev) => {
@ -65,13 +154,14 @@ export default function LoginRoute() {
const fullName = String(user?.full_name || user?.fullName || '').trim();
const [firstName, ...rest] = fullName.split(' ');
const lastName = rest.join(' ');
const normalizedRole = String(user?.active_role || user?.role || roleGuess() || '')
.trim()
.toUpperCase()
.replace(/\s+/g, '_');
const normalizedRole = resolveActiveRole(
user?.active_role || user?.role || roleGuess(),
String(user?.email || email())
);
const storedRole = normalizedRole
? normalizedRole.toLowerCase()
: roleGuess();
const selectedProfessionalRole = getStoredPreferredRole(String(user?.email || email()));
const payload = {
firstName: firstName || '',
lastName: lastName || '',
@ -82,6 +172,7 @@ export default function LoginRoute() {
roleKey: storedRole,
role: storedRole,
active_role: normalizedRole || 'JOB_SEEKER',
selectedProfessionalRole: selectedProfessionalRole || null,
user,
};
if (typeof window !== 'undefined') {
@ -92,6 +183,7 @@ export default function LoginRoute() {
};
const login = async () => {
if (submitting()) return;
setError('');
if (!isValidEmail(email())) {
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_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) {
auth.setUser({
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,
});
auth.setUser(userPayload);
}
navigate('/dashboard', { replace: true });
} catch {
setError('Network error during login. Please try again.');
} finally {
setSubmitting(false);
}
};
const resendOtp = async () => {
if (submitting()) return;
setError('');
setSubmitting(true);
try {
@ -171,6 +272,7 @@ export default function LoginRoute() {
};
const verifyThenLogin = async () => {
if (submitting()) return;
setError('');
if (otpCode().length !== 6) {
setError('Enter a valid 6-digit OTP.');
@ -216,10 +318,29 @@ export default function LoginRoute() {
<div class="field">
<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' }}>
{email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format'}
</p>
<Show when={roleHint() || checkingRole()}>
<p class="validation-note" style={{ color: '#0f766e' }}>
{checkingRole() ? 'Checking account role...' : `${roleHint()}`}
</p>
</Show>
</div>
<div class="field">

View file

@ -1,8 +1,8 @@
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader';
import CaptchaCanvas from '~/components/CaptchaCanvas';
import { A, useNavigate, useSearchParams } from "@solidjs/router";
import { createMemo, createSignal, For, onMount, Show } from "solid-js";
import PublicBackground from "~/components/PublicBackground";
import PublicHeader from "~/components/PublicHeader";
import CaptchaCanvas from "~/components/CaptchaCanvas";
import {
checkPasswordStrength,
isPasswordStrong,
@ -10,24 +10,36 @@ import {
isValidEmail,
isValidName,
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>;
function normalizeIntent(intent: string | null | undefined): RoleKey {
const v = String(intent || '').toLowerCase();
if (v.includes('company')) return 'company';
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 (v.includes('customer')) return 'customer';
return 'job_seeker';
const v = String(intent || "").toLowerCase();
if (v.includes("company")) return "company";
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 (v.includes("customer")) return "customer";
return "job_seeker";
}
function randomCaptcha(length = 6): string {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let out = '';
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = "";
for (let i = 0; i < length; i += 1) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
@ -61,48 +73,53 @@ export default function SignupRoute() {
// If no intent/role provided, normalizeIntent will default to job_seeker.
});
const [step, setStep] = createSignal<'register' | 'verify'>('register');
const [firstName, setFirstName] = createSignal('');
const [lastName, setLastName] = createSignal('');
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
const [step, setStep] = createSignal<"register" | "verify">("register");
const [firstName, setFirstName] = createSignal("");
const [lastName, setLastName] = createSignal("");
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [confirmPassword, setConfirmPassword] = createSignal("");
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 [captcha, setCaptcha] = createSignal('');
const [captcha, setCaptcha] = createSignal("");
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
const [otp, setOtp] = createSignal(['', '', '', '', '', '']);
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
const [errors, setErrors] = createSignal<RegisterErrors>({});
const [serverError, setServerError] = createSignal('');
const [serverError, setServerError] = createSignal("");
const [emailExists, setEmailExists] = createSignal(false);
const [submitting, setSubmitting] = createSignal(false);
const [pendingEmail, setPendingEmail] = createSignal('');
const [pendingEmail, setPendingEmail] = createSignal("");
const [verifiedSuccess, setVerifiedSuccess] = createSignal(false);
const [showPassword, setShowPassword] = createSignal(false);
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
const passwordChecks = createMemo(() => checkPasswordStrength(password(), confirmPassword()));
const otpCode = createMemo(() => otp().join(''));
const otpCode = createMemo(() => otp().join(""));
const firstNameValid = createMemo(() => !firstName().trim() || isValidName(firstName()));
const lastNameValid = createMemo(() => !lastName().trim() || isValidName(lastName()));
const emailValid = createMemo(() => !email().trim() || isValidEmail(email()));
const canSubmit = createMemo(() =>
firstName().trim().length > 0 &&
firstNameValid() &&
lastName().trim().length > 0 &&
lastNameValid() &&
emailValid() &&
isValidEmail(email()) &&
isPasswordStrong(passwordChecks()) &&
passwordChecks().match &&
isValidCaptcha(captcha(), captchaCode()) &&
termsAccepted() &&
!emailExists()
const canSubmit = createMemo(
() =>
firstName().trim().length > 0 &&
firstNameValid() &&
lastName().trim().length > 0 &&
lastNameValid() &&
emailValid() &&
isValidEmail(email()) &&
isPasswordStrong(passwordChecks()) &&
passwordChecks().match &&
isValidCaptcha(captcha(), captchaCode()) &&
termsAccepted() &&
!emailExists()
);
const refreshCaptcha = () => {
setCaptcha('');
setCaptcha("");
setCaptchaCode(randomCaptcha());
};
@ -114,10 +131,10 @@ export default function SignupRoute() {
}
try {
const response = await fetch('/api/gateway/api/auth/check-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
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(() => ({}));
@ -131,7 +148,7 @@ export default function SignupRoute() {
};
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) => {
const next = prev.slice();
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 payload = {
firstName: input.firstName,
@ -157,15 +180,15 @@ export default function SignupRoute() {
selectedProfessionalRole: selectedProfessionalRole() || null,
user: input.user || null,
};
if (typeof window !== 'undefined') {
window.localStorage.setItem('nxtgauge_signup_profile_v1', JSON.stringify(payload));
window.localStorage.setItem('nxtgauge_auth_user', JSON.stringify(payload));
window.localStorage.setItem('nxtgauge_user', JSON.stringify(payload));
if (typeof window !== "undefined") {
window.localStorage.setItem("nxtgauge_signup_profile_v1", JSON.stringify(payload));
window.localStorage.setItem("nxtgauge_auth_user", JSON.stringify(payload));
window.localStorage.setItem("nxtgauge_user", JSON.stringify(payload));
}
};
const register = async () => {
setServerError('');
setServerError("");
const validation = validateRegisterForm({
firstName: firstName(),
lastName: lastName(),
@ -181,23 +204,23 @@ export default function SignupRoute() {
setSubmitting(true);
try {
const res = await fetch('/api/gateway/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "include",
body: JSON.stringify({
full_name: `${firstName().trim()} ${lastName().trim()}`.trim(),
email: email().trim().toLowerCase(),
password: password(),
phone: null,
phone: "",
intent: role(),
profession: selectedProfessionalRole() || undefined,
role_key: selectedProfessionalRole() || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setServerError(String(data?.error || data?.message || 'Unable to create account.'));
setServerError(String(data?.error || data?.message || "Unable to create account."));
refreshCaptcha();
return;
}
@ -211,52 +234,52 @@ export default function SignupRoute() {
email: cleanEmail,
roleKey: role(),
});
setStep('verify');
setOtp(['', '', '', '', '', '']);
setStep("verify");
setOtp(["", "", "", "", "", ""]);
} finally {
setSubmitting(false);
}
};
const verifyOtp = async () => {
setServerError('');
setServerError("");
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;
}
setSubmitting(true);
try {
const verifyRes = await fetch('/api/gateway/api/auth/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
const verifyRes = await fetch("/api/gateway/api/auth/verify-email", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "include",
body: JSON.stringify({ otp: otpCode() }),
});
const verifyData = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) {
setServerError(String(verifyData?.error || verifyData?.message || 'Verification failed.'));
setServerError(String(verifyData?.error || verifyData?.message || "Verification failed."));
return;
}
setVerifiedSuccess(true);
setTimeout(() => navigate('/login?verified=1', { replace: true }), 1400);
setTimeout(() => navigate("/login?verified=1", { replace: true }), 1400);
} finally {
setSubmitting(false);
}
};
const resendOtp = async () => {
setServerError('');
setServerError("");
setSubmitting(true);
try {
const res = await fetch('/api/gateway/api/auth/resend-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
const res = await fetch("/api/gateway/api/auth/resend-otp", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "include",
body: JSON.stringify({ email: pendingEmail() || email().trim().toLowerCase() }),
});
const data = await res.json().catch(() => ({}));
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 {
setSubmitting(false);
@ -274,73 +297,135 @@ export default function SignupRoute() {
<div class="auth-visual-content">
<p class="eyebrow">Get Started</p>
<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>
</section>
<section class="auth-form card glass-light">
<Show when={step() === 'register'} fallback={
<>
<h2 class="title">Verify Email</h2>
<p class="subtitle">Enter the 6-digit code sent to <strong>{pendingEmail() || email()}</strong>.</p>
<Show
when={step() === "register"}
fallback={
<>
<h2 class="title">Verify Email</h2>
<p class="subtitle">
Enter the 6-digit code sent to <strong>{pendingEmail() || email()}</strong>.
</p>
<Show when={!verifiedSuccess()} fallback={
<div 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 class="otp-row">
<For each={Array.from({ length: 6 }, (_, index) => index)}>
{(index) => (
<input
id={`otp-${index}`}
class="otp-input"
inputMode="numeric"
maxlength={1}
value={otp()[index]}
onInput={(e) => setOtpDigit(index, e.currentTarget.value)}
/>
)}
</For>
</div>
<Show
when={!verifiedSuccess()}
fallback={
<div
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 class="otp-row">
<For each={Array.from({ length: 6 }, (_, index) => index)}>
{(index) => (
<input
id={`otp-${index}`}
class="otp-input"
inputMode="numeric"
maxlength={1}
value={otp()[index]}
onInput={(e) => setOtpDigit(index, e.currentTarget.value)}
/>
)}
</For>
</div>
<button class="auth-submit-btn" type="button" disabled={submitting()} onClick={() => void verifyOtp()}>
{submitting() ? 'Verifying...' : 'Verify and Continue'}
</button>
<div class="auth-footer-row">
<p class="note">Didnt receive code?</p>
<button class="auth-forgot-link" type="button" onClick={() => void resendOtp()} disabled={submitting()}>
Resend OTP
<button
class="auth-submit-btn"
type="button"
disabled={submitting()}
onClick={() => void verifyOtp()}
>
{submitting() ? "Verifying..." : "Verify and Continue"}
</button>
</div>
</Show>
</>
}>
<h2 class="title">Create Your Account</h2>
<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="auth-footer-row">
<p class="note">Didnt receive code?</p>
<button
class="auth-forgot-link"
type="button"
onClick={() => void resendOtp()}
disabled={submitting()}
>
Resend OTP
</button>
</div>
</Show>
</>
}
>
<h2 class="title">Create Your Account</h2>
<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="field">
<label class="label" for="first-name">FULL NAME</label>
<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'}
<label class="label" for="first-name">
FULL NAME
</label>
<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>
</div>
<div class="field">
<label class="label" for="last-name">LAST NAME</label>
<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'}
<label class="label" for="last-name">
LAST NAME
</label>
<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>
</div>
</div>
<div class="field">
<label class="label" for="email">EMAIL ADDRESS</label>
<label class="label" for="email">
EMAIL ADDRESS
</label>
<input
id="email"
type="email"
@ -350,82 +435,168 @@ export default function SignupRoute() {
setEmail(e.currentTarget.value);
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()
? '• This email is already registered'
: (email().trim() && emailValid() ? '✓ Valid email format' : '• Enter a valid email format')}
? "• This email is already registered"
: email().trim() && emailValid()
? "✓ Valid email format"
: "• Enter a valid email format"}
</p>
</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">
<label class="label" for="password">PASSWORD</label>
<label class="label" for="password">
PASSWORD
</label>
<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
class="auth-toggle-visibility"
type="button"
onClick={() => setShowPassword((prev) => !prev)}
aria-label={showPassword() ? 'Hide password' : 'Show password'}
aria-label={showPassword() ? "Hide password" : "Show password"}
>
<PasswordVisibilityIcon visible={showPassword()} />
</button>
</div>
<div class="password-strength-grid">
<p style={{ color: passwordChecks().minLength ? '#fd6116' : '#6e7591' }}>{passwordChecks().minLength ? '✓' : '•'} 8+ chars</p>
<p style={{ color: passwordChecks().uppercase ? '#fd6116' : '#6e7591' }}>{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>
<p style={{ color: passwordChecks().minLength ? "#fd6116" : "#6e7591" }}>
{passwordChecks().minLength ? "✓" : "•"} 8+ chars
</p>
<p style={{ color: passwordChecks().uppercase ? "#fd6116" : "#6e7591" }}>
{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 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">
<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
class="auth-toggle-visibility"
type="button"
onClick={() => setShowConfirmPassword((prev) => !prev)}
aria-label={showConfirmPassword() ? 'Hide password' : 'Show password'}
aria-label={showConfirmPassword() ? "Hide password" : "Show password"}
>
<PasswordVisibilityIcon visible={showConfirmPassword()} />
</button>
</div>
<p class="validation-note" style={{ color: confirmPassword() && passwordChecks().match ? '#fd6116' : '#6e7591' }}>
{confirmPassword() && passwordChecks().match ? '✓ Passwords match' : '• Passwords do not match'}
<p
class="validation-note"
style={{
color: confirmPassword() && passwordChecks().match ? "#fd6116" : "#6e7591",
}}
>
{confirmPassword() && passwordChecks().match
? "✓ Passwords match"
: "• Passwords do not match"}
</p>
</div>
</div>
<div class="field">
<label class="label" for="captcha">CAPTCHA</label>
<label class="label" for="captcha">
CAPTCHA
</label>
<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" />
<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>
<p 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
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>
</div>
<div class="field" style={{ 'margin-top': '16px' }}>
<div class="field" style={{ "margin-top": "16px" }}>
<label class="auth-checkbox-wrapper">
<input 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>
<input
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>
</div>
<button class="auth-submit-btn" type="button" disabled={submitting() || !canSubmit()} onClick={() => void register()}>
{submitting() ? 'Creating Account...' : 'Sign Up'}
<button
class="auth-submit-btn"
type="button"
disabled={submitting() || !canSubmit()}
onClick={() => void register()}
>
{submitting() ? "Creating Account..." : "Sign Up"}
</button>
<div class="auth-footer-row">
<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>
</Show>

View file

@ -1,8 +1,24 @@
/// <reference types="vitest/config" />
import { defineConfig } from '@solidjs/start/config';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
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,
},
},
},
},
});