fix: update forms to match DB schema - first_name/last_name, role keys
This commit is contained in:
parent
c2cbafc159
commit
5922b98c93
12 changed files with 1348 additions and 650 deletions
0
frontend-solid.dev.log
Normal file
0
frontend-solid.dev.log
Normal file
1
frontend-solid.dev.pid
Normal file
1
frontend-solid.dev.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
7995
|
||||
|
|
@ -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")}`
|
||||
: ""}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (2–3 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 (2–3 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 (2–3 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 (2–3 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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 24–48 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 24–48 hours
|
||||
</p>
|
||||
<p class="sub contact-detail">
|
||||
<span class="contact-icon">
|
||||
<IconPin />
|
||||
</span>
|
||||
Remote-first, India
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<A class="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 24–48 hours.</p></article>
|
||||
<article class="contact-mini-faq-card"><h3>Verification</h3><p>Verification is required to reduce spam and improve trust.</p></article>
|
||||
<article class="contact-mini-faq-card"><h3>Posting flow</h3><p>You can submit multiple requirements and jobs after onboarding.</p></article>
|
||||
<article class="contact-mini-faq-card">
|
||||
<h3>Approval time</h3>
|
||||
<p>Most profile and listing approvals are completed in 24–48 hours.</p>
|
||||
</article>
|
||||
<article class="contact-mini-faq-card">
|
||||
<h3>Verification</h3>
|
||||
<p>Verification is required to reduce spam and improve trust.</p>
|
||||
</article>
|
||||
<article class="contact-mini-faq-card">
|
||||
<h3>Posting flow</h3>
|
||||
<p>You can submit multiple requirements and jobs after onboarding.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Show when={submitted()}>
|
||||
<div style={{ position: 'fixed', right: '16px', top: '88px', 'z-index': 90, padding: '10px 14px', 'border-radius': '12px', border: '1px solid rgba(255,255,255,0.25)', background: 'rgba(16,11,47,0.88)', color: 'white', 'font-weight': 700 }}>
|
||||
<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>
|
||||
|
|
|
|||
1
src/routes/dashboard/index.tsx
Normal file
1
src/routes/dashboard/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from "../dashboard";
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">Didn’t 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">Didn’t 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue