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;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
budget_min?: number | null;
|
budget_inr?: number | null;
|
||||||
budget_max?: number | null;
|
|
||||||
area?: string | null;
|
area?: string | null;
|
||||||
city?: string | null;
|
location?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -44,7 +43,7 @@ export default function CustomerRequirementsPage() {
|
||||||
budget_min: "",
|
budget_min: "",
|
||||||
budget_max: "",
|
budget_max: "",
|
||||||
area: "",
|
area: "",
|
||||||
city: "",
|
location: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadRequirements = async () => {
|
const loadRequirements = async () => {
|
||||||
|
|
@ -79,10 +78,12 @@ export default function CustomerRequirementsPage() {
|
||||||
const payload = {
|
const payload = {
|
||||||
title: form().title.trim(),
|
title: form().title.trim(),
|
||||||
description: form().description.trim() || undefined,
|
description: form().description.trim() || undefined,
|
||||||
budget_min: form().budget_min ? Number(form().budget_min) : undefined,
|
budget_inr:
|
||||||
budget_max: form().budget_max ? Number(form().budget_max) : undefined,
|
form().budget_min || form().budget_max
|
||||||
|
? Number(form().budget_min) || Number(form().budget_max)
|
||||||
|
: undefined,
|
||||||
area: form().area.trim() || undefined,
|
area: form().area.trim() || undefined,
|
||||||
city: form().city.trim() || undefined,
|
location: form().city.trim() || undefined,
|
||||||
};
|
};
|
||||||
const res = await apiFetch("/api/customers/requirements", {
|
const res = await apiFetch("/api/customers/requirements", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -94,7 +95,14 @@ export default function CustomerRequirementsPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMsg("Requirement created.");
|
setMsg("Requirement created.");
|
||||||
setForm({ title: "", description: "", budget_min: "", budget_max: "", area: "", city: "" });
|
setForm({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
budget_min: "",
|
||||||
|
budget_max: "",
|
||||||
|
area: "",
|
||||||
|
location: "",
|
||||||
|
});
|
||||||
await loadRequirements();
|
await loadRequirements();
|
||||||
} catch {
|
} catch {
|
||||||
setErr("Network error while creating requirement.");
|
setErr("Network error while creating requirement.");
|
||||||
|
|
@ -193,10 +201,10 @@ export default function CustomerRequirementsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={LABEL}>City</label>
|
<label style={LABEL}>Location</label>
|
||||||
<input
|
<input
|
||||||
value={form().city}
|
value={form().location}
|
||||||
onInput={(e) => setField("city", e.currentTarget.value)}
|
onInput={(e) => setField("location", e.currentTarget.value)}
|
||||||
style={INPUT}
|
style={INPUT}
|
||||||
placeholder="Chennai"
|
placeholder="Chennai"
|
||||||
/>
|
/>
|
||||||
|
|
@ -303,7 +311,7 @@ export default function CustomerRequirementsPage() {
|
||||||
{row.title}
|
{row.title}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
|
<p style={{ margin: "4px 0 0", "font-size": "12px", color: "#6B7280" }}>
|
||||||
{row.city || "—"} {row.area ? `• ${row.area}` : ""}{" "}
|
{row.location || row.city || "—"} {row.area ? `• ${row.area}` : ""}{" "}
|
||||||
{row.created_at
|
{row.created_at
|
||||||
? `• ${new Date(row.created_at).toLocaleString("en-IN")}`
|
? `• ${new Date(row.created_at).toLocaleString("en-IN")}`
|
||||||
: ""}
|
: ""}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,26 @@ const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = {
|
||||||
skills: '',
|
skills: '',
|
||||||
};
|
};
|
||||||
const JOB_SEEKER_FALLBACK_TABS = ['About', 'Education', 'Work Experience', 'Skills'];
|
const JOB_SEEKER_FALLBACK_TABS = ['About', 'Education', 'Work Experience', 'Skills'];
|
||||||
|
const PROFESSIONAL_PORTFOLIO_TABS: Record<string, string[]> = {
|
||||||
|
DEVELOPER: ['About', 'Services & Pricing', 'Projects', 'Tech Stack & Experience', 'Testimonials', 'FAQs'],
|
||||||
|
default: ['About', 'Services & Pricing', 'Projects', 'Experience', 'Testimonials', 'FAQs'],
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProfessionalPortfolioState = {
|
||||||
|
about: string;
|
||||||
|
services: string;
|
||||||
|
experience: string;
|
||||||
|
testimonials: string;
|
||||||
|
faqs: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_PROFESSIONAL_FORM: ProfessionalPortfolioState = {
|
||||||
|
about: '',
|
||||||
|
services: '',
|
||||||
|
experience: '',
|
||||||
|
testimonials: '',
|
||||||
|
faqs: '',
|
||||||
|
};
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -95,6 +115,9 @@ export default function PortfolioPage(props: Props) {
|
||||||
const [jobSeekerSaving, setJobSeekerSaving] = createSignal(false);
|
const [jobSeekerSaving, setJobSeekerSaving] = createSignal(false);
|
||||||
const [jobSeekerMsg, setJobSeekerMsg] = createSignal('');
|
const [jobSeekerMsg, setJobSeekerMsg] = createSignal('');
|
||||||
const [jobSeekerErr, setJobSeekerErr] = createSignal('');
|
const [jobSeekerErr, setJobSeekerErr] = createSignal('');
|
||||||
|
const [professionalTab, setProfessionalTab] = createSignal('About');
|
||||||
|
const [professionalForm, setProfessionalForm] = createSignal<ProfessionalPortfolioState>({ ...EMPTY_PROFESSIONAL_FORM });
|
||||||
|
const [professionalMsg, setProfessionalMsg] = createSignal('');
|
||||||
|
|
||||||
const loadItems = async () => {
|
const loadItems = async () => {
|
||||||
if (!isProfessional()) { setLoading(false); return; }
|
if (!isProfessional()) { setLoading(false); return; }
|
||||||
|
|
@ -196,6 +219,54 @@ export default function PortfolioPage(props: Props) {
|
||||||
return grouped;
|
return grouped;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const professionalTabs = () => {
|
||||||
|
const runtimeRaw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : [];
|
||||||
|
const fromRuntime = runtimeRaw
|
||||||
|
.map((tab) => toLabel(tab))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((tab) => {
|
||||||
|
const t = normalizeToken(tab);
|
||||||
|
if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About';
|
||||||
|
if (t.includes('service') || t.includes('pricing') || t.includes('package')) return 'Services & Pricing';
|
||||||
|
if (t.includes('project') || t.includes('portfolio') || t.includes('gallery') || t.includes('showreel')) return 'Projects';
|
||||||
|
if (t.includes('stack') || t.includes('experience') || t.includes('qualification') || t.includes('tool')) return props.roleKey === 'DEVELOPER' ? 'Tech Stack & Experience' : 'Experience';
|
||||||
|
if (t.includes('testimonial') || t.includes('review')) return 'Testimonials';
|
||||||
|
if (t.includes('faq') || t.includes('question')) return 'FAQs';
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
const uniqueRuntime = Array.from(new Set(fromRuntime));
|
||||||
|
if (uniqueRuntime.length >= 3) return uniqueRuntime;
|
||||||
|
return PROFESSIONAL_PORTFOLIO_TABS[props.roleKey] || PROFESSIONAL_PORTFOLIO_TABS.default;
|
||||||
|
};
|
||||||
|
|
||||||
|
const professionalFormStorageKey = () => `nxtgauge_portfolio_meta_${String(props.roleKey || 'professional').toLowerCase()}`;
|
||||||
|
const loadProfessionalForm = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(professionalFormStorageKey());
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<ProfessionalPortfolioState>;
|
||||||
|
setProfessionalForm({
|
||||||
|
about: String(parsed?.about || ''),
|
||||||
|
services: String(parsed?.services || ''),
|
||||||
|
experience: String(parsed?.experience || ''),
|
||||||
|
testimonials: String(parsed?.testimonials || ''),
|
||||||
|
faqs: String(parsed?.faqs || ''),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed local storage payloads.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveProfessionalForm = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(professionalFormStorageKey(), JSON.stringify(professionalForm()));
|
||||||
|
}
|
||||||
|
setProfessionalMsg('Portfolio section saved.');
|
||||||
|
window.setTimeout(() => setProfessionalMsg(''), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (isJobSeeker()) {
|
if (isJobSeeker()) {
|
||||||
loadJobSeekerPortfolio();
|
loadJobSeekerPortfolio();
|
||||||
|
|
@ -204,6 +275,11 @@ export default function PortfolioPage(props: Props) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isProfessional()) {
|
||||||
|
const tabs = professionalTabs();
|
||||||
|
setProfessionalTab(tabs[0] || 'About');
|
||||||
|
loadProfessionalForm();
|
||||||
|
}
|
||||||
void loadItems();
|
void loadItems();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -418,188 +494,175 @@ export default function PortfolioPage(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isProjectsTab = () => {
|
||||||
|
const key = normalizeToken(professionalTab());
|
||||||
|
return key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isServicesTab = () => {
|
||||||
|
const key = normalizeToken(professionalTab());
|
||||||
|
return key.includes('service') || key.includes('pricing') || key.includes('package');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTestimonialsTab = () => normalizeToken(professionalTab()).includes('testimonial');
|
||||||
|
const isFaqTab = () => normalizeToken(professionalTab()).includes('faq');
|
||||||
|
const sectionFieldKey = () => {
|
||||||
|
if (isServicesTab()) return 'services' as const;
|
||||||
|
if (isTestimonialsTab()) return 'testimonials' as const;
|
||||||
|
if (isFaqTab()) return 'faqs' as const;
|
||||||
|
const key = normalizeToken(professionalTab());
|
||||||
|
if (key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification')) return 'experience' as const;
|
||||||
|
return 'about' as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionPlaceholder = () => {
|
||||||
|
if (isServicesTab()) return 'List your plans, pricing slabs, and deliverables...';
|
||||||
|
if (isTestimonialsTab()) return 'Add client quotes and project outcomes...';
|
||||||
|
if (isFaqTab()) return 'Add common questions and answers...';
|
||||||
|
if (sectionFieldKey() === 'experience') return 'Share stack, years of experience, and toolchain...';
|
||||||
|
return 'Write a short summary about your profile and strengths...';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ 'max-width': '800px' }}>
|
<div style={{ 'max-width': '800px' }}>
|
||||||
|
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '0 16px' }}>
|
||||||
{/* ── Header ────────────────────────────────────────────────────── */}
|
<div style={{ display: 'flex', gap: '20px', 'border-bottom': '1px solid #E5E7EB', padding: '12px 0 0', 'flex-wrap': 'wrap' }}>
|
||||||
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'margin-bottom': '16px' }}>
|
<For each={professionalTabs()}>
|
||||||
|
{(tab) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setProfessionalTab(tab)}
|
||||||
|
style={{
|
||||||
|
padding: '0 0 10px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'font-size': '13px',
|
||||||
|
'font-weight': professionalTab() === tab ? '700' : '500',
|
||||||
|
color: professionalTab() === tab ? '#FF5E13' : '#6B7280',
|
||||||
|
'border-bottom': professionalTab() === tab ? '2px solid #FF5E13' : '2px solid transparent',
|
||||||
|
'margin-bottom': '-1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'padding': '14px 0' }}>
|
||||||
<div>
|
<div>
|
||||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p>
|
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p>
|
||||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
<p style={{ margin: '6px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||||
Showcase your work to attract clients.
|
Runtime-config driven tab layout aligned with external dashboard preview.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={isProjectsTab()}>
|
||||||
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
||||||
+ Add Item
|
+ Add Item
|
||||||
</button>
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Create / Edit form ─────────────────────────────────────────── */}
|
<Show
|
||||||
|
when={isProjectsTab()}
|
||||||
|
fallback={
|
||||||
|
<div style={{ ...CARD, display: 'grid', gap: '12px' }}>
|
||||||
|
<Show when={professionalMsg()}>
|
||||||
|
<div style={{ border: '1px solid #BBF7D0', background: '#ECFDF5', color: '#065F46', 'border-radius': '10px', padding: '10px 12px', 'font-size': '12px', 'font-weight': '600' }}>
|
||||||
|
{professionalMsg()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>{professionalTab()}</p>
|
||||||
|
<div>
|
||||||
|
<label style={LABEL}>Section Content</label>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={professionalForm()[sectionFieldKey()]}
|
||||||
|
onInput={(e) => setProfessionalForm((prev) => ({ ...prev, [sectionFieldKey()]: e.currentTarget.value }))}
|
||||||
|
placeholder={sectionPlaceholder()}
|
||||||
|
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
|
||||||
|
<button type="button" onClick={() => setProfessionalForm({ ...EMPTY_PROFESSIONAL_FORM })} style={BTN_GHOST}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={saveProfessionalForm} style={BTN_ORANGE}>
|
||||||
|
Save Section
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
<Show when={showForm()}>
|
<Show when={showForm()}>
|
||||||
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
|
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
|
||||||
<p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}>
|
<p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}>
|
||||||
{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}
|
{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
|
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
|
||||||
<div style={{ 'grid-column': 'span 2' }}>
|
<div style={{ 'grid-column': 'span 2' }}>
|
||||||
<label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label>
|
<label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label>
|
||||||
<input
|
<input type="text" placeholder="e.g. Developer dashboard rebuild" value={form().title} onInput={(e) => setField('title', e.currentTarget.value)} style={INPUT} />
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Wedding shoot at Udaipur"
|
|
||||||
value={form().title}
|
|
||||||
onInput={(e) => setField('title', e.currentTarget.value)}
|
|
||||||
style={INPUT}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ 'grid-column': 'span 2' }}>
|
<div style={{ 'grid-column': 'span 2' }}>
|
||||||
<label style={LABEL}>Description</label>
|
<label style={LABEL}>Description</label>
|
||||||
<textarea
|
<textarea rows={3} placeholder="Brief description of the project..." value={form().description} onInput={(e) => setField('description', e.currentTarget.value)} style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }} />
|
||||||
rows={3}
|
|
||||||
placeholder="Brief description of the work…"
|
|
||||||
value={form().description}
|
|
||||||
onInput={(e) => setField('description', e.currentTarget.value)}
|
|
||||||
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ 'grid-column': 'span 2' }}>
|
<div style={{ 'grid-column': 'span 2' }}>
|
||||||
<label style={LABEL}>Tags (comma separated)</label>
|
<label style={LABEL}>Tags (comma separated)</label>
|
||||||
<input
|
<input type="text" placeholder="e.g. solidjs, rust, dashboard" value={form().tags} onInput={(e) => setField('tags', e.currentTarget.value)} style={INPUT} />
|
||||||
type="text"
|
|
||||||
placeholder="e.g. wedding, outdoor, portrait"
|
|
||||||
value={form().tags}
|
|
||||||
onInput={(e) => setField('tags', e.currentTarget.value)}
|
|
||||||
style={INPUT}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>
|
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>{error()}</p>
|
||||||
{error()}
|
|
||||||
</p>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '10px', 'margin-top': '16px' }}>
|
<div style={{ display: 'flex', gap: '10px', 'margin-top': '16px' }}>
|
||||||
<button
|
<button type="button" onClick={handleSave} disabled={saving()} style={{ ...BTN_PRIMARY, opacity: saving() ? '0.6' : '1' }}>
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving()}
|
|
||||||
style={{ ...BTN_PRIMARY, opacity: saving() ? '0.6' : '1' }}
|
|
||||||
>
|
|
||||||
{saving() ? 'Saving…' : editId() ? 'Update Item' : 'Add Item'}
|
{saving() ? 'Saving…' : editId() ? 'Update Item' : 'Add Item'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={cancelForm} style={BTN_GHOST}>
|
<button type="button" onClick={cancelForm} style={BTN_GHOST}>Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* ── Loading ─────────────────────────────────────────────────────── */}
|
|
||||||
<Show when={loading()}>
|
<Show when={loading()}>
|
||||||
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
|
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
|
||||||
Loading portfolio…
|
Loading portfolio…
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* ── Empty state ─────────────────────────────────────────────────── */}
|
|
||||||
<Show when={!loading() && items().length === 0 && !showForm()}>
|
<Show when={!loading() && items().length === 0 && !showForm()}>
|
||||||
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
|
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
|
||||||
<p style={{ margin: '0', 'font-size': '40px' }}>🗂️</p>
|
<p style={{ margin: '0', 'font-size': '40px' }}>🗂️</p>
|
||||||
<p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>
|
<p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>No portfolio items yet</p>
|
||||||
No portfolio items yet
|
<p style={{ margin: '0 0 16px', 'font-size': '13px', color: '#6B7280' }}>Add your first work sample to attract clients.</p>
|
||||||
</p>
|
<button type="button" onClick={openCreate} style={BTN_ORANGE}>+ Add First Item</button>
|
||||||
<p style={{ margin: '0 0 16px', 'font-size': '13px', color: '#6B7280' }}>
|
|
||||||
Add your first work sample to attract clients.
|
|
||||||
</p>
|
|
||||||
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
|
||||||
+ Add First Item
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* ── Portfolio grid ─────────────────────────────────────────────── */}
|
|
||||||
<Show when={!loading() && items().length > 0}>
|
<Show when={!loading() && items().length > 0}>
|
||||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||||
<For each={items()}>
|
<For each={items()}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div style={{
|
<div style={{ ...CARD, padding: '16px', display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
|
||||||
...CARD,
|
<div style={{ height: '120px', 'border-radius': '8px', background: '#F3F4F6', display: 'flex', 'align-items': 'center', 'justify-content': 'center', color: '#D1D5DB', 'font-size': '28px' }}>🖼️</div>
|
||||||
padding: '16px',
|
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>{item.title}</p>
|
||||||
display: 'flex',
|
|
||||||
'flex-direction': 'column',
|
|
||||||
gap: '8px',
|
|
||||||
}}>
|
|
||||||
{/* Placeholder image area */}
|
|
||||||
<div style={{
|
|
||||||
height: '120px',
|
|
||||||
'border-radius': '8px',
|
|
||||||
background: '#F3F4F6',
|
|
||||||
display: 'flex',
|
|
||||||
'align-items': 'center',
|
|
||||||
'justify-content': 'center',
|
|
||||||
color: '#D1D5DB',
|
|
||||||
'font-size': '28px',
|
|
||||||
}}>
|
|
||||||
🖼️
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
|
|
||||||
{item.title}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Show when={item.description}>
|
<Show when={item.description}>
|
||||||
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>
|
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>{item.description}</p>
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={item.tags && item.tags.length > 0}>
|
<Show when={item.tags && item.tags.length > 0}>
|
||||||
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
|
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
|
||||||
<For each={item.tags}>
|
<For each={item.tags}>
|
||||||
{(tag) => (
|
{(tag) => <span style={{ 'font-size': '10px', 'font-weight': '700', color: '#6B7280', background: '#F3F4F6', border: '1px solid #E5E7EB', 'border-radius': '6px', padding: '2px 8px' }}>{tag}</span>}
|
||||||
<span style={{
|
|
||||||
'font-size': '10px',
|
|
||||||
'font-weight': '700',
|
|
||||||
color: '#6B7280',
|
|
||||||
background: '#F3F4F6',
|
|
||||||
border: '1px solid #E5E7EB',
|
|
||||||
'border-radius': '6px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
}}>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}>
|
<div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}>
|
||||||
<button
|
<button type="button" onClick={() => openEdit(item)} style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}>Edit</button>
|
||||||
type="button"
|
|
||||||
onClick={() => openEdit(item)}
|
|
||||||
style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(item.id)}
|
onClick={() => handleDelete(item.id)}
|
||||||
disabled={deleting() === item.id}
|
disabled={deleting() === item.id}
|
||||||
style={{
|
style={{ height: '30px', 'border-radius': '8px', border: '1px solid #FECACA', background: '#fff', color: '#EF4444', 'font-size': '11px', 'font-weight': '700', padding: '0 12px', cursor: 'pointer', flex: '1', opacity: deleting() === item.id ? '0.6' : '1' }}
|
||||||
height: '30px',
|
|
||||||
'border-radius': '8px',
|
|
||||||
border: '1px solid #FECACA',
|
|
||||||
background: '#fff',
|
|
||||||
color: '#EF4444',
|
|
||||||
'font-size': '11px',
|
|
||||||
'font-weight': '700',
|
|
||||||
padding: '0 12px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
flex: '1',
|
|
||||||
opacity: deleting() === item.id ? '0.6' : '1',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{deleting() === item.id ? '…' : 'Delete'}
|
{deleting() === item.id ? '…' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -610,5 +673,7 @@ export default function PortfolioPage(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,65 +3,86 @@
|
||||||
* Supports all 13 roles. Tabs: Basic Info · Documents.
|
* Supports all 13 roles. Tabs: Basic Info · Documents.
|
||||||
* User fills and saves freely; "Submit for Verification" locks and queues for admin.
|
* User fills and saves freely; "Submit for Verification" locks and queues for admin.
|
||||||
*/
|
*/
|
||||||
|
import { For, Match, Show, Switch, createEffect, createSignal, onMount } from "solid-js";
|
||||||
import {
|
import {
|
||||||
For, Match, Show, Switch, createEffect, createSignal, onMount,
|
CARD,
|
||||||
} from 'solid-js';
|
BTN_ORANGE,
|
||||||
import { CARD, BTN_ORANGE, BTN_GHOST, INPUT, LABEL, BTN_PRIMARY } from '~/components/DashboardShell';
|
BTN_GHOST,
|
||||||
|
INPUT,
|
||||||
|
LABEL,
|
||||||
|
BTN_PRIMARY,
|
||||||
|
} from "~/components/DashboardShell";
|
||||||
|
|
||||||
const API = '/api/gateway';
|
const API = "/api/gateway";
|
||||||
|
|
||||||
// ── Role-specific field definitions ──────────────────────────────────────────
|
// ── Role-specific field definitions ──────────────────────────────────────────
|
||||||
|
|
||||||
const BASIC_FIELDS: Record<string, Array<{ key: string; label: string; type?: string; required?: boolean; options?: string[] }>> = {
|
const BASIC_FIELDS: Record<
|
||||||
|
string,
|
||||||
|
Array<{ key: string; label: string; type?: string; required?: boolean; options?: string[] }>
|
||||||
|
> = {
|
||||||
default: [
|
default: [
|
||||||
{ key: 'first_name', label: 'First Name', required: true },
|
{ key: "first_name", label: "First Name", required: true },
|
||||||
{ key: 'last_name', label: 'Last Name', required: true },
|
{ key: "last_name", label: "Last Name", required: true },
|
||||||
{ key: 'phone', label: 'Mobile Number', required: true },
|
{ key: "phone", label: "Mobile Number", required: true },
|
||||||
{ key: 'gender', label: 'Gender', type: 'select', options: ['Male', 'Female', 'Other', 'Prefer not to say'] },
|
{
|
||||||
{ key: 'city', label: 'City', required: true },
|
key: "gender",
|
||||||
{ key: 'state', label: 'State', required: true },
|
label: "Gender",
|
||||||
{ key: 'pin_code', label: 'PIN Code' },
|
type: "select",
|
||||||
{ key: 'address', label: 'Address', type: 'textarea' },
|
options: ["Male", "Female", "Other", "Prefer not to say"],
|
||||||
|
},
|
||||||
|
{ key: "location", label: "City", required: true },
|
||||||
|
{ key: "state", label: "State", required: true },
|
||||||
|
{ key: "pin_code", label: "PIN Code" },
|
||||||
|
{ key: "address", label: "Address", type: "textarea" },
|
||||||
],
|
],
|
||||||
COMPANY: [
|
COMPANY: [
|
||||||
{ key: 'company_name', label: 'Company Name', required: true },
|
{ key: "company_name", label: "Company Name", required: true },
|
||||||
{ key: 'company_email', label: 'Company Email', type: 'email', required: true },
|
{ key: "company_email", label: "Company Email", type: "email", required: true },
|
||||||
{ key: 'company_phone', label: 'Company Phone' },
|
{ key: "company_phone", label: "Company Phone" },
|
||||||
{ key: 'website', label: 'Website URL', type: 'url' },
|
{ key: "website", label: "Website URL", type: "url" },
|
||||||
{ key: 'city', label: 'City', required: true },
|
{ key: "location", label: "City", required: true },
|
||||||
{ key: 'state', label: 'State', required: true },
|
{ key: "state", label: "State", required: true },
|
||||||
{ key: 'pin_code', label: 'PIN Code' },
|
{ key: "pin_code", label: "PIN Code" },
|
||||||
{ key: 'address', label: 'Registered Address', type: 'textarea' },
|
{ key: "address", label: "Registered Address", type: "textarea" },
|
||||||
{ key: 'gst_number', label: 'GST Number (optional)' },
|
{ key: "gst_number", label: "GST Number (optional)" },
|
||||||
],
|
],
|
||||||
PHOTOGRAPHER: [
|
PHOTOGRAPHER: [
|
||||||
{ key: 'first_name', label: 'First Name', required: true },
|
{ key: 'first_name', label: 'First Name', required: true },
|
||||||
{ key: 'last_name', label: 'Last Name', required: true },
|
{ key: 'last_name', label: 'Last Name', required: true },
|
||||||
{ key: 'phone', label: 'Mobile Number', required: true },
|
{ key: 'phone', label: 'Mobile Number', required: true },
|
||||||
{ key: 'city', label: 'City', required: true },
|
{ key: 'location', label: 'City', required: true },
|
||||||
{ key: 'state', label: 'State', required: true },
|
{ key: 'state', label: 'State', required: true },
|
||||||
{ key: 'pin_code', label: 'PIN Code' },
|
{ key: 'pin_code', label: 'PIN Code' },
|
||||||
{ key: 'speciality', label: 'Photography Speciality', type: 'select',
|
{ key: 'speciality', label: 'Photography Speciality', type: 'select',
|
||||||
options: ['Wedding', 'Portrait', 'Commercial', 'Event', 'Wildlife', 'Fashion', 'Product', 'Other'] },
|
options: ['Wedding', 'Portrait', 'Commercial', 'Event', 'Wildlife', 'Fashion', 'Product', 'Other'] },
|
||||||
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
||||||
{ key: 'bio', label: 'Short Bio', type: 'textarea' },
|
{ key: 'bio', label: 'Short Bio', type: 'textarea' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: "experience_years", label: "Years of Experience", type: "number" },
|
||||||
|
{ key: "bio", label: "Short Bio", type: "textarea" },
|
||||||
],
|
],
|
||||||
FITNESS_TRAINER: [
|
FITNESS_TRAINER: [
|
||||||
{ key: 'first_name', label: 'First Name', required: true },
|
{ key: 'first_name', label: 'First Name', required: true },
|
||||||
{ key: 'last_name', label: 'Last Name', required: true },
|
{ key: 'last_name', label: 'Last Name', required: true },
|
||||||
{ key: 'phone', label: 'Mobile Number', required: true },
|
{ key: 'phone', label: 'Mobile Number', required: true },
|
||||||
{ key: 'city', label: 'City', required: true },
|
{ key: 'location', label: 'City', required: true },
|
||||||
{ key: 'state', label: 'State', required: true },
|
{ key: 'state', label: 'State', required: true },
|
||||||
{ key: 'training_type', label: 'Training Type', type: 'select',
|
{ key: 'training_type', label: 'Training Type', type: 'select',
|
||||||
options: ['Personal Training', 'Group Fitness', 'Yoga', 'CrossFit', 'Zumba', 'Pilates', 'Other'] },
|
options: ['Personal Training', 'Group Fitness', 'Yoga', 'CrossFit', 'Zumba', 'Pilates', 'Other'] },
|
||||||
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
||||||
{ key: 'bio', label: 'Short Bio', type: 'textarea' },
|
{ key: 'bio', label: 'Short Bio', type: 'textarea' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: "experience_years", label: "Years of Experience", type: "number" },
|
||||||
|
{ key: "bio", label: "Short Bio", type: "textarea" },
|
||||||
],
|
],
|
||||||
TUTOR: [
|
TUTOR: [
|
||||||
{ key: 'first_name', label: 'First Name', required: true },
|
{ key: 'first_name', label: 'First Name', required: true },
|
||||||
{ key: 'last_name', label: 'Last Name', required: true },
|
{ key: 'last_name', label: 'Last Name', required: true },
|
||||||
{ key: 'phone', label: 'Mobile Number', required: true },
|
{ key: 'phone', label: 'Mobile Number', required: true },
|
||||||
{ key: 'city', label: 'City', required: true },
|
{ key: 'location', label: 'City', required: true },
|
||||||
{ key: 'state', label: 'State', required: true },
|
{ key: 'state', label: 'State', required: true },
|
||||||
{ key: 'subjects', label: 'Subjects Taught (comma separated)' },
|
{ key: 'subjects', label: 'Subjects Taught (comma separated)' },
|
||||||
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
||||||
|
|
@ -71,43 +92,103 @@ const BASIC_FIELDS: Record<string, Array<{ key: string; label: string; type?: st
|
||||||
{ key: 'business_name', label: 'Business Name', required: true },
|
{ key: 'business_name', label: 'Business Name', required: true },
|
||||||
{ key: 'owner_name', label: 'Owner Name', required: true },
|
{ key: 'owner_name', label: 'Owner Name', required: true },
|
||||||
{ key: 'phone', label: 'Contact Number', required: true },
|
{ key: 'phone', label: 'Contact Number', required: true },
|
||||||
{ key: 'city', label: 'City', required: true },
|
{ key: 'location', label: 'City', required: true },
|
||||||
{ key: 'state', label: 'State', required: true },
|
{ key: 'state', label: 'State', required: true },
|
||||||
{ key: 'cuisine_types', label: 'Cuisine Types (comma separated)' },
|
{ key: 'cuisine_types', label: 'Cuisine Types (comma separated)' },
|
||||||
{ key: 'bio', label: 'About Your Service', type: 'textarea' },
|
{ key: 'bio', label: 'About Your Service', type: 'textarea' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOC_FIELDS: Record<string, Array<{ key: string; label: string; required?: boolean; hint?: string }>> = {
|
const DOC_FIELDS: Record<
|
||||||
|
string,
|
||||||
|
Array<{ key: string; label: string; required?: boolean; hint?: string }>
|
||||||
|
> = {
|
||||||
default: [
|
default: [
|
||||||
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true,
|
{
|
||||||
hint: 'JPG, PNG or PDF · Max 10MB' },
|
key: "aadhar_doc",
|
||||||
|
label: "Aadhar / Government ID",
|
||||||
|
required: true,
|
||||||
|
hint: "JPG, PNG or PDF · Max 10MB",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
COMPANY: [
|
COMPANY: [
|
||||||
{ key: 'registration_doc', label: 'Company Registration Certificate', required: true,
|
{
|
||||||
hint: 'JPG, PNG or PDF · Max 10MB' },
|
key: "registration_doc",
|
||||||
{ key: 'gst_doc', label: 'GST Certificate (optional)',
|
label: "Company Registration Certificate",
|
||||||
hint: 'JPG, PNG or PDF · Max 10MB' },
|
required: true,
|
||||||
|
hint: "JPG, PNG or PDF · Max 10MB",
|
||||||
|
},
|
||||||
|
{ key: "gst_doc", label: "GST Certificate (optional)", hint: "JPG, PNG or PDF · Max 10MB" },
|
||||||
],
|
],
|
||||||
PHOTOGRAPHER: [
|
PHOTOGRAPHER: [
|
||||||
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' },
|
{
|
||||||
{ key: 'sample_work', label: 'Sample Work Photos (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: [
|
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: [
|
TUTOR: [
|
||||||
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' },
|
{
|
||||||
{ key: 'degree_certificate', label: 'Degree Certificate', required: true, hint: 'JPG, PNG or PDF · Max 10MB' },
|
key: "aadhar_doc",
|
||||||
|
label: "Aadhar / Government ID",
|
||||||
|
required: true,
|
||||||
|
hint: "JPG, PNG or PDF · Max 10MB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "degree_certificate",
|
||||||
|
label: "Degree Certificate",
|
||||||
|
required: true,
|
||||||
|
hint: "JPG, PNG or PDF · Max 10MB",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
FITNESS_TRAINER: [
|
FITNESS_TRAINER: [
|
||||||
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' },
|
{
|
||||||
{ key: 'certification_doc', label: 'Fitness Certification', required: true, hint: 'JPG, PNG or PDF · Max 10MB' },
|
key: "aadhar_doc",
|
||||||
|
label: "Aadhar / Government ID",
|
||||||
|
required: true,
|
||||||
|
hint: "JPG, PNG or PDF · Max 10MB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "certification_doc",
|
||||||
|
label: "Fitness Certification",
|
||||||
|
required: true,
|
||||||
|
hint: "JPG, PNG or PDF · Max 10MB",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
CATERING_SERVICES: [
|
CATERING_SERVICES: [
|
||||||
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true, hint: 'JPG, PNG or PDF · Max 10MB' },
|
{
|
||||||
{ key: 'fssai_license', label: 'FSSAI License', required: true, hint: 'JPG, PNG or PDF · Max 10MB' },
|
key: "aadhar_doc",
|
||||||
|
label: "Aadhar / Government ID",
|
||||||
|
required: true,
|
||||||
|
hint: "JPG, PNG or PDF · Max 10MB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fssai_license",
|
||||||
|
label: "FSSAI License",
|
||||||
|
required: true,
|
||||||
|
hint: "JPG, PNG or PDF · Max 10MB",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -123,8 +204,8 @@ function getDocFields(roleKey: string) {
|
||||||
async function apiFetch(path: string, opts?: RequestInit) {
|
async function apiFetch(path: string, opts?: RequestInit) {
|
||||||
return fetch(`${API}${path}`, {
|
return fetch(`${API}${path}`, {
|
||||||
...opts,
|
...opts,
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
headers: { "Content-Type": "application/json", ...(opts?.headers ?? {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,17 +215,17 @@ interface Props {
|
||||||
roleKey: string;
|
roleKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'basic' | 'documents';
|
type Tab = "basic" | "documents";
|
||||||
|
|
||||||
export default function ProfilePage(props: Props) {
|
export default function ProfilePage(props: Props) {
|
||||||
const [tab, setTab] = createSignal<Tab>('basic');
|
const [tab, setTab] = createSignal<Tab>("basic");
|
||||||
const [form, setForm] = createSignal<Record<string, string>>({});
|
const [form, setForm] = createSignal<Record<string, string>>({});
|
||||||
const [saving, setSaving] = createSignal(false);
|
const [saving, setSaving] = createSignal(false);
|
||||||
const [saveMsg, setSaveMsg] = createSignal('');
|
const [saveMsg, setSaveMsg] = createSignal("");
|
||||||
const [verificationStatus, setVerificationStatus] = createSignal('NOT_SUBMITTED');
|
const [verificationStatus, setVerificationStatus] = createSignal("NOT_SUBMITTED");
|
||||||
const [docRequest, setDocRequest] = createSignal<string | null>(null);
|
const [docRequest, setDocRequest] = createSignal<string | null>(null);
|
||||||
const [submitting, setSubmitting] = createSignal(false);
|
const [submitting, setSubmitting] = createSignal(false);
|
||||||
const [submitMsg, setSubmitMsg] = createSignal('');
|
const [submitMsg, setSubmitMsg] = createSignal("");
|
||||||
|
|
||||||
// Load saved profile + verification status on mount
|
// Load saved profile + verification status on mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
|
@ -155,10 +236,10 @@ export default function ProfilePage(props: Props) {
|
||||||
|
|
||||||
if (profileRes.ok) {
|
if (profileRes.ok) {
|
||||||
const data = await profileRes.json();
|
const data = await profileRes.json();
|
||||||
if (data.profile_data && typeof data.profile_data === 'object') {
|
if (data.profile_data && typeof data.profile_data === "object") {
|
||||||
const flat: Record<string, string> = {};
|
const flat: Record<string, string> = {};
|
||||||
for (const [k, v] of Object.entries(data.profile_data)) {
|
for (const [k, v] of Object.entries(data.profile_data)) {
|
||||||
flat[k] = String(v ?? '');
|
flat[k] = String(v ?? "");
|
||||||
}
|
}
|
||||||
setForm(flat);
|
setForm(flat);
|
||||||
}
|
}
|
||||||
|
|
@ -166,158 +247,165 @@ export default function ProfilePage(props: Props) {
|
||||||
|
|
||||||
if (statusRes.ok) {
|
if (statusRes.ok) {
|
||||||
const s = await statusRes.json();
|
const s = await statusRes.json();
|
||||||
setVerificationStatus(s.status ?? 'NOT_SUBMITTED');
|
setVerificationStatus(s.status ?? "NOT_SUBMITTED");
|
||||||
setDocRequest(s.document_request ?? null);
|
setDocRequest(s.document_request ?? null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLocked = () =>
|
const isLocked = () => ["PENDING", "UNDER_REVIEW"].includes(verificationStatus());
|
||||||
['PENDING', 'UNDER_REVIEW'].includes(verificationStatus());
|
|
||||||
|
|
||||||
const setField = (key: string, val: string) =>
|
const setField = (key: string, val: string) => setForm((prev) => ({ ...prev, [key]: val }));
|
||||||
setForm((prev) => ({ ...prev, [key]: val }));
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveMsg('');
|
setSaveMsg("");
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/profile', {
|
const res = await apiFetch("/api/profile", {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
body: JSON.stringify({ roleKey: props.roleKey, profile_data: form() }),
|
body: JSON.stringify({ roleKey: props.roleKey, profile_data: form() }),
|
||||||
});
|
});
|
||||||
setSaveMsg(res.ok ? 'Saved successfully.' : 'Failed to save. Please try again.');
|
setSaveMsg(res.ok ? "Saved successfully." : "Failed to save. Please try again.");
|
||||||
} catch {
|
} catch {
|
||||||
setSaveMsg('Network error. Please try again.');
|
setSaveMsg("Network error. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
setTimeout(() => setSaveMsg(''), 3000);
|
setTimeout(() => setSaveMsg(""), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitForVerification = async () => {
|
const handleSubmitForVerification = async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setSubmitMsg('');
|
setSubmitMsg("");
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/profile/submit-for-verification', {
|
const res = await apiFetch("/api/profile/submit-for-verification", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ roleKey: props.roleKey }),
|
body: JSON.stringify({ roleKey: props.roleKey }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setVerificationStatus('PENDING');
|
setVerificationStatus("PENDING");
|
||||||
setSubmitMsg('Submitted! We will review your profile and notify you.');
|
setSubmitMsg("Submitted! We will review your profile and notify you.");
|
||||||
} else if (res.status === 409) {
|
} else if (res.status === 409) {
|
||||||
setSubmitMsg(data.error ?? 'A verification is already in progress.');
|
setSubmitMsg(data.error ?? "A verification is already in progress.");
|
||||||
} else {
|
} else {
|
||||||
setSubmitMsg(data.error ?? 'Submission failed. Please try again.');
|
setSubmitMsg(data.error ?? "Submission failed. Please try again.");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitMsg('Network error. Please try again.');
|
setSubmitMsg("Network error. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
PENDING: '#F59E0B',
|
PENDING: "#F59E0B",
|
||||||
UNDER_REVIEW: '#3B82F6',
|
UNDER_REVIEW: "#3B82F6",
|
||||||
DOCUMENTS_REQUESTED: '#FF5E13',
|
DOCUMENTS_REQUESTED: "#FF5E13",
|
||||||
REVISION_REQUESTED: '#FF5E13',
|
REVISION_REQUESTED: "#FF5E13",
|
||||||
APPROVED: '#10B981',
|
APPROVED: "#10B981",
|
||||||
REJECTED: '#EF4444',
|
REJECTED: "#EF4444",
|
||||||
NOT_SUBMITTED: '#9CA3AF',
|
NOT_SUBMITTED: "#9CA3AF",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
PENDING: 'Pending Review',
|
PENDING: "Pending Review",
|
||||||
UNDER_REVIEW: 'Under Review',
|
UNDER_REVIEW: "Under Review",
|
||||||
DOCUMENTS_REQUESTED: 'Documents Requested',
|
DOCUMENTS_REQUESTED: "Documents Requested",
|
||||||
REVISION_REQUESTED: 'Revision Requested',
|
REVISION_REQUESTED: "Revision Requested",
|
||||||
APPROVED: 'Approved',
|
APPROVED: "Approved",
|
||||||
REJECTED: 'Rejected',
|
REJECTED: "Rejected",
|
||||||
NOT_SUBMITTED: 'Not Submitted',
|
NOT_SUBMITTED: "Not Submitted",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ 'max-width': '760px' }}>
|
<div style={{ "max-width": "760px" }}>
|
||||||
|
|
||||||
{/* ── Verification status banner ─────────────────────────────────── */}
|
{/* ── Verification status banner ─────────────────────────────────── */}
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
...CARD,
|
...CARD,
|
||||||
'margin-bottom': '16px',
|
"margin-bottom": "16px",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
'align-items': 'center',
|
"align-items": "center",
|
||||||
'justify-content': 'space-between',
|
"justify-content": "space-between",
|
||||||
gap: '12px',
|
gap: "12px",
|
||||||
'flex-wrap': 'wrap',
|
"flex-wrap": "wrap",
|
||||||
}}>
|
}}
|
||||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}>
|
>
|
||||||
<span style={{
|
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
|
||||||
display: 'inline-flex',
|
<span
|
||||||
'align-items': 'center',
|
style={{
|
||||||
height: '22px',
|
display: "inline-flex",
|
||||||
padding: '0 10px',
|
"align-items": "center",
|
||||||
'border-radius': '999px',
|
height: "22px",
|
||||||
background: `${statusColor[verificationStatus()] ?? '#9CA3AF'}22`,
|
padding: "0 10px",
|
||||||
color: statusColor[verificationStatus()] ?? '#9CA3AF',
|
"border-radius": "999px",
|
||||||
'font-size': '11px',
|
background: `${statusColor[verificationStatus()] ?? "#9CA3AF"}22`,
|
||||||
'font-weight': '700',
|
color: statusColor[verificationStatus()] ?? "#9CA3AF",
|
||||||
}}>
|
"font-size": "11px",
|
||||||
|
"font-weight": "700",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{statusLabel[verificationStatus()] ?? verificationStatus()}
|
{statusLabel[verificationStatus()] ?? verificationStatus()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={docRequest()}>
|
<Show when={docRequest()}>
|
||||||
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>
|
<p style={{ margin: "0", "font-size": "13px", color: "#6B7280" }}>
|
||||||
<strong style={{ color: '#FF5E13' }}>Action needed:</strong> {docRequest()}
|
<strong style={{ color: "#FF5E13" }}>Action needed:</strong> {docRequest()}
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={!isLocked() && verificationStatus() !== 'APPROVED'}>
|
<Show when={!isLocked() && verificationStatus() !== "APPROVED"}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmitForVerification}
|
onClick={handleSubmitForVerification}
|
||||||
disabled={submitting()}
|
disabled={submitting()}
|
||||||
style={{ ...BTN_ORANGE, opacity: submitting() ? '0.7' : '1' }}
|
style={{ ...BTN_ORANGE, opacity: submitting() ? "0.7" : "1" }}
|
||||||
>
|
>
|
||||||
{submitting() ? 'Submitting…' : 'Submit for Verification'}
|
{submitting() ? "Submitting…" : "Submit for Verification"}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={submitMsg()}>
|
<Show when={submitMsg()}>
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
...CARD,
|
...CARD,
|
||||||
'margin-bottom': '16px',
|
"margin-bottom": "16px",
|
||||||
padding: '12px 16px',
|
padding: "12px 16px",
|
||||||
background: submitMsg().includes('Submitted') ? '#ECFDF5' : '#FEF2F2',
|
background: submitMsg().includes("Submitted") ? "#ECFDF5" : "#FEF2F2",
|
||||||
border: `1px solid ${submitMsg().includes('Submitted') ? '#6EE7B7' : '#FECACA'}`,
|
border: `1px solid ${submitMsg().includes("Submitted") ? "#6EE7B7" : "#FECACA"}`,
|
||||||
color: submitMsg().includes('Submitted') ? '#065F46' : '#B91C1C',
|
color: submitMsg().includes("Submitted") ? "#065F46" : "#B91C1C",
|
||||||
'font-size': '13px',
|
"font-size": "13px",
|
||||||
'font-weight': '600',
|
"font-weight": "600",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{submitMsg()}
|
{submitMsg()}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* ── Tabs ──────────────────────────────────────────────────────── */}
|
{/* ── Tabs ──────────────────────────────────────────────────────── */}
|
||||||
<div style={{ display: 'flex', gap: '4px', 'margin-bottom': '16px' }}>
|
<div style={{ display: "flex", gap: "4px", "margin-bottom": "16px" }}>
|
||||||
<For each={[
|
<For
|
||||||
{ key: 'basic', label: 'Basic Information' },
|
each={
|
||||||
{ key: 'documents', label: 'Documents' },
|
[
|
||||||
] as Array<{ key: Tab; label: string }>}>
|
{ key: "basic", label: "Basic Information" },
|
||||||
|
{ key: "documents", label: "Documents" },
|
||||||
|
] as Array<{ key: Tab; label: string }>
|
||||||
|
}
|
||||||
|
>
|
||||||
{(t) => (
|
{(t) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab(t.key)}
|
onClick={() => setTab(t.key)}
|
||||||
style={{
|
style={{
|
||||||
height: '36px',
|
height: "36px",
|
||||||
padding: '0 16px',
|
padding: "0 16px",
|
||||||
'border-radius': '8px',
|
"border-radius": "8px",
|
||||||
border: tab() === t.key ? '1px solid #FF5E13' : '1px solid #E5E7EB',
|
border: tab() === t.key ? "1px solid #FF5E13" : "1px solid #E5E7EB",
|
||||||
background: tab() === t.key ? '#FFF3EE' : '#fff',
|
background: tab() === t.key ? "#FFF3EE" : "#fff",
|
||||||
color: tab() === t.key ? '#FF5E13' : '#6B7280',
|
color: tab() === t.key ? "#FF5E13" : "#6B7280",
|
||||||
'font-size': '13px',
|
"font-size": "13px",
|
||||||
'font-weight': tab() === t.key ? '700' : '500',
|
"font-weight": tab() === t.key ? "700" : "500",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
|
|
@ -329,41 +417,40 @@ export default function ProfilePage(props: Props) {
|
||||||
{/* ── Tab content ───────────────────────────────────────────────── */}
|
{/* ── Tab content ───────────────────────────────────────────────── */}
|
||||||
<div style={CARD}>
|
<div style={CARD}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<Match when={tab() === 'basic'}>
|
<Match when={tab() === "basic"}>
|
||||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '16px' }}>
|
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "16px" }}>
|
||||||
<For each={getBasicFields(props.roleKey)}>
|
<For each={getBasicFields(props.roleKey)}>
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<div style={{ 'grid-column': field.type === 'textarea' ? 'span 2' : 'span 1' }}>
|
<div style={{ "grid-column": field.type === "textarea" ? "span 2" : "span 1" }}>
|
||||||
<label style={LABEL}>
|
<label style={LABEL}>
|
||||||
{field.label}
|
{field.label}
|
||||||
<Show when={field.required}>
|
<Show when={field.required}>
|
||||||
<span style={{ color: '#EF4444' }}> *</span>
|
<span style={{ color: "#EF4444" }}> *</span>
|
||||||
</Show>
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={field.type === 'textarea'}>
|
<Match when={field.type === "textarea"}>
|
||||||
<textarea
|
<textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={isLocked()}
|
disabled={isLocked()}
|
||||||
value={form()[field.key] ?? ''}
|
value={form()[field.key] ?? ""}
|
||||||
onInput={(e) => setField(field.key, e.currentTarget.value)}
|
onInput={(e) => setField(field.key, e.currentTarget.value)}
|
||||||
style={{
|
style={{
|
||||||
...INPUT,
|
...INPUT,
|
||||||
height: 'auto',
|
height: "auto",
|
||||||
padding: '10px 12px',
|
padding: "10px 12px",
|
||||||
resize: 'vertical',
|
resize: "vertical",
|
||||||
opacity: isLocked() ? '0.6' : '1',
|
opacity: isLocked() ? "0.6" : "1",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={field.type === 'select'}>
|
<Match when={field.type === "select"}>
|
||||||
<select
|
<select
|
||||||
disabled={isLocked()}
|
disabled={isLocked()}
|
||||||
value={form()[field.key] ?? ''}
|
value={form()[field.key] ?? ""}
|
||||||
onChange={(e) => setField(field.key, e.currentTarget.value)}
|
onChange={(e) => setField(field.key, e.currentTarget.value)}
|
||||||
style={{ ...INPUT, opacity: isLocked() ? '0.6' : '1' }}
|
style={{ ...INPUT, opacity: isLocked() ? "0.6" : "1" }}
|
||||||
>
|
>
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
<For each={field.options ?? []}>
|
<For each={field.options ?? []}>
|
||||||
|
|
@ -373,11 +460,11 @@ export default function ProfilePage(props: Props) {
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<input
|
<input
|
||||||
type={field.type ?? 'text'}
|
type={field.type ?? "text"}
|
||||||
disabled={isLocked()}
|
disabled={isLocked()}
|
||||||
value={form()[field.key] ?? ''}
|
value={form()[field.key] ?? ""}
|
||||||
onInput={(e) => setField(field.key, e.currentTarget.value)}
|
onInput={(e) => setField(field.key, e.currentTarget.value)}
|
||||||
style={{ ...INPUT, opacity: isLocked() ? '0.6' : '1' }}
|
style={{ ...INPUT, opacity: isLocked() ? "0.6" : "1" }}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
@ -387,35 +474,50 @@ export default function ProfilePage(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isLocked()}>
|
<Show when={isLocked()}>
|
||||||
<p style={{ margin: '16px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>
|
<p style={{ margin: "16px 0 0", "font-size": "12px", color: "#9CA3AF" }}>
|
||||||
Profile is locked while verification is in progress.
|
Profile is locked while verification is in progress.
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
{/* Documents */}
|
{/* Documents */}
|
||||||
<Match when={tab() === 'documents'}>
|
<Match when={tab() === "documents"}>
|
||||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}>
|
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||||
<For each={getDocFields(props.roleKey)}>
|
<For each={getDocFields(props.roleKey)}>
|
||||||
{(doc) => (
|
{(doc) => (
|
||||||
<div style={{ 'border': '1px dashed #E5E7EB', 'border-radius': '10px', padding: '16px' }}>
|
<div
|
||||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>
|
style={{
|
||||||
|
border: "1px dashed #E5E7EB",
|
||||||
|
"border-radius": "10px",
|
||||||
|
padding: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0",
|
||||||
|
"font-size": "13px",
|
||||||
|
"font-weight": "700",
|
||||||
|
color: "#111827",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{doc.label}
|
{doc.label}
|
||||||
<Show when={doc.required}>
|
<Show when={doc.required}>
|
||||||
<span style={{ color: '#EF4444' }}> *</span>
|
<span style={{ color: "#EF4444" }}> *</span>
|
||||||
</Show>
|
</Show>
|
||||||
</p>
|
</p>
|
||||||
<Show when={doc.hint}>
|
<Show when={doc.hint}>
|
||||||
<p style={{ margin: '2px 0 10px', 'font-size': '11px', color: '#9CA3AF' }}>{doc.hint}</p>
|
<p style={{ margin: "2px 0 10px", "font-size": "11px", color: "#9CA3AF" }}>
|
||||||
|
{doc.hint}
|
||||||
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={form()[doc.key]}
|
when={form()[doc.key]}
|
||||||
fallback={
|
fallback={
|
||||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}>
|
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id={`file-${doc.key}`}
|
id={`file-${doc.key}`}
|
||||||
style={{ display: 'none' }}
|
style={{ display: "none" }}
|
||||||
disabled={isLocked()}
|
disabled={isLocked()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.currentTarget.files?.[0];
|
const file = e.currentTarget.files?.[0];
|
||||||
|
|
@ -426,31 +528,44 @@ export default function ProfilePage(props: Props) {
|
||||||
for={`file-${doc.key}`}
|
for={`file-${doc.key}`}
|
||||||
style={{
|
style={{
|
||||||
...BTN_GHOST,
|
...BTN_GHOST,
|
||||||
display: 'inline-flex',
|
display: "inline-flex",
|
||||||
'align-items': 'center',
|
"align-items": "center",
|
||||||
'line-height': '1',
|
"line-height": "1",
|
||||||
opacity: isLocked() ? '0.5' : '1',
|
opacity: isLocked() ? "0.5" : "1",
|
||||||
cursor: isLocked() ? 'not-allowed' : 'pointer',
|
cursor: isLocked() ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Choose File
|
Choose File
|
||||||
</label>
|
</label>
|
||||||
<span style={{ 'font-size': '12px', color: '#9CA3AF' }}>No file chosen</span>
|
<span style={{ "font-size": "12px", color: "#9CA3AF" }}>
|
||||||
|
No file chosen
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}>
|
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
|
||||||
<span style={{
|
<span
|
||||||
'font-size': '12px', 'font-weight': '600', color: '#10B981',
|
style={{
|
||||||
background: '#ECFDF5', padding: '4px 10px', 'border-radius': '6px',
|
"font-size": "12px",
|
||||||
}}>
|
"font-weight": "600",
|
||||||
|
color: "#10B981",
|
||||||
|
background: "#ECFDF5",
|
||||||
|
padding: "4px 10px",
|
||||||
|
"border-radius": "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
✓ {form()[doc.key]}
|
✓ {form()[doc.key]}
|
||||||
</span>
|
</span>
|
||||||
<Show when={!isLocked()}>
|
<Show when={!isLocked()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setField(doc.key, '')}
|
onClick={() => setField(doc.key, "")}
|
||||||
style={{ ...BTN_GHOST, height: '28px', 'font-size': '11px', padding: '0 10px' }}
|
style={{
|
||||||
|
...BTN_GHOST,
|
||||||
|
height: "28px",
|
||||||
|
"font-size": "11px",
|
||||||
|
padding: "0 10px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -462,26 +577,27 @@ export default function ProfilePage(props: Props) {
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Save button ─────────────────────────────────────────────── */}
|
{/* ── Save button ─────────────────────────────────────────────── */}
|
||||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px', 'margin-top': '16px' }}>
|
<div style={{ display: "flex", "align-items": "center", gap: "12px", "margin-top": "16px" }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving() || isLocked()}
|
disabled={saving() || isLocked()}
|
||||||
style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? '0.6' : '1' }}
|
style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? "0.6" : "1" }}
|
||||||
>
|
>
|
||||||
{saving() ? 'Saving…' : 'Save Changes'}
|
{saving() ? "Saving…" : "Save Changes"}
|
||||||
</button>
|
</button>
|
||||||
<Show when={saveMsg()}>
|
<Show when={saveMsg()}>
|
||||||
<span style={{
|
<span
|
||||||
'font-size': '13px',
|
style={{
|
||||||
'font-weight': '600',
|
"font-size": "13px",
|
||||||
color: saveMsg().includes('success') ? '#10B981' : '#EF4444',
|
"font-weight": "600",
|
||||||
}}>
|
color: saveMsg().includes("success") ? "#10B981" : "#EF4444",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{saveMsg()}
|
{saveMsg()}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,41 @@ function clearAuthStorage() {
|
||||||
localStorage.removeItem('nxtgauge_signup_profile_v1');
|
localStorage.removeItem('nxtgauge_signup_profile_v1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRoleValue(value: unknown): string {
|
||||||
|
return String(value || '').trim().toUpperCase().replace(/\s+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredPreferredRole(emailHint?: string): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user'];
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, any>;
|
||||||
|
const storedEmail = String(parsed?.email || '').trim().toLowerCase();
|
||||||
|
if (emailHint && storedEmail && storedEmail !== emailHint.trim().toLowerCase()) continue;
|
||||||
|
|
||||||
|
const selectedProfessionalRole = normalizeRoleValue(parsed?.selectedProfessionalRole);
|
||||||
|
if (selectedProfessionalRole) return selectedProfessionalRole;
|
||||||
|
|
||||||
|
const activeRole = normalizeRoleValue(parsed?.active_role || parsed?.role);
|
||||||
|
if (activeRole) return activeRole;
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed local storage payloads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string {
|
||||||
|
const backendRole = normalizeRoleValue(rawBackendRole);
|
||||||
|
if (backendRole) return backendRole;
|
||||||
|
const preferredRole = getStoredPreferredRole(emailHint);
|
||||||
|
if (preferredRole) return preferredRole;
|
||||||
|
return preferredRole || 'JOB_SEEKER';
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSession(): Promise<AuthUser | null> {
|
async function fetchSession(): Promise<AuthUser | null> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
@ -49,11 +84,12 @@ async function fetchSession(): Promise<AuthUser | null> {
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data?.id && !data?.user_id) return null;
|
if (!data?.id && !data?.user_id) return null;
|
||||||
|
const resolvedActiveRole = resolveActiveRole(data.active_role || data.role, data.email || '');
|
||||||
return {
|
return {
|
||||||
id: data.id || data.user_id,
|
id: data.id || data.user_id,
|
||||||
email: data.email || '',
|
email: data.email || '',
|
||||||
full_name: data.full_name || data.name || '',
|
full_name: data.full_name || data.name || '',
|
||||||
active_role: data.active_role || data.role || 'JOB_SEEKER',
|
active_role: resolvedActiveRole,
|
||||||
email_verified: data.email_verified || false,
|
email_verified: data.email_verified || false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -66,7 +102,7 @@ export function AuthProvider(props: ParentProps) {
|
||||||
const [session] = createResource(fetchSession);
|
const [session] = createResource(fetchSession);
|
||||||
|
|
||||||
const isLoading = () => session.loading;
|
const isLoading = () => session.loading;
|
||||||
const isAuthenticated = () => !!user() || (!!session() && !session.error);
|
const isAuthenticated = () => !!user() || !!getToken() || (!!session() && !session.error);
|
||||||
|
|
||||||
if (session()) {
|
if (session()) {
|
||||||
setUser(session() as AuthUser | null);
|
setUser(session() as AuthUser | null);
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,11 @@ async function proxyRequest(method: string, request: Request, params: any) {
|
||||||
pathArray = [pathArray];
|
pathArray = [pathArray];
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = `/${pathArray.join('/')}`;
|
const rawPath = `/${pathArray.join('/')}`;
|
||||||
|
// Normalize all forwarded routes to the Rust gateway's /api/* contract.
|
||||||
|
const path = rawPath.startsWith('/api/') || rawPath === '/api'
|
||||||
|
? rawPath
|
||||||
|
: `/api${rawPath}`;
|
||||||
|
|
||||||
// Preserve query string
|
// Preserve query string
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { A } from '@solidjs/router';
|
import { A } from "@solidjs/router";
|
||||||
import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
import { Show, createMemo, createSignal, onCleanup, onMount } from "solid-js";
|
||||||
import PublicBackground from '~/components/PublicBackground';
|
import PublicBackground from "~/components/PublicBackground";
|
||||||
import PublicHeader from '~/components/PublicHeader';
|
import PublicHeader from "~/components/PublicHeader";
|
||||||
import PublicFooter from '~/components/PublicFooter';
|
import PublicFooter from "~/components/PublicFooter";
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
|
|
@ -17,31 +17,31 @@ type FormValues = {
|
||||||
type FormErrors = Partial<Record<keyof FormValues, string>>;
|
type FormErrors = Partial<Record<keyof FormValues, string>>;
|
||||||
|
|
||||||
const initialValues: FormValues = {
|
const initialValues: FormValues = {
|
||||||
fullName: '',
|
fullName: "",
|
||||||
email: '',
|
email: "",
|
||||||
phone: '',
|
phone: "",
|
||||||
userType: '',
|
userType: "",
|
||||||
topic: '',
|
topic: "",
|
||||||
message: '',
|
message: "",
|
||||||
attachment: null,
|
attachment: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const userTypes = [
|
const userTypes = [
|
||||||
'Customer (Hire professional)',
|
"Customer (Hire professional)",
|
||||||
'Company (Post job)',
|
"Company (Post job)",
|
||||||
'Professional (Provide services)',
|
"Professional (Provide services)",
|
||||||
'Job Seeker (Apply jobs)',
|
"Job Seeker (Apply jobs)",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const topics = [
|
const topics = [
|
||||||
'Account & Login',
|
"Account & Login",
|
||||||
'Verification',
|
"Verification",
|
||||||
'Posting a Job',
|
"Posting a Job",
|
||||||
'Posting a Requirement',
|
"Posting a Requirement",
|
||||||
'Leads / Matching',
|
"Leads / Matching",
|
||||||
'Payments / Credits',
|
"Payments / Credits",
|
||||||
'Bug Report',
|
"Bug Report",
|
||||||
'Other',
|
"Other",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function IconMail() {
|
function IconMail() {
|
||||||
|
|
@ -75,22 +75,27 @@ export default function ContactPage() {
|
||||||
const [values, setValues] = createSignal<FormValues>(initialValues);
|
const [values, setValues] = createSignal<FormValues>(initialValues);
|
||||||
const [errors, setErrors] = createSignal<FormErrors>({});
|
const [errors, setErrors] = createSignal<FormErrors>({});
|
||||||
const [submitted, setSubmitted] = createSignal(false);
|
const [submitted, setSubmitted] = createSignal(false);
|
||||||
|
const [submitting, setSubmitting] = createSignal(false);
|
||||||
const [showBackToTop, setShowBackToTop] = createSignal(false);
|
const [showBackToTop, setShowBackToTop] = createSignal(false);
|
||||||
const [scrollY, setScrollY] = createSignal(0);
|
const [scrollY, setScrollY] = createSignal(0);
|
||||||
|
const [err, setErr] = createSignal("");
|
||||||
|
|
||||||
const validate = (v: FormValues): FormErrors => {
|
const validate = (v: FormValues): FormErrors => {
|
||||||
const next: FormErrors = {};
|
const next: FormErrors = {};
|
||||||
if (!v.fullName.trim()) next.fullName = 'Full name is required.';
|
if (!v.fullName.trim()) next.fullName = "Full name is required.";
|
||||||
if (!v.email.trim()) next.email = 'Email is required.';
|
if (!v.email.trim()) next.email = "Email is required.";
|
||||||
if (v.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim())) next.email = 'Enter a valid email.';
|
if (v.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim()))
|
||||||
if (!v.userType.trim()) next.userType = 'Please select user type.';
|
next.email = "Enter a valid email.";
|
||||||
if (!v.topic.trim()) next.topic = 'Please select a topic.';
|
if (!v.userType.trim()) next.userType = "Please select user type.";
|
||||||
if (!v.message.trim()) next.message = 'Message is required.';
|
if (!v.topic.trim()) next.topic = "Please select a topic.";
|
||||||
if (v.message.trim() && v.message.trim().length < 20) next.message = 'Message must be at least 20 characters.';
|
if (!v.message.trim()) next.message = "Message is required.";
|
||||||
|
if (v.message.trim() && v.message.trim().length < 20)
|
||||||
|
next.message = "Message must be at least 20 characters.";
|
||||||
if (v.attachment) {
|
if (v.attachment) {
|
||||||
if (v.attachment.size > 10 * 1024 * 1024) next.attachment = 'Attachment must be 10MB or smaller.';
|
if (v.attachment.size > 10 * 1024 * 1024)
|
||||||
const allowed = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg'];
|
next.attachment = "Attachment must be 10MB or smaller.";
|
||||||
if (!allowed.includes(v.attachment.type)) next.attachment = 'Allowed formats: PDF, PNG, JPG.';
|
const allowed = ["application/pdf", "image/png", "image/jpeg", "image/jpg"];
|
||||||
|
if (!allowed.includes(v.attachment.type)) next.attachment = "Allowed formats: PDF, PNG, JPG.";
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
@ -107,8 +112,8 @@ export default function ContactPage() {
|
||||||
setScrollY(window.scrollY || 0);
|
setScrollY(window.scrollY || 0);
|
||||||
};
|
};
|
||||||
onScroll();
|
onScroll();
|
||||||
window.addEventListener('scroll', onScroll, { passive: true });
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
onCleanup(() => window.removeEventListener('scroll', onScroll));
|
onCleanup(() => window.removeEventListener("scroll", onScroll));
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -137,94 +142,208 @@ export default function ContactPage() {
|
||||||
<div class="contact-layout-grid">
|
<div class="contact-layout-grid">
|
||||||
<form
|
<form
|
||||||
class="card glass-light contact-form-card"
|
class="card glass-light contact-form-card"
|
||||||
onSubmit={(event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const nextErrors = validate(values());
|
const nextErrors = validate(values());
|
||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
if (Object.keys(nextErrors).length > 0) return;
|
if (Object.keys(nextErrors).length > 0) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
const userTypeToCategory: Record<string, string> = {
|
||||||
|
"Customer (Hire professional)": "GENERAL",
|
||||||
|
"Company (Post job)": "ACCOUNT",
|
||||||
|
"Professional (Provide services)": "GENERAL",
|
||||||
|
"Job Seeker (Apply jobs)": "GENERAL",
|
||||||
|
};
|
||||||
|
const category = userTypeToCategory[values().userType] || "GENERAL";
|
||||||
|
const res = await fetch("/api/gateway/api/support/tickets", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
subject: values().topic,
|
||||||
|
description: values().message,
|
||||||
|
category: category,
|
||||||
|
requester_name: values().fullName,
|
||||||
|
requester_email: values().email,
|
||||||
|
phone: values().phone,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error || data.message || "Failed to submit ticket.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
setValues(initialValues);
|
setValues(initialValues);
|
||||||
window.setTimeout(() => setSubmitted(false), 3200);
|
window.setTimeout(() => {
|
||||||
|
setSubmitted(false);
|
||||||
|
setErr("");
|
||||||
|
}, 3200);
|
||||||
|
} catch {
|
||||||
|
setErr("Network error while submitting ticket.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
|
<div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Full Name *</span>
|
<span class="label">Full Name *</span>
|
||||||
<input class="input" value={values().fullName} onInput={(e) => update('fullName', e.currentTarget.value)} />
|
<input
|
||||||
<Show when={errors().fullName}><p class="error">{errors().fullName}</p></Show>
|
class="input"
|
||||||
|
value={values().fullName}
|
||||||
|
onInput={(e) => update("fullName", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Show when={errors().fullName}>
|
||||||
|
<p class="error">{errors().fullName}</p>
|
||||||
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Email *</span>
|
<span class="label">Email *</span>
|
||||||
<input class="input" type="email" value={values().email} onInput={(e) => update('email', e.currentTarget.value)} />
|
<input
|
||||||
<Show when={errors().email}><p class="error">{errors().email}</p></Show>
|
class="input"
|
||||||
|
type="email"
|
||||||
|
value={values().email}
|
||||||
|
onInput={(e) => update("email", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Show when={errors().email}>
|
||||||
|
<p class="error">{errors().email}</p>
|
||||||
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
|
<div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Phone</span>
|
<span class="label">Phone</span>
|
||||||
<input class="input" value={values().phone} onInput={(e) => update('phone', e.currentTarget.value)} />
|
<input
|
||||||
|
class="input"
|
||||||
|
value={values().phone}
|
||||||
|
onInput={(e) => update("phone", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">User Type *</span>
|
<span class="label">User Type *</span>
|
||||||
<select class="select" value={values().userType} onInput={(e) => update('userType', e.currentTarget.value)}>
|
<select
|
||||||
|
class="select"
|
||||||
|
value={values().userType}
|
||||||
|
onInput={(e) => update("userType", e.currentTarget.value)}
|
||||||
|
>
|
||||||
<option value="">Select user type</option>
|
<option value="">Select user type</option>
|
||||||
{userTypes.map((type) => (
|
{userTypes.map((type) => (
|
||||||
<option value={type}>{type}</option>
|
<option value={type}>{type}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Show when={errors().userType}><p class="error">{errors().userType}</p></Show>
|
<Show when={errors().userType}>
|
||||||
|
<p class="error">{errors().userType}</p>
|
||||||
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
|
<div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Topic *</span>
|
<span class="label">Topic *</span>
|
||||||
<select class="select" value={values().topic} onInput={(e) => update('topic', e.currentTarget.value)}>
|
<select
|
||||||
|
class="select"
|
||||||
|
value={values().topic}
|
||||||
|
onInput={(e) => update("topic", e.currentTarget.value)}
|
||||||
|
>
|
||||||
<option value="">Select topic</option>
|
<option value="">Select topic</option>
|
||||||
{topics.map((topic) => (
|
{topics.map((topic) => (
|
||||||
<option value={topic}>{topic}</option>
|
<option value={topic}>{topic}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Show when={errors().topic}><p class="error">{errors().topic}</p></Show>
|
<Show when={errors().topic}>
|
||||||
|
<p class="error">{errors().topic}</p>
|
||||||
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Attachment</span>
|
<span class="label">Attachment</span>
|
||||||
<label class="contact-upload">
|
<label class="contact-upload">
|
||||||
<span class="contact-upload-icon" aria-hidden="true">⌁</span>
|
<span class="contact-upload-icon" aria-hidden="true">
|
||||||
<span class="contact-upload-text">{values().attachment ? values().attachment.name : 'Upload pdf/png/jpg (max 10MB)'}</span>
|
⌁
|
||||||
|
</span>
|
||||||
|
<span class="contact-upload-text">
|
||||||
|
{values().attachment
|
||||||
|
? values().attachment.name
|
||||||
|
: "Upload pdf/png/jpg (max 10MB)"}
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
class="contact-upload-input"
|
class="contact-upload-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
onChange={(e) => update('attachment', e.currentTarget.files?.[0] ?? null)}
|
onChange={(e) => update("attachment", e.currentTarget.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Show when={errors().attachment}><p class="error">{errors().attachment}</p></Show>
|
<Show when={errors().attachment}>
|
||||||
|
<p class="error">{errors().attachment}</p>
|
||||||
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Message *</span>
|
<span class="label">Message *</span>
|
||||||
<textarea class="textarea" value={values().message} onInput={(e) => update('message', e.currentTarget.value)} />
|
<textarea
|
||||||
<Show when={errors().message}><p class="error">{errors().message}</p></Show>
|
class="textarea"
|
||||||
|
value={values().message}
|
||||||
|
onInput={(e) => update("message", e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Show when={errors().message}>
|
||||||
|
<p class="error">{errors().message}</p>
|
||||||
|
</Show>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<button class="lp-primary-btn" type="submit" disabled={!canSubmit()}>Send message</button>
|
<button
|
||||||
<button class="lp-ghost-btn" type="button" onClick={() => { setValues(initialValues); setErrors({}); }}>Reset</button>
|
class="lp-primary-btn"
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit() || submitting()}
|
||||||
|
>
|
||||||
|
{submitting() ? "Sending..." : "Send message"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="lp-ghost-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setValues(initialValues);
|
||||||
|
setErrors({});
|
||||||
|
setErr("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<aside class="card glass-dark contact-side-card">
|
<aside class="card glass-dark contact-side-card">
|
||||||
<h3>Contact details</h3>
|
<h3>Contact details</h3>
|
||||||
<p class="sub contact-detail"><span class="contact-icon"><IconMail /></span>support@nxtgauge.com</p>
|
<p class="sub contact-detail">
|
||||||
<p class="sub contact-detail"><span class="contact-icon"><IconClock /></span>Typically within 24–48 hours</p>
|
<span class="contact-icon">
|
||||||
<p class="sub contact-detail"><span class="contact-icon"><IconPin /></span>Remote-first, India</p>
|
<IconMail />
|
||||||
|
</span>
|
||||||
|
support@nxtgauge.com
|
||||||
|
</p>
|
||||||
|
<p class="sub contact-detail">
|
||||||
|
<span class="contact-icon">
|
||||||
|
<IconClock />
|
||||||
|
</span>
|
||||||
|
Typically within 24–48 hours
|
||||||
|
</p>
|
||||||
|
<p class="sub contact-detail">
|
||||||
|
<span class="contact-icon">
|
||||||
|
<IconPin />
|
||||||
|
</span>
|
||||||
|
Remote-first, India
|
||||||
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/about">About Us</A>
|
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/about">
|
||||||
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/#faqs">FAQs</A>
|
About Us
|
||||||
|
</A>
|
||||||
|
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/#faqs">
|
||||||
|
FAQs
|
||||||
|
</A>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -236,23 +355,63 @@ export default function ContactPage() {
|
||||||
<h2 class="center">Common Questions</h2>
|
<h2 class="center">Common Questions</h2>
|
||||||
<p class="center sub contact-quick-clarity">Quick clarity before you raise a query.</p>
|
<p class="center sub contact-quick-clarity">Quick clarity before you raise a query.</p>
|
||||||
<div class="contact-mini-faq-grid">
|
<div class="contact-mini-faq-grid">
|
||||||
<article class="contact-mini-faq-card"><h3>Approval time</h3><p>Most profile and listing approvals are completed in 24–48 hours.</p></article>
|
<article class="contact-mini-faq-card">
|
||||||
<article class="contact-mini-faq-card"><h3>Verification</h3><p>Verification is required to reduce spam and improve trust.</p></article>
|
<h3>Approval time</h3>
|
||||||
<article class="contact-mini-faq-card"><h3>Posting flow</h3><p>You can submit multiple requirements and jobs after onboarding.</p></article>
|
<p>Most profile and listing approvals are completed in 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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Show when={submitted()}>
|
<Show when={submitted()}>
|
||||||
<div style={{ position: 'fixed', right: '16px', top: '88px', 'z-index': 90, padding: '10px 14px', 'border-radius': '12px', border: '1px solid rgba(255,255,255,0.25)', background: 'rgba(16,11,47,0.88)', color: 'white', 'font-weight': 700 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: "16px",
|
||||||
|
top: "88px",
|
||||||
|
"z-index": 90,
|
||||||
|
padding: "10px 14px",
|
||||||
|
"border-radius": "12px",
|
||||||
|
border: "1px solid rgba(255,255,255,0.25)",
|
||||||
|
background: "rgba(16,11,47,0.88)",
|
||||||
|
color: "white",
|
||||||
|
"font-weight": 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Message sent. We'll reply soon.
|
Message sent. We'll reply soon.
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={err()}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: "16px",
|
||||||
|
top: "88px",
|
||||||
|
"z-index": 90,
|
||||||
|
padding: "10px 14px",
|
||||||
|
"border-radius": "12px",
|
||||||
|
border: "1px solid rgba(239,68,68,0.25)",
|
||||||
|
background: "rgba(220,38,38,0.1)",
|
||||||
|
color: "#DC2626",
|
||||||
|
"font-weight": 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{err()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<PublicFooter />
|
<PublicFooter />
|
||||||
|
|
||||||
<Show when={showBackToTop()}>
|
<Show when={showBackToTop()}>
|
||||||
<button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
|
<button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>
|
||||||
↑
|
↑
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
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';
|
type RoleKey = 'company' | 'job_seeker' | 'professional' | 'customer';
|
||||||
|
|
||||||
|
function normalizeRoleValue(value: unknown): string {
|
||||||
|
return String(value || '').trim().toUpperCase().replace(/\s+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredPreferredRole(emailHint?: string): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
const keys = ['nxtgauge_signup_profile_v1', 'nxtgauge_auth_user', 'nxtgauge_user'];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, any>;
|
||||||
|
const storedEmail = String(parsed?.email || '').trim().toLowerCase();
|
||||||
|
if (emailHint && storedEmail && storedEmail !== emailHint.trim().toLowerCase()) continue;
|
||||||
|
|
||||||
|
const selectedProfessionalRole = normalizeRoleValue(parsed?.selectedProfessionalRole);
|
||||||
|
if (selectedProfessionalRole) return selectedProfessionalRole;
|
||||||
|
|
||||||
|
const activeRole = normalizeRoleValue(parsed?.active_role || parsed?.role);
|
||||||
|
if (activeRole) return activeRole;
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed local storage payloads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActiveRole(rawBackendRole: unknown, emailHint?: string): string {
|
||||||
|
const backendRole = normalizeRoleValue(rawBackendRole);
|
||||||
|
if (backendRole) return backendRole;
|
||||||
|
const preferredRole = getStoredPreferredRole(emailHint);
|
||||||
|
if (preferredRole) return preferredRole;
|
||||||
|
return preferredRole || 'JOB_SEEKER';
|
||||||
|
}
|
||||||
|
|
||||||
function makeCaptcha() {
|
function makeCaptcha() {
|
||||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||||
|
|
@ -45,9 +82,61 @@ export default function LoginRoute() {
|
||||||
const [error, setError] = createSignal('');
|
const [error, setError] = createSignal('');
|
||||||
const [submitting, setSubmitting] = createSignal(false);
|
const [submitting, setSubmitting] = createSignal(false);
|
||||||
const [roleGuess, setRoleGuess] = createSignal<RoleKey>('job_seeker');
|
const [roleGuess, setRoleGuess] = createSignal<RoleKey>('job_seeker');
|
||||||
|
const [roleHint, setRoleHint] = createSignal('');
|
||||||
|
const [checkingRole, setCheckingRole] = createSignal(false);
|
||||||
|
|
||||||
const otpCode = createMemo(() => otp().join(''));
|
const otpCode = createMemo(() => otp().join(''));
|
||||||
|
|
||||||
|
const formatRoleLabel = (value: string): string =>
|
||||||
|
String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/[_\s]+/g, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||||
|
|
||||||
|
const lookupRoleByEmail = async (emailValue: string) => {
|
||||||
|
const normalized = emailValue.trim().toLowerCase();
|
||||||
|
if (!normalized || !isValidEmail(normalized)) {
|
||||||
|
setRoleHint('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCheckingRole(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/gateway/api/auth/check-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ email: normalized }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || !payload?.exists) {
|
||||||
|
setRoleHint('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const detectedRole = normalizeRoleValue(
|
||||||
|
payload?.active_role || payload?.role || payload?.roles?.[0]
|
||||||
|
);
|
||||||
|
if (!detectedRole) {
|
||||||
|
const fallbackRole = normalizeRoleValue(getStoredPreferredRole(normalized));
|
||||||
|
setRoleHint(
|
||||||
|
fallbackRole
|
||||||
|
? `Role: ${formatRoleLabel(fallbackRole)}`
|
||||||
|
: 'Role: Not assigned'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRoleHint(`Role: ${formatRoleLabel(detectedRole)}`);
|
||||||
|
const roleLower = detectedRole.toLowerCase();
|
||||||
|
if (roleLower === 'company' || roleLower === 'customer' || roleLower === 'job_seeker' || roleLower === 'professional') {
|
||||||
|
setRoleGuess(roleLower as RoleKey);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRoleHint('');
|
||||||
|
} finally {
|
||||||
|
setCheckingRole(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setOtpDigit = (index: number, value: string) => {
|
const setOtpDigit = (index: number, value: string) => {
|
||||||
const clean = value.replace(/\D/g, '').slice(0, 1);
|
const clean = value.replace(/\D/g, '').slice(0, 1);
|
||||||
setOtp((prev) => {
|
setOtp((prev) => {
|
||||||
|
|
@ -65,13 +154,14 @@ export default function LoginRoute() {
|
||||||
const fullName = String(user?.full_name || user?.fullName || '').trim();
|
const fullName = String(user?.full_name || user?.fullName || '').trim();
|
||||||
const [firstName, ...rest] = fullName.split(' ');
|
const [firstName, ...rest] = fullName.split(' ');
|
||||||
const lastName = rest.join(' ');
|
const lastName = rest.join(' ');
|
||||||
const normalizedRole = String(user?.active_role || user?.role || roleGuess() || '')
|
const normalizedRole = resolveActiveRole(
|
||||||
.trim()
|
user?.active_role || user?.role || roleGuess(),
|
||||||
.toUpperCase()
|
String(user?.email || email())
|
||||||
.replace(/\s+/g, '_');
|
);
|
||||||
const storedRole = normalizedRole
|
const storedRole = normalizedRole
|
||||||
? normalizedRole.toLowerCase()
|
? normalizedRole.toLowerCase()
|
||||||
: roleGuess();
|
: roleGuess();
|
||||||
|
const selectedProfessionalRole = getStoredPreferredRole(String(user?.email || email()));
|
||||||
const payload = {
|
const payload = {
|
||||||
firstName: firstName || '',
|
firstName: firstName || '',
|
||||||
lastName: lastName || '',
|
lastName: lastName || '',
|
||||||
|
|
@ -82,6 +172,7 @@ export default function LoginRoute() {
|
||||||
roleKey: storedRole,
|
roleKey: storedRole,
|
||||||
role: storedRole,
|
role: storedRole,
|
||||||
active_role: normalizedRole || 'JOB_SEEKER',
|
active_role: normalizedRole || 'JOB_SEEKER',
|
||||||
|
selectedProfessionalRole: selectedProfessionalRole || null,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|
@ -92,6 +183,7 @@ export default function LoginRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
|
if (submitting()) return;
|
||||||
setError('');
|
setError('');
|
||||||
if (!isValidEmail(email())) {
|
if (!isValidEmail(email())) {
|
||||||
setError('Enter a valid email address.');
|
setError('Enter a valid email address.');
|
||||||
|
|
@ -135,23 +227,32 @@ export default function LoginRoute() {
|
||||||
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
|
window.sessionStorage.setItem('nxtgauge_access_token', accessToken);
|
||||||
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
|
window.sessionStorage.setItem('nxtgauge_frontend_access_token', accessToken);
|
||||||
}
|
}
|
||||||
saveUser(data?.user || {});
|
const resolvedActiveRole = resolveActiveRole(
|
||||||
|
data?.user?.active_role || data?.user?.role,
|
||||||
|
data?.user?.email || email().trim().toLowerCase()
|
||||||
|
);
|
||||||
|
const normalizedEmail = email().trim().toLowerCase();
|
||||||
|
const userPayload = {
|
||||||
|
id: String(data?.user?.id || ''),
|
||||||
|
email: String(data?.user?.email || normalizedEmail),
|
||||||
|
full_name: String(data?.user?.full_name || ''),
|
||||||
|
active_role: resolvedActiveRole,
|
||||||
|
email_verified: Boolean(data?.user?.email_verified ?? true),
|
||||||
|
};
|
||||||
|
saveUser({ ...(data?.user || {}), ...userPayload });
|
||||||
if (auth.setUser) {
|
if (auth.setUser) {
|
||||||
auth.setUser({
|
auth.setUser(userPayload);
|
||||||
id: data?.user?.id || '',
|
|
||||||
email: data?.user?.email || email().trim().toLowerCase(),
|
|
||||||
full_name: data?.user?.full_name || '',
|
|
||||||
active_role: data?.user?.active_role || 'JOB_SEEKER',
|
|
||||||
email_verified: data?.user?.email_verified || false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
|
} catch {
|
||||||
|
setError('Network error during login. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resendOtp = async () => {
|
const resendOtp = async () => {
|
||||||
|
if (submitting()) return;
|
||||||
setError('');
|
setError('');
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -171,6 +272,7 @@ export default function LoginRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyThenLogin = async () => {
|
const verifyThenLogin = async () => {
|
||||||
|
if (submitting()) return;
|
||||||
setError('');
|
setError('');
|
||||||
if (otpCode().length !== 6) {
|
if (otpCode().length !== 6) {
|
||||||
setError('Enter a valid 6-digit OTP.');
|
setError('Enter a valid 6-digit OTP.');
|
||||||
|
|
@ -216,10 +318,29 @@ export default function LoginRoute() {
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="login-email">EMAIL</label>
|
<label class="label" for="login-email">EMAIL</label>
|
||||||
<input id="login-email" type="email" class="input" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} placeholder="Enter your email" />
|
<input
|
||||||
|
id="login-email"
|
||||||
|
type="email"
|
||||||
|
class="input"
|
||||||
|
value={email()}
|
||||||
|
onInput={(e) => {
|
||||||
|
const value = e.currentTarget.value;
|
||||||
|
setEmail(value);
|
||||||
|
void lookupRoleByEmail(value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
void lookupRoleByEmail(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
placeholder="Enter your email"
|
||||||
|
/>
|
||||||
<p class="validation-note" style={{ color: email().trim() && isValidEmail(email()) ? '#fd6116' : '#6e7591' }}>
|
<p class="validation-note" style={{ color: email().trim() && isValidEmail(email()) ? '#fd6116' : '#6e7591' }}>
|
||||||
{email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format'}
|
{email().trim() && isValidEmail(email()) ? '✓ Valid email format' : '• Enter a valid email format'}
|
||||||
</p>
|
</p>
|
||||||
|
<Show when={roleHint() || checkingRole()}>
|
||||||
|
<p class="validation-note" style={{ color: '#0f766e' }}>
|
||||||
|
{checkingRole() ? 'Checking account role...' : `• ${roleHint()}`}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { A, useNavigate, useSearchParams } from '@solidjs/router';
|
import { A, useNavigate, useSearchParams } from "@solidjs/router";
|
||||||
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
|
import { createMemo, createSignal, For, onMount, Show } from "solid-js";
|
||||||
import PublicBackground from '~/components/PublicBackground';
|
import PublicBackground from "~/components/PublicBackground";
|
||||||
import PublicHeader from '~/components/PublicHeader';
|
import PublicHeader from "~/components/PublicHeader";
|
||||||
import CaptchaCanvas from '~/components/CaptchaCanvas';
|
import CaptchaCanvas from "~/components/CaptchaCanvas";
|
||||||
import {
|
import {
|
||||||
checkPasswordStrength,
|
checkPasswordStrength,
|
||||||
isPasswordStrong,
|
isPasswordStrong,
|
||||||
|
|
@ -10,24 +10,36 @@ import {
|
||||||
isValidEmail,
|
isValidEmail,
|
||||||
isValidName,
|
isValidName,
|
||||||
validateRegisterForm,
|
validateRegisterForm,
|
||||||
} from '~/lib/form-validation';
|
} from "~/lib/form-validation";
|
||||||
|
|
||||||
type RoleKey = 'company' | 'job_seeker' | 'professional' | 'customer';
|
type RoleKey = "company" | "job_seeker" | "professional" | "customer";
|
||||||
|
|
||||||
type RegisterErrors = Record<string, string>;
|
type RegisterErrors = Record<string, string>;
|
||||||
|
|
||||||
function normalizeIntent(intent: string | null | undefined): RoleKey {
|
function normalizeIntent(intent: string | null | undefined): RoleKey {
|
||||||
const v = String(intent || '').toLowerCase();
|
const v = String(intent || "").toLowerCase();
|
||||||
if (v.includes('company')) return 'company';
|
if (v.includes("company")) return "company";
|
||||||
if (v.includes('professional')) return 'professional';
|
if (v.includes("professional")) return "professional";
|
||||||
if (v.includes('developer') || v.includes('photographer') || v.includes('makeup') || v.includes('tutor') || v.includes('video') || v.includes('graphic') || v.includes('social') || v.includes('fitness') || v.includes('catering') || v.includes('ugc')) return 'professional';
|
if (
|
||||||
if (v.includes('customer')) return 'customer';
|
v.includes("developer") ||
|
||||||
return 'job_seeker';
|
v.includes("photographer") ||
|
||||||
|
v.includes("makeup") ||
|
||||||
|
v.includes("tutor") ||
|
||||||
|
v.includes("video") ||
|
||||||
|
v.includes("graphic") ||
|
||||||
|
v.includes("social") ||
|
||||||
|
v.includes("fitness") ||
|
||||||
|
v.includes("catering") ||
|
||||||
|
v.includes("ugc")
|
||||||
|
)
|
||||||
|
return "professional";
|
||||||
|
if (v.includes("customer")) return "customer";
|
||||||
|
return "job_seeker";
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomCaptcha(length = 6): string {
|
function randomCaptcha(length = 6): string {
|
||||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
let out = '';
|
let out = "";
|
||||||
for (let i = 0; i < length; i += 1) {
|
for (let i = 0; i < length; i += 1) {
|
||||||
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||||
}
|
}
|
||||||
|
|
@ -61,33 +73,38 @@ export default function SignupRoute() {
|
||||||
// If no intent/role provided, normalizeIntent will default to job_seeker.
|
// If no intent/role provided, normalizeIntent will default to job_seeker.
|
||||||
});
|
});
|
||||||
|
|
||||||
const [step, setStep] = createSignal<'register' | 'verify'>('register');
|
const [step, setStep] = createSignal<"register" | "verify">("register");
|
||||||
const [firstName, setFirstName] = createSignal('');
|
const [firstName, setFirstName] = createSignal("");
|
||||||
const [lastName, setLastName] = createSignal('');
|
const [lastName, setLastName] = createSignal("");
|
||||||
const [email, setEmail] = createSignal('');
|
const [email, setEmail] = createSignal("");
|
||||||
const [password, setPassword] = createSignal('');
|
const [password, setPassword] = createSignal("");
|
||||||
const [confirmPassword, setConfirmPassword] = createSignal('');
|
const [confirmPassword, setConfirmPassword] = createSignal("");
|
||||||
const role = createMemo<RoleKey>(() => normalizeIntent(search.intent || search.role));
|
const role = createMemo<RoleKey>(() => normalizeIntent(search.intent || search.role));
|
||||||
const selectedProfessionalRole = createMemo(() => String(search.role || '').trim().toUpperCase());
|
const selectedProfessionalRole = createMemo(() =>
|
||||||
|
String(search.role || "")
|
||||||
|
.trim()
|
||||||
|
.toUpperCase()
|
||||||
|
);
|
||||||
const [termsAccepted, setTermsAccepted] = createSignal(false);
|
const [termsAccepted, setTermsAccepted] = createSignal(false);
|
||||||
const [captcha, setCaptcha] = createSignal('');
|
const [captcha, setCaptcha] = createSignal("");
|
||||||
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
|
const [captchaCode, setCaptchaCode] = createSignal(randomCaptcha());
|
||||||
const [otp, setOtp] = createSignal(['', '', '', '', '', '']);
|
const [otp, setOtp] = createSignal(["", "", "", "", "", ""]);
|
||||||
const [errors, setErrors] = createSignal<RegisterErrors>({});
|
const [errors, setErrors] = createSignal<RegisterErrors>({});
|
||||||
const [serverError, setServerError] = createSignal('');
|
const [serverError, setServerError] = createSignal("");
|
||||||
const [emailExists, setEmailExists] = createSignal(false);
|
const [emailExists, setEmailExists] = createSignal(false);
|
||||||
const [submitting, setSubmitting] = createSignal(false);
|
const [submitting, setSubmitting] = createSignal(false);
|
||||||
const [pendingEmail, setPendingEmail] = createSignal('');
|
const [pendingEmail, setPendingEmail] = createSignal("");
|
||||||
const [verifiedSuccess, setVerifiedSuccess] = createSignal(false);
|
const [verifiedSuccess, setVerifiedSuccess] = createSignal(false);
|
||||||
const [showPassword, setShowPassword] = createSignal(false);
|
const [showPassword, setShowPassword] = createSignal(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
|
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
|
||||||
|
|
||||||
const passwordChecks = createMemo(() => checkPasswordStrength(password(), confirmPassword()));
|
const passwordChecks = createMemo(() => checkPasswordStrength(password(), confirmPassword()));
|
||||||
const otpCode = createMemo(() => otp().join(''));
|
const otpCode = createMemo(() => otp().join(""));
|
||||||
const firstNameValid = createMemo(() => !firstName().trim() || isValidName(firstName()));
|
const firstNameValid = createMemo(() => !firstName().trim() || isValidName(firstName()));
|
||||||
const lastNameValid = createMemo(() => !lastName().trim() || isValidName(lastName()));
|
const lastNameValid = createMemo(() => !lastName().trim() || isValidName(lastName()));
|
||||||
const emailValid = createMemo(() => !email().trim() || isValidEmail(email()));
|
const emailValid = createMemo(() => !email().trim() || isValidEmail(email()));
|
||||||
const canSubmit = createMemo(() =>
|
const canSubmit = createMemo(
|
||||||
|
() =>
|
||||||
firstName().trim().length > 0 &&
|
firstName().trim().length > 0 &&
|
||||||
firstNameValid() &&
|
firstNameValid() &&
|
||||||
lastName().trim().length > 0 &&
|
lastName().trim().length > 0 &&
|
||||||
|
|
@ -102,7 +119,7 @@ export default function SignupRoute() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCaptcha = () => {
|
const refreshCaptcha = () => {
|
||||||
setCaptcha('');
|
setCaptcha("");
|
||||||
setCaptchaCode(randomCaptcha());
|
setCaptchaCode(randomCaptcha());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -114,10 +131,10 @@ export default function SignupRoute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/gateway/api/auth/check-email', {
|
const response = await fetch("/api/gateway/api/auth/check-email", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({ email: normalized }),
|
body: JSON.stringify({ email: normalized }),
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
@ -131,7 +148,7 @@ export default function SignupRoute() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const setOtpDigit = (index: number, value: string) => {
|
const setOtpDigit = (index: number, value: string) => {
|
||||||
const clean = value.replace(/\D/g, '').slice(0, 1);
|
const clean = value.replace(/\D/g, "").slice(0, 1);
|
||||||
setOtp((prev) => {
|
setOtp((prev) => {
|
||||||
const next = prev.slice();
|
const next = prev.slice();
|
||||||
next[index] = clean;
|
next[index] = clean;
|
||||||
|
|
@ -143,7 +160,13 @@ export default function SignupRoute() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveUserForDashboard = (input: { firstName: string; lastName: string; email: string; roleKey: RoleKey; user?: any }) => {
|
const saveUserForDashboard = (input: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
roleKey: RoleKey;
|
||||||
|
user?: any;
|
||||||
|
}) => {
|
||||||
const fullName = `${input.firstName} ${input.lastName}`.trim();
|
const fullName = `${input.firstName} ${input.lastName}`.trim();
|
||||||
const payload = {
|
const payload = {
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
|
|
@ -157,15 +180,15 @@ export default function SignupRoute() {
|
||||||
selectedProfessionalRole: selectedProfessionalRole() || null,
|
selectedProfessionalRole: selectedProfessionalRole() || null,
|
||||||
user: input.user || null,
|
user: input.user || null,
|
||||||
};
|
};
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
window.localStorage.setItem('nxtgauge_signup_profile_v1', JSON.stringify(payload));
|
window.localStorage.setItem("nxtgauge_signup_profile_v1", JSON.stringify(payload));
|
||||||
window.localStorage.setItem('nxtgauge_auth_user', JSON.stringify(payload));
|
window.localStorage.setItem("nxtgauge_auth_user", JSON.stringify(payload));
|
||||||
window.localStorage.setItem('nxtgauge_user', JSON.stringify(payload));
|
window.localStorage.setItem("nxtgauge_user", JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async () => {
|
const register = async () => {
|
||||||
setServerError('');
|
setServerError("");
|
||||||
const validation = validateRegisterForm({
|
const validation = validateRegisterForm({
|
||||||
firstName: firstName(),
|
firstName: firstName(),
|
||||||
lastName: lastName(),
|
lastName: lastName(),
|
||||||
|
|
@ -181,23 +204,23 @@ export default function SignupRoute() {
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/gateway/api/auth/register', {
|
const res = await fetch("/api/auth/register", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
full_name: `${firstName().trim()} ${lastName().trim()}`.trim(),
|
full_name: `${firstName().trim()} ${lastName().trim()}`.trim(),
|
||||||
email: email().trim().toLowerCase(),
|
email: email().trim().toLowerCase(),
|
||||||
password: password(),
|
password: password(),
|
||||||
phone: null,
|
phone: "",
|
||||||
intent: role(),
|
intent: role(),
|
||||||
profession: selectedProfessionalRole() || undefined,
|
role_key: selectedProfessionalRole() || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setServerError(String(data?.error || data?.message || 'Unable to create account.'));
|
setServerError(String(data?.error || data?.message || "Unable to create account."));
|
||||||
refreshCaptcha();
|
refreshCaptcha();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -211,52 +234,52 @@ export default function SignupRoute() {
|
||||||
email: cleanEmail,
|
email: cleanEmail,
|
||||||
roleKey: role(),
|
roleKey: role(),
|
||||||
});
|
});
|
||||||
setStep('verify');
|
setStep("verify");
|
||||||
setOtp(['', '', '', '', '', '']);
|
setOtp(["", "", "", "", "", ""]);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyOtp = async () => {
|
const verifyOtp = async () => {
|
||||||
setServerError('');
|
setServerError("");
|
||||||
if (otpCode().length !== 6) {
|
if (otpCode().length !== 6) {
|
||||||
setServerError('Enter the 6-digit code sent to your email.');
|
setServerError("Enter the 6-digit code sent to your email.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const verifyRes = await fetch('/api/gateway/api/auth/verify-email', {
|
const verifyRes = await fetch("/api/gateway/api/auth/verify-email", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({ otp: otpCode() }),
|
body: JSON.stringify({ otp: otpCode() }),
|
||||||
});
|
});
|
||||||
const verifyData = await verifyRes.json().catch(() => ({}));
|
const verifyData = await verifyRes.json().catch(() => ({}));
|
||||||
if (!verifyRes.ok) {
|
if (!verifyRes.ok) {
|
||||||
setServerError(String(verifyData?.error || verifyData?.message || 'Verification failed.'));
|
setServerError(String(verifyData?.error || verifyData?.message || "Verification failed."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setVerifiedSuccess(true);
|
setVerifiedSuccess(true);
|
||||||
setTimeout(() => navigate('/login?verified=1', { replace: true }), 1400);
|
setTimeout(() => navigate("/login?verified=1", { replace: true }), 1400);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resendOtp = async () => {
|
const resendOtp = async () => {
|
||||||
setServerError('');
|
setServerError("");
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/gateway/api/auth/resend-otp', {
|
const res = await fetch("/api/gateway/api/auth/resend-otp", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({ email: pendingEmail() || email().trim().toLowerCase() }),
|
body: JSON.stringify({ email: pendingEmail() || email().trim().toLowerCase() }),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setServerError(String(data?.error || data?.message || 'Unable to resend OTP right now.'));
|
setServerError(String(data?.error || data?.message || "Unable to resend OTP right now."));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
@ -274,23 +297,46 @@ export default function SignupRoute() {
|
||||||
<div class="auth-visual-content">
|
<div class="auth-visual-content">
|
||||||
<p class="eyebrow">Get Started</p>
|
<p class="eyebrow">Get Started</p>
|
||||||
<h1 class="title light">Create Your Nxtgauge Account</h1>
|
<h1 class="title light">Create Your Nxtgauge Account</h1>
|
||||||
<p class="subtitle light">Join verified opportunities and continue directly to your dashboard after signup.</p>
|
<p class="subtitle light">
|
||||||
|
Join verified opportunities and continue directly to your dashboard after signup.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="auth-form card glass-light">
|
<section class="auth-form card glass-light">
|
||||||
<Show when={step() === 'register'} fallback={
|
<Show
|
||||||
|
when={step() === "register"}
|
||||||
|
fallback={
|
||||||
<>
|
<>
|
||||||
<h2 class="title">Verify Email</h2>
|
<h2 class="title">Verify Email</h2>
|
||||||
<p class="subtitle">Enter the 6-digit code sent to <strong>{pendingEmail() || email()}</strong>.</p>
|
<p class="subtitle">
|
||||||
|
Enter the 6-digit code sent to <strong>{pendingEmail() || email()}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
<Show when={!verifiedSuccess()} fallback={
|
<Show
|
||||||
<div style={{ 'margin-top': '12px', 'border-radius': '12px', border: '1px solid #FED7AA', background: '#FFF7ED', padding: '14px 16px', color: '#C2410C', 'text-align': 'center' }}>
|
when={!verifiedSuccess()}
|
||||||
<div style={{ 'font-size': '30px', 'line-height': '1' }}>✓</div>
|
fallback={
|
||||||
<p style={{ margin: '8px 0 0', 'font-weight': '700', 'font-size': '14px' }}>Your email has been verified.</p>
|
<div
|
||||||
<p style={{ margin: '6px 0 0', 'font-size': '13px' }}>Redirecting to login...</p>
|
style={{
|
||||||
|
"margin-top": "12px",
|
||||||
|
"border-radius": "12px",
|
||||||
|
border: "1px solid #FED7AA",
|
||||||
|
background: "#FFF7ED",
|
||||||
|
padding: "14px 16px",
|
||||||
|
color: "#C2410C",
|
||||||
|
"text-align": "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ "font-size": "30px", "line-height": "1" }}>✓</div>
|
||||||
|
<p style={{ margin: "8px 0 0", "font-weight": "700", "font-size": "14px" }}>
|
||||||
|
Your email has been verified.
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: "6px 0 0", "font-size": "13px" }}>
|
||||||
|
Redirecting to login...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
<div class="otp-row">
|
<div class="otp-row">
|
||||||
<For each={Array.from({ length: 6 }, (_, index) => index)}>
|
<For each={Array.from({ length: 6 }, (_, index) => index)}>
|
||||||
{(index) => (
|
{(index) => (
|
||||||
|
|
@ -306,41 +352,80 @@ export default function SignupRoute() {
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="auth-submit-btn" type="button" disabled={submitting()} onClick={() => void verifyOtp()}>
|
<button
|
||||||
{submitting() ? 'Verifying...' : 'Verify and Continue'}
|
class="auth-submit-btn"
|
||||||
|
type="button"
|
||||||
|
disabled={submitting()}
|
||||||
|
onClick={() => void verifyOtp()}
|
||||||
|
>
|
||||||
|
{submitting() ? "Verifying..." : "Verify and Continue"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="auth-footer-row">
|
<div class="auth-footer-row">
|
||||||
<p class="note">Didn’t receive code?</p>
|
<p class="note">Didn’t receive code?</p>
|
||||||
<button class="auth-forgot-link" type="button" onClick={() => void resendOtp()} disabled={submitting()}>
|
<button
|
||||||
|
class="auth-forgot-link"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void resendOtp()}
|
||||||
|
disabled={submitting()}
|
||||||
|
>
|
||||||
Resend OTP
|
Resend OTP
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
}>
|
}
|
||||||
|
>
|
||||||
<h2 class="title">Create Your Account</h2>
|
<h2 class="title">Create Your Account</h2>
|
||||||
<p class="subtitle">Sign up first, then go directly to dashboard after email verification.</p>
|
<p class="subtitle">
|
||||||
|
Sign up first, then go directly to dashboard after email verification.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
|
<div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="first-name">FULL NAME</label>
|
<label class="label" for="first-name">
|
||||||
<input id="first-name" class="input" value={firstName()} onInput={(e) => setFirstName(e.currentTarget.value)} />
|
FULL NAME
|
||||||
<p class="validation-note" style={{ color: firstName().trim() && firstNameValid() ? '#fd6116' : '#6e7591' }}>
|
</label>
|
||||||
{firstName().trim() && firstNameValid() ? '✓ First name looks good' : '• First name is required'}
|
<input
|
||||||
|
id="first-name"
|
||||||
|
class="input"
|
||||||
|
value={firstName()}
|
||||||
|
onInput={(e) => setFirstName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
class="validation-note"
|
||||||
|
style={{ color: firstName().trim() && firstNameValid() ? "#fd6116" : "#6e7591" }}
|
||||||
|
>
|
||||||
|
{firstName().trim() && firstNameValid()
|
||||||
|
? "✓ First name looks good"
|
||||||
|
: "• First name is required"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="last-name">LAST NAME</label>
|
<label class="label" for="last-name">
|
||||||
<input id="last-name" class="input" value={lastName()} onInput={(e) => setLastName(e.currentTarget.value)} />
|
LAST NAME
|
||||||
<p class="validation-note" style={{ color: lastName().trim() && lastNameValid() ? '#fd6116' : '#6e7591' }}>
|
</label>
|
||||||
{lastName().trim() && lastNameValid() ? '✓ Last name looks good' : '• Last name is required'}
|
<input
|
||||||
|
id="last-name"
|
||||||
|
class="input"
|
||||||
|
value={lastName()}
|
||||||
|
onInput={(e) => setLastName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
class="validation-note"
|
||||||
|
style={{ color: lastName().trim() && lastNameValid() ? "#fd6116" : "#6e7591" }}
|
||||||
|
>
|
||||||
|
{lastName().trim() && lastNameValid()
|
||||||
|
? "✓ Last name looks good"
|
||||||
|
: "• Last name is required"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="email">EMAIL ADDRESS</label>
|
<label class="label" for="email">
|
||||||
|
EMAIL ADDRESS
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
|
@ -350,82 +435,168 @@ export default function SignupRoute() {
|
||||||
setEmail(e.currentTarget.value);
|
setEmail(e.currentTarget.value);
|
||||||
setEmailExists(false);
|
setEmailExists(false);
|
||||||
}}
|
}}
|
||||||
onBlur={() => { void checkEmailExists(email()); }}
|
onBlur={() => {
|
||||||
|
void checkEmailExists(email());
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p class="validation-note" style={{ color: emailExists() ? '#dc2626' : (email().trim() && emailValid() ? '#fd6116' : '#6e7591') }}>
|
<p
|
||||||
|
class="validation-note"
|
||||||
|
style={{
|
||||||
|
color: emailExists()
|
||||||
|
? "#dc2626"
|
||||||
|
: email().trim() && emailValid()
|
||||||
|
? "#fd6116"
|
||||||
|
: "#6e7591",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{emailExists()
|
{emailExists()
|
||||||
? '• This email is already registered'
|
? "• This email is already registered"
|
||||||
: (email().trim() && emailValid() ? '✓ Valid email format' : '• Enter a valid email format')}
|
: email().trim() && emailValid()
|
||||||
|
? "✓ Valid email format"
|
||||||
|
: "• Enter a valid email format"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
|
<div class="grid" style={{ "grid-template-columns": "1fr 1fr", margin: 0 }}>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="password">PASSWORD</label>
|
<label class="label" for="password">
|
||||||
|
PASSWORD
|
||||||
|
</label>
|
||||||
<div class="auth-password-wrap">
|
<div class="auth-password-wrap">
|
||||||
<input id="password" type={showPassword() ? 'text' : 'password'} class="input" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword() ? "text" : "password"}
|
||||||
|
class="input"
|
||||||
|
value={password()}
|
||||||
|
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
class="auth-toggle-visibility"
|
class="auth-toggle-visibility"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
aria-label={showPassword() ? 'Hide password' : 'Show password'}
|
aria-label={showPassword() ? "Hide password" : "Show password"}
|
||||||
>
|
>
|
||||||
<PasswordVisibilityIcon visible={showPassword()} />
|
<PasswordVisibilityIcon visible={showPassword()} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="password-strength-grid">
|
<div class="password-strength-grid">
|
||||||
<p style={{ color: passwordChecks().minLength ? '#fd6116' : '#6e7591' }}>{passwordChecks().minLength ? '✓' : '•'} 8+ chars</p>
|
<p style={{ color: passwordChecks().minLength ? "#fd6116" : "#6e7591" }}>
|
||||||
<p style={{ color: passwordChecks().uppercase ? '#fd6116' : '#6e7591' }}>{passwordChecks().uppercase ? '✓' : '•'} Uppercase</p>
|
{passwordChecks().minLength ? "✓" : "•"} 8+ chars
|
||||||
<p style={{ color: passwordChecks().special ? '#fd6116' : '#6e7591' }}>{passwordChecks().special ? '✓' : '•'} Special</p>
|
</p>
|
||||||
<p style={{ color: passwordChecks().lowercase ? '#fd6116' : '#6e7591' }}>{passwordChecks().lowercase ? '✓' : '•'} Lowercase</p>
|
<p style={{ color: passwordChecks().uppercase ? "#fd6116" : "#6e7591" }}>
|
||||||
<p style={{ color: passwordChecks().number ? '#fd6116' : '#6e7591' }}>{passwordChecks().number ? '✓' : '•'} Number</p>
|
{passwordChecks().uppercase ? "✓" : "•"} Uppercase
|
||||||
|
</p>
|
||||||
|
<p style={{ color: passwordChecks().special ? "#fd6116" : "#6e7591" }}>
|
||||||
|
{passwordChecks().special ? "✓" : "•"} Special
|
||||||
|
</p>
|
||||||
|
<p style={{ color: passwordChecks().lowercase ? "#fd6116" : "#6e7591" }}>
|
||||||
|
{passwordChecks().lowercase ? "✓" : "•"} Lowercase
|
||||||
|
</p>
|
||||||
|
<p style={{ color: passwordChecks().number ? "#fd6116" : "#6e7591" }}>
|
||||||
|
{passwordChecks().number ? "✓" : "•"} Number
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="confirm-password">CONFIRM PASSWORD</label>
|
<label class="label" for="confirm-password">
|
||||||
|
CONFIRM PASSWORD
|
||||||
|
</label>
|
||||||
<div class="auth-password-wrap">
|
<div class="auth-password-wrap">
|
||||||
<input id="confirm-password" type={showConfirmPassword() ? 'text' : 'password'} class="input" value={confirmPassword()} onInput={(e) => setConfirmPassword(e.currentTarget.value)} />
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
type={showConfirmPassword() ? "text" : "password"}
|
||||||
|
class="input"
|
||||||
|
value={confirmPassword()}
|
||||||
|
onInput={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
class="auth-toggle-visibility"
|
class="auth-toggle-visibility"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowConfirmPassword((prev) => !prev)}
|
onClick={() => setShowConfirmPassword((prev) => !prev)}
|
||||||
aria-label={showConfirmPassword() ? 'Hide password' : 'Show password'}
|
aria-label={showConfirmPassword() ? "Hide password" : "Show password"}
|
||||||
>
|
>
|
||||||
<PasswordVisibilityIcon visible={showConfirmPassword()} />
|
<PasswordVisibilityIcon visible={showConfirmPassword()} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="validation-note" style={{ color: confirmPassword() && passwordChecks().match ? '#fd6116' : '#6e7591' }}>
|
<p
|
||||||
{confirmPassword() && passwordChecks().match ? '✓ Passwords match' : '• Passwords do not match'}
|
class="validation-note"
|
||||||
|
style={{
|
||||||
|
color: confirmPassword() && passwordChecks().match ? "#fd6116" : "#6e7591",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmPassword() && passwordChecks().match
|
||||||
|
? "✓ Passwords match"
|
||||||
|
: "• Passwords do not match"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="captcha">CAPTCHA</label>
|
<label class="label" for="captcha">
|
||||||
|
CAPTCHA
|
||||||
|
</label>
|
||||||
<div class="auth-captcha-row">
|
<div class="auth-captcha-row">
|
||||||
<button type="button" class="auth-captcha-refresh" onClick={refreshCaptcha} aria-label="Refresh captcha">↻</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-captcha-refresh"
|
||||||
|
onClick={refreshCaptcha}
|
||||||
|
aria-label="Refresh captcha"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
|
<CaptchaCanvas code={captchaCode()} class="auth-captcha-canvas" />
|
||||||
<input id="captcha" class="input" value={captcha()} onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())} placeholder="Enter captcha" />
|
<input
|
||||||
|
id="captcha"
|
||||||
|
class="input"
|
||||||
|
value={captcha()}
|
||||||
|
onInput={(e) => setCaptcha(e.currentTarget.value.toUpperCase())}
|
||||||
|
placeholder="Enter captcha"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="validation-note" style={{ color: captcha() && isValidCaptcha(captcha(), captchaCode()) ? '#fd6116' : '#6e7591' }}>
|
<p
|
||||||
{captcha() ? (isValidCaptcha(captcha(), captchaCode()) ? '✓ Captcha matched' : '• Captcha does not match') : '• Enter captcha to continue'}
|
class="validation-note"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
captcha() && isValidCaptcha(captcha(), captchaCode()) ? "#fd6116" : "#6e7591",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{captcha()
|
||||||
|
? isValidCaptcha(captcha(), captchaCode())
|
||||||
|
? "✓ Captcha matched"
|
||||||
|
: "• Captcha does not match"
|
||||||
|
: "• Enter captcha to continue"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field" style={{ 'margin-top': '16px' }}>
|
<div class="field" style={{ "margin-top": "16px" }}>
|
||||||
<label class="auth-checkbox-wrapper">
|
<label class="auth-checkbox-wrapper">
|
||||||
<input class="auth-checkbox" type="checkbox" checked={termsAccepted()} onChange={(e) => setTermsAccepted(e.currentTarget.checked)} />
|
<input
|
||||||
<span class="auth-checkbox-label">I agree to the <A href="/terms">Terms and Conditions</A> and <A href="/privacy">Privacy Policy</A></span>
|
class="auth-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
checked={termsAccepted()}
|
||||||
|
onChange={(e) => setTermsAccepted(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span class="auth-checkbox-label">
|
||||||
|
I agree to the <A href="/terms">Terms and Conditions</A> and{" "}
|
||||||
|
<A href="/privacy">Privacy Policy</A>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="auth-submit-btn" type="button" disabled={submitting() || !canSubmit()} onClick={() => void register()}>
|
<button
|
||||||
{submitting() ? 'Creating Account...' : 'Sign Up'}
|
class="auth-submit-btn"
|
||||||
|
type="button"
|
||||||
|
disabled={submitting() || !canSubmit()}
|
||||||
|
onClick={() => void register()}
|
||||||
|
>
|
||||||
|
{submitting() ? "Creating Account..." : "Sign Up"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="auth-footer-row">
|
<div class="auth-footer-row">
|
||||||
<p class="footer-text">We will send a verification code to your email.</p>
|
<p class="footer-text">We will send a verification code to your email.</p>
|
||||||
<p class="note">Already have an account? <A href="/login">Sign In</A></p>
|
<p class="note">
|
||||||
|
Already have an account? <A href="/login">Sign In</A>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,24 @@
|
||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from '@solidjs/start/config';
|
import { defineConfig } from "@solidjs/start/config";
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()]
|
plugins: [tailwindcss()],
|
||||||
}
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api/gateway": {
|
||||||
|
target: "http://localhost:9100",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) =>
|
||||||
|
path
|
||||||
|
.replace(/^\/api\/gateway\/api(\/|$)/, "/api$1")
|
||||||
|
.replace(/^\/api\/gateway(\/|$)/, "/api$1"),
|
||||||
|
},
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:9100",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue