feat(dashboard): real My Profile, My Portfolio, Verification pages
- DashboardShell: sticky sidebar + header wrapper with shared style tokens - ProfilePage: 3-tab form (Basic, Documents, Settings) per role, save/submit-for-verification - PortfolioPage: full CRUD wired to /api/:prefix/portfolio/me endpoints - VerificationStatusPage: 7-state status display with progress timeline and resubmit flow - dashboard.tsx: REAL_PAGES routing intercepts these three sidebar items and renders real components instead of DashboardDesignPreview mock Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ed68a636f5
commit
bbf11b91e1
5 changed files with 1549 additions and 7 deletions
249
src/components/DashboardShell.tsx
Normal file
249
src/components/DashboardShell.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* DashboardShell — real sidebar + header layout wrapper.
|
||||
* Used for pages that need actual backend connectivity
|
||||
* (My Profile, My Portfolio, Verification) instead of the preview mock.
|
||||
*/
|
||||
import { For, JSX, Show, createMemo } from 'solid-js';
|
||||
import {
|
||||
User, Briefcase, LayoutDashboard, FolderOpen, MapPin, Star,
|
||||
CreditCard, Globe, ShieldCheck, HelpCircle, Settings,
|
||||
RefreshCw, LogOut, Bell, ChevronRight,
|
||||
} from 'lucide-solid';
|
||||
|
||||
// ── Icon map (matches DashboardDesignPreview sidebar keys) ────────────────────
|
||||
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'my dashboard': LayoutDashboard,
|
||||
'my profile': User,
|
||||
'my portfolio': FolderOpen,
|
||||
'leads': MapPin,
|
||||
'my responses': Star,
|
||||
'credits': CreditCard,
|
||||
'explore nxtgauge': Globe,
|
||||
'verification': ShieldCheck,
|
||||
'help center': HelpCircle,
|
||||
'settings': Settings,
|
||||
'switch services': RefreshCw,
|
||||
'jobs': Briefcase,
|
||||
'applications': Briefcase,
|
||||
'shortlisted candidates': User,
|
||||
'my applications': FolderOpen,
|
||||
'saved jobs': Star,
|
||||
'my requirements': FolderOpen,
|
||||
'received responses': Bell,
|
||||
'shortlisted responses': Star,
|
||||
'logout': LogOut,
|
||||
};
|
||||
|
||||
function SidebarIcon(props: { label: string }) {
|
||||
const key = props.label.toLowerCase();
|
||||
const Icon = ICON_MAP[key] || ChevronRight;
|
||||
return <Icon size={16} />;
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
sidebarItems: string[];
|
||||
activeSidebar: string;
|
||||
onSidebarSelect: (item: string) => void;
|
||||
roleKey: string;
|
||||
userName?: string;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
// ── Brand colours ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ORANGE = '#FF5E13';
|
||||
const NAVY = '#0D0D2A';
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DashboardShell(props: Props) {
|
||||
const roleLabel = createMemo(() => {
|
||||
const k = String(props.roleKey || '').replace(/_/g, ' ');
|
||||
return k.charAt(0).toUpperCase() + k.slice(1).toLowerCase();
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
'min-height': '100vh',
|
||||
background: '#F3F4F6',
|
||||
'font-family': "'Inter', sans-serif",
|
||||
}}>
|
||||
|
||||
{/* ── Sidebar ──────────────────────────────────────────────────────── */}
|
||||
<aside style={{
|
||||
width: '220px',
|
||||
'flex-shrink': '0',
|
||||
background: NAVY,
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
'padding': '0',
|
||||
'min-height': '100vh',
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
height: '100vh',
|
||||
'overflow-y': 'auto',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ padding: '20px 16px 12px', 'border-bottom': '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ 'font-size': '20px', 'font-weight': '800', color: ORANGE }}>Nxtgauge</span>
|
||||
</div>
|
||||
|
||||
{/* Role badge */}
|
||||
<div style={{ padding: '10px 16px', 'border-bottom': '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<p style={{ margin: '0', 'font-size': '10px', 'letter-spacing': '0.08em', 'text-transform': 'uppercase', color: 'rgba(255,255,255,0.4)' }}>Active Role</p>
|
||||
<p style={{ margin: '2px 0 0', 'font-size': '12px', 'font-weight': '700', color: 'rgba(255,255,255,0.85)' }}>{roleLabel()}</p>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav style={{ flex: '1', padding: '8px 8px' }}>
|
||||
<For each={props.sidebarItems}>
|
||||
{(item) => {
|
||||
const isActive = () => item.toLowerCase() === props.activeSidebar.toLowerCase();
|
||||
const isLogout = item.toLowerCase() === 'logout';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSidebarSelect(item)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
gap: '10px',
|
||||
width: '100%',
|
||||
'text-align': 'left',
|
||||
padding: '9px 10px',
|
||||
'border-radius': '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
'font-size': '13px',
|
||||
'font-weight': isActive() ? '700' : '500',
|
||||
'margin-bottom': '2px',
|
||||
background: isActive() ? 'rgba(255,94,19,0.18)' : 'transparent',
|
||||
color: isActive() ? ORANGE : isLogout ? '#F87171' : 'rgba(255,255,255,0.65)',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{ 'flex-shrink': '0', opacity: isActive() ? '1' : '0.7' }}>
|
||||
<SidebarIcon label={item} />
|
||||
</span>
|
||||
{item}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div style={{ padding: '12px 16px', 'border-top': '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<p style={{ margin: '0', 'font-size': '12px', 'font-weight': '600', color: 'rgba(255,255,255,0.75)', overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'nowrap' }}>
|
||||
{props.userName || 'User'}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Main content ─────────────────────────────────────────────────── */}
|
||||
<div style={{ flex: '1', display: 'flex', 'flex-direction': 'column', 'min-width': '0' }}>
|
||||
{/* Top bar */}
|
||||
<header style={{
|
||||
height: '56px',
|
||||
background: '#fff',
|
||||
'border-bottom': '1px solid #E5E7EB',
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'space-between',
|
||||
padding: '0 24px',
|
||||
'flex-shrink': '0',
|
||||
}}>
|
||||
<p style={{ margin: '0', 'font-size': '15px', 'font-weight': '700', color: NAVY }}>
|
||||
{props.activeSidebar}
|
||||
</p>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}>
|
||||
<Bell size={18} style={{ color: '#9CA3AF' }} />
|
||||
<div style={{
|
||||
width: '32px', height: '32px', 'border-radius': '999px',
|
||||
background: ORANGE, color: '#fff', display: 'flex',
|
||||
'align-items': 'center', 'justify-content': 'center',
|
||||
'font-size': '13px', 'font-weight': '700',
|
||||
}}>
|
||||
{(props.userName || 'U').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main style={{ flex: '1', padding: '24px', 'overflow-y': 'auto' }}>
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared UI primitives ──────────────────────────────────────────────────────
|
||||
|
||||
export const CARD = {
|
||||
background: '#fff',
|
||||
border: '1px solid #E5E7EB',
|
||||
'border-radius': '14px',
|
||||
padding: '20px',
|
||||
'box-shadow': '0 1px 4px rgba(0,0,0,0.06)',
|
||||
} as const;
|
||||
|
||||
export const BTN_PRIMARY = {
|
||||
height: '38px',
|
||||
'border-radius': '10px',
|
||||
border: 'none',
|
||||
background: NAVY,
|
||||
color: '#fff',
|
||||
padding: '0 18px',
|
||||
'font-size': '13px',
|
||||
'font-weight': '700',
|
||||
cursor: 'pointer',
|
||||
} as const;
|
||||
|
||||
export const BTN_ORANGE = {
|
||||
height: '38px',
|
||||
'border-radius': '10px',
|
||||
border: 'none',
|
||||
background: ORANGE,
|
||||
color: '#fff',
|
||||
padding: '0 18px',
|
||||
'font-size': '13px',
|
||||
'font-weight': '700',
|
||||
cursor: 'pointer',
|
||||
} as const;
|
||||
|
||||
export const BTN_GHOST = {
|
||||
height: '38px',
|
||||
'border-radius': '10px',
|
||||
border: '1px solid #E5E7EB',
|
||||
background: '#fff',
|
||||
color: '#374151',
|
||||
padding: '0 18px',
|
||||
'font-size': '13px',
|
||||
'font-weight': '600',
|
||||
cursor: 'pointer',
|
||||
} as const;
|
||||
|
||||
export const INPUT = {
|
||||
height: '40px',
|
||||
width: '100%',
|
||||
'border-radius': '8px',
|
||||
border: '1px solid #E5E7EB',
|
||||
padding: '0 12px',
|
||||
'font-size': '14px',
|
||||
color: '#111827',
|
||||
background: '#fff',
|
||||
'box-sizing': 'border-box',
|
||||
} as const;
|
||||
|
||||
export const LABEL = {
|
||||
display: 'block',
|
||||
'font-size': '12px',
|
||||
'font-weight': '600',
|
||||
color: '#374151',
|
||||
'margin-bottom': '6px',
|
||||
} as const;
|
||||
364
src/components/dashboard/PortfolioPage.tsx
Normal file
364
src/components/dashboard/PortfolioPage.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
/**
|
||||
* PortfolioPage — real My Portfolio CRUD, wired to backend.
|
||||
* Uses existing /api/:rolePrefix/portfolio/me endpoints.
|
||||
* Professionals only.
|
||||
*/
|
||||
import { For, Show, createSignal, onMount } from 'solid-js';
|
||||
import { CARD, BTN_ORANGE, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
// ── Role prefix map ───────────────────────────────────────────────────────────
|
||||
|
||||
const ROLE_PREFIX: Record<string, string> = {
|
||||
PHOTOGRAPHER: 'photographers',
|
||||
MAKEUP_ARTIST: 'makeup-artists',
|
||||
TUTOR: 'tutors',
|
||||
DEVELOPER: 'developers',
|
||||
VIDEO_EDITOR: 'video-editors',
|
||||
GRAPHIC_DESIGNER: 'graphic-designers',
|
||||
SOCIAL_MEDIA_MANAGER:'social-media-managers',
|
||||
FITNESS_TRAINER: 'fitness-trainers',
|
||||
CATERING_SERVICES: 'catering-services',
|
||||
UGC_CONTENT_CREATOR: 'ugc-content-creators',
|
||||
};
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PortfolioItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = { title: '', description: '', tags: '' };
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
roleKey: string;
|
||||
}
|
||||
|
||||
export default function PortfolioPage(props: Props) {
|
||||
const prefix = () => ROLE_PREFIX[props.roleKey] ?? '';
|
||||
const isProfessional = () => Boolean(prefix());
|
||||
|
||||
const [items, setItems] = createSignal<PortfolioItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [showForm, setShowForm] = createSignal(false);
|
||||
const [editId, setEditId] = createSignal<string | null>(null);
|
||||
const [form, setForm] = createSignal<FormState>({ ...EMPTY_FORM });
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [deleting, setDeleting] = createSignal<string | null>(null);
|
||||
const [error, setError] = createSignal('');
|
||||
|
||||
const loadItems = async () => {
|
||||
if (!isProfessional()) { setLoading(false); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch(`/api/${prefix()}/portfolio/me`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setItems(Array.isArray(data) ? data : (data.items ?? []));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(loadItems);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditId(null);
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setError('');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: PortfolioItem) => {
|
||||
setEditId(item.id);
|
||||
setForm({
|
||||
title: item.title,
|
||||
description: item.description ?? '',
|
||||
tags: (item.tags ?? []).join(', '),
|
||||
});
|
||||
setError('');
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const cancelForm = () => {
|
||||
setShowForm(false);
|
||||
setEditId(null);
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setError('');
|
||||
};
|
||||
|
||||
const setField = (key: keyof FormState, val: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: val }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form().title.trim()) { setError('Title is required.'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
const payload = {
|
||||
title: form().title.trim(),
|
||||
description: form().description.trim() || undefined,
|
||||
tags: form().tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
try {
|
||||
const id = editId();
|
||||
const res = id
|
||||
? await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
|
||||
: await apiFetch(`/api/${prefix()}/portfolio/me`, { method: 'POST', body: JSON.stringify(payload) });
|
||||
|
||||
if (res.ok) {
|
||||
await loadItems();
|
||||
cancelForm();
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError(d.error ?? d.message ?? 'Failed to save. Please try again.');
|
||||
}
|
||||
} catch {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Delete this portfolio item?')) return;
|
||||
setDeleting(id);
|
||||
try {
|
||||
await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'DELETE' });
|
||||
await loadItems();
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Not a professional role ─────────────────────────────────────────────
|
||||
if (!isProfessional()) {
|
||||
return (
|
||||
<div style={{ ...CARD, 'text-align': 'center', padding: '40px' }}>
|
||||
<p style={{ margin: '0', 'font-size': '15px', color: '#9CA3AF' }}>
|
||||
Portfolio is available for professional roles only.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ 'max-width': '800px' }}>
|
||||
|
||||
{/* ── Header ────────────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'margin-bottom': '16px' }}>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p>
|
||||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Showcase your work to attract clients.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Create / Edit form ─────────────────────────────────────────── */}
|
||||
<Show when={showForm()}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
|
||||
<p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}>
|
||||
{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
|
||||
<div style={{ 'grid-column': 'span 2' }}>
|
||||
<label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Wedding shoot at Udaipur"
|
||||
value={form().title}
|
||||
onInput={(e) => setField('title', e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ 'grid-column': 'span 2' }}>
|
||||
<label style={LABEL}>Description</label>
|
||||
<textarea
|
||||
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 style={{ 'grid-column': 'span 2' }}>
|
||||
<label style={LABEL}>Tags (comma separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. wedding, outdoor, portrait"
|
||||
value={form().tags}
|
||||
onInput={(e) => setField('tags', e.currentTarget.value)}
|
||||
style={INPUT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>
|
||||
{error()}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', 'margin-top': '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving()}
|
||||
style={{ ...BTN_PRIMARY, opacity: saving() ? '0.6' : '1' }}
|
||||
>
|
||||
{saving() ? 'Saving…' : editId() ? 'Update Item' : 'Add Item'}
|
||||
</button>
|
||||
<button type="button" onClick={cancelForm} style={BTN_GHOST}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Loading ─────────────────────────────────────────────────────── */}
|
||||
<Show when={loading()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
|
||||
Loading portfolio…
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Empty state ─────────────────────────────────────────────────── */}
|
||||
<Show when={!loading() && items().length === 0 && !showForm()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
|
||||
<p style={{ margin: '0', 'font-size': '40px' }}>🗂️</p>
|
||||
<p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>
|
||||
No portfolio items yet
|
||||
</p>
|
||||
<p style={{ margin: '0 0 16px', 'font-size': '13px', color: '#6B7280' }}>
|
||||
Add your first work sample to attract clients.
|
||||
</p>
|
||||
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
||||
+ Add First Item
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Portfolio grid ─────────────────────────────────────────────── */}
|
||||
<Show when={!loading() && items().length > 0}>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<div style={{
|
||||
...CARD,
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{/* Placeholder image area */}
|
||||
<div style={{
|
||||
height: '120px',
|
||||
'border-radius': '8px',
|
||||
background: '#F3F4F6',
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'center',
|
||||
color: '#D1D5DB',
|
||||
'font-size': '28px',
|
||||
}}>
|
||||
🖼️
|
||||
</div>
|
||||
|
||||
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
|
||||
{item.title}
|
||||
</p>
|
||||
|
||||
<Show when={item.description}>
|
||||
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>
|
||||
{item.description}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={item.tags && item.tags.length > 0}>
|
||||
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
|
||||
<For each={item.tags}>
|
||||
{(tag) => (
|
||||
<span style={{
|
||||
'font-size': '10px',
|
||||
'font-weight': '700',
|
||||
color: '#6B7280',
|
||||
background: '#F3F4F6',
|
||||
border: '1px solid #E5E7EB',
|
||||
'border-radius': '6px',
|
||||
padding: '2px 8px',
|
||||
}}>
|
||||
{tag}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(item)}
|
||||
style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
disabled={deleting() === item.id}
|
||||
style={{
|
||||
height: '30px',
|
||||
'border-radius': '8px',
|
||||
border: '1px solid #FECACA',
|
||||
background: '#fff',
|
||||
color: '#EF4444',
|
||||
'font-size': '11px',
|
||||
'font-weight': '700',
|
||||
padding: '0 12px',
|
||||
cursor: 'pointer',
|
||||
flex: '1',
|
||||
opacity: deleting() === item.id ? '0.6' : '1',
|
||||
}}
|
||||
>
|
||||
{deleting() === item.id ? '…' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
526
src/components/dashboard/ProfilePage.tsx
Normal file
526
src/components/dashboard/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
/**
|
||||
* ProfilePage — real My Profile form, wired to backend.
|
||||
* Supports all 13 roles. Tabs: Basic Info · Documents · Settings.
|
||||
* 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 { CARD, BTN_ORANGE, BTN_GHOST, INPUT, LABEL, BTN_PRIMARY } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
// ── Role-specific field definitions ──────────────────────────────────────────
|
||||
|
||||
const BASIC_FIELDS: Record<string, Array<{ key: string; label: string; type?: string; required?: boolean; options?: string[] }>> = {
|
||||
default: [
|
||||
{ key: 'first_name', label: 'First Name', required: true },
|
||||
{ key: 'last_name', label: 'Last Name', required: true },
|
||||
{ key: 'phone', label: 'Mobile Number', required: true },
|
||||
{ key: 'gender', label: 'Gender', type: 'select', options: ['Male', 'Female', 'Other', 'Prefer not to say'] },
|
||||
{ key: 'city', label: 'City', required: true },
|
||||
{ key: 'state', label: 'State', required: true },
|
||||
{ key: 'pin_code', label: 'PIN Code' },
|
||||
{ key: 'address', label: 'Address', type: 'textarea' },
|
||||
],
|
||||
COMPANY: [
|
||||
{ key: 'company_name', label: 'Company Name', required: true },
|
||||
{ key: 'company_email', label: 'Company Email', type: 'email', required: true },
|
||||
{ key: 'company_phone', label: 'Company Phone' },
|
||||
{ key: 'website', label: 'Website URL', type: 'url' },
|
||||
{ key: 'city', label: 'City', required: true },
|
||||
{ key: 'state', label: 'State', required: true },
|
||||
{ key: 'pin_code', label: 'PIN Code' },
|
||||
{ key: 'address', label: 'Registered Address', type: 'textarea' },
|
||||
{ key: 'gst_number', label: 'GST Number (optional)' },
|
||||
],
|
||||
PHOTOGRAPHER: [
|
||||
{ key: 'first_name', label: 'First Name', required: true },
|
||||
{ key: 'last_name', label: 'Last Name', required: true },
|
||||
{ key: 'phone', label: 'Mobile Number', required: true },
|
||||
{ key: 'city', label: 'City', required: true },
|
||||
{ key: 'state', label: 'State', required: true },
|
||||
{ key: 'pin_code', label: 'PIN Code' },
|
||||
{ key: 'speciality', label: 'Photography Speciality', type: 'select',
|
||||
options: ['Wedding', 'Portrait', 'Commercial', 'Event', 'Wildlife', 'Fashion', 'Product', 'Other'] },
|
||||
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
||||
{ key: 'bio', label: 'Short Bio', type: 'textarea' },
|
||||
],
|
||||
FITNESS_TRAINER: [
|
||||
{ key: 'first_name', label: 'First Name', required: true },
|
||||
{ key: 'last_name', label: 'Last Name', required: true },
|
||||
{ key: 'phone', label: 'Mobile Number', required: true },
|
||||
{ key: 'city', label: 'City', required: true },
|
||||
{ key: 'state', label: 'State', required: true },
|
||||
{ key: 'training_type', label: 'Training Type', type: 'select',
|
||||
options: ['Personal Training', 'Group Fitness', 'Yoga', 'CrossFit', 'Zumba', 'Pilates', 'Other'] },
|
||||
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
||||
{ key: 'bio', label: 'Short Bio', type: 'textarea' },
|
||||
],
|
||||
TUTOR: [
|
||||
{ key: 'first_name', label: 'First Name', required: true },
|
||||
{ key: 'last_name', label: 'Last Name', required: true },
|
||||
{ key: 'phone', label: 'Mobile Number', required: true },
|
||||
{ key: 'city', label: 'City', required: true },
|
||||
{ key: 'state', label: 'State', required: true },
|
||||
{ key: 'subjects', label: 'Subjects Taught (comma separated)' },
|
||||
{ key: 'experience_years', label: 'Years of Experience', type: 'number' },
|
||||
{ key: 'bio', label: 'Short Bio', type: 'textarea' },
|
||||
],
|
||||
CATERING_SERVICES: [
|
||||
{ key: 'business_name', label: 'Business Name', required: true },
|
||||
{ key: 'owner_name', label: 'Owner Name', required: true },
|
||||
{ key: 'phone', label: 'Contact Number', required: true },
|
||||
{ key: 'city', label: 'City', required: true },
|
||||
{ key: 'state', label: 'State', required: true },
|
||||
{ key: 'cuisine_types', label: 'Cuisine Types (comma separated)' },
|
||||
{ key: 'bio', label: 'About Your Service', type: 'textarea' },
|
||||
],
|
||||
};
|
||||
|
||||
const DOC_FIELDS: Record<string, Array<{ key: string; label: string; required?: boolean; hint?: string }>> = {
|
||||
default: [
|
||||
{ key: 'aadhar_doc', label: 'Aadhar / Government ID', required: true,
|
||||
hint: 'JPG, PNG or PDF · Max 10MB' },
|
||||
],
|
||||
COMPANY: [
|
||||
{ key: 'registration_doc', label: 'Company Registration Certificate', required: true,
|
||||
hint: 'JPG, PNG or PDF · Max 10MB' },
|
||||
{ key: 'gst_doc', label: 'GST Certificate (optional)',
|
||||
hint: 'JPG, PNG or PDF · Max 10MB' },
|
||||
],
|
||||
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' },
|
||||
],
|
||||
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' },
|
||||
],
|
||||
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' },
|
||||
],
|
||||
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' },
|
||||
],
|
||||
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' },
|
||||
],
|
||||
};
|
||||
|
||||
function getBasicFields(roleKey: string) {
|
||||
return BASIC_FIELDS[roleKey] ?? BASIC_FIELDS.default;
|
||||
}
|
||||
function getDocFields(roleKey: string) {
|
||||
return DOC_FIELDS[roleKey] ?? DOC_FIELDS.default;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
roleKey: string;
|
||||
}
|
||||
|
||||
type Tab = 'basic' | 'documents' | 'settings';
|
||||
|
||||
export default function ProfilePage(props: Props) {
|
||||
const [tab, setTab] = createSignal<Tab>('basic');
|
||||
const [form, setForm] = createSignal<Record<string, string>>({});
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [saveMsg, setSaveMsg] = createSignal('');
|
||||
const [verificationStatus, setVerificationStatus] = createSignal('NOT_SUBMITTED');
|
||||
const [docRequest, setDocRequest] = createSignal<string | null>(null);
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [submitMsg, setSubmitMsg] = createSignal('');
|
||||
|
||||
// Load saved profile + verification status on mount
|
||||
onMount(async () => {
|
||||
const [profileRes, statusRes] = await Promise.all([
|
||||
apiFetch(`/api/profile?roleKey=${props.roleKey}`),
|
||||
apiFetch(`/api/me/verification-status?roleKey=${props.roleKey}`),
|
||||
]);
|
||||
|
||||
if (profileRes.ok) {
|
||||
const data = await profileRes.json();
|
||||
if (data.profile_data && typeof data.profile_data === 'object') {
|
||||
const flat: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(data.profile_data)) {
|
||||
flat[k] = String(v ?? '');
|
||||
}
|
||||
setForm(flat);
|
||||
}
|
||||
}
|
||||
|
||||
if (statusRes.ok) {
|
||||
const s = await statusRes.json();
|
||||
setVerificationStatus(s.status ?? 'NOT_SUBMITTED');
|
||||
setDocRequest(s.document_request ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
const isLocked = () =>
|
||||
['PENDING', 'UNDER_REVIEW'].includes(verificationStatus());
|
||||
|
||||
const setField = (key: string, val: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: val }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveMsg('');
|
||||
try {
|
||||
const res = await apiFetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ roleKey: props.roleKey, profile_data: form() }),
|
||||
});
|
||||
setSaveMsg(res.ok ? 'Saved successfully.' : 'Failed to save. Please try again.');
|
||||
} catch {
|
||||
setSaveMsg('Network error. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setTimeout(() => setSaveMsg(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForVerification = async () => {
|
||||
setSubmitting(true);
|
||||
setSubmitMsg('');
|
||||
try {
|
||||
const res = await apiFetch('/api/profile/submit-for-verification', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ roleKey: props.roleKey }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setVerificationStatus('PENDING');
|
||||
setSubmitMsg('Submitted! We will review your profile and notify you.');
|
||||
} else if (res.status === 409) {
|
||||
setSubmitMsg(data.error ?? 'A verification is already in progress.');
|
||||
} else {
|
||||
setSubmitMsg(data.error ?? 'Submission failed. Please try again.');
|
||||
}
|
||||
} catch {
|
||||
setSubmitMsg('Network error. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
PENDING: '#F59E0B',
|
||||
UNDER_REVIEW: '#3B82F6',
|
||||
DOCUMENTS_REQUESTED: '#FF5E13',
|
||||
REVISION_REQUESTED: '#FF5E13',
|
||||
APPROVED: '#10B981',
|
||||
REJECTED: '#EF4444',
|
||||
NOT_SUBMITTED: '#9CA3AF',
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
PENDING: 'Pending Review',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
DOCUMENTS_REQUESTED: 'Documents Requested',
|
||||
REVISION_REQUESTED: 'Revision Requested',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
NOT_SUBMITTED: 'Not Submitted',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ 'max-width': '760px' }}>
|
||||
|
||||
{/* ── Verification status banner ─────────────────────────────────── */}
|
||||
<div style={{
|
||||
...CARD,
|
||||
'margin-bottom': '16px',
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'space-between',
|
||||
gap: '12px',
|
||||
'flex-wrap': 'wrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
'align-items': 'center',
|
||||
height: '22px',
|
||||
padding: '0 10px',
|
||||
'border-radius': '999px',
|
||||
background: `${statusColor[verificationStatus()] ?? '#9CA3AF'}22`,
|
||||
color: statusColor[verificationStatus()] ?? '#9CA3AF',
|
||||
'font-size': '11px',
|
||||
'font-weight': '700',
|
||||
}}>
|
||||
{statusLabel[verificationStatus()] ?? verificationStatus()}
|
||||
</span>
|
||||
<Show when={docRequest()}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', color: '#6B7280' }}>
|
||||
<strong style={{ color: '#FF5E13' }}>Action needed:</strong> {docRequest()}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!isLocked() && verificationStatus() !== 'APPROVED'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitForVerification}
|
||||
disabled={submitting()}
|
||||
style={{ ...BTN_ORANGE, opacity: submitting() ? '0.7' : '1' }}
|
||||
>
|
||||
{submitting() ? 'Submitting…' : 'Submit for Verification'}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={submitMsg()}>
|
||||
<div style={{
|
||||
...CARD,
|
||||
'margin-bottom': '16px',
|
||||
padding: '12px 16px',
|
||||
background: submitMsg().includes('Submitted') ? '#ECFDF5' : '#FEF2F2',
|
||||
border: `1px solid ${submitMsg().includes('Submitted') ? '#6EE7B7' : '#FECACA'}`,
|
||||
color: submitMsg().includes('Submitted') ? '#065F46' : '#B91C1C',
|
||||
'font-size': '13px',
|
||||
'font-weight': '600',
|
||||
}}>
|
||||
{submitMsg()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Tabs ──────────────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: '4px', 'margin-bottom': '16px' }}>
|
||||
<For each={[
|
||||
{ key: 'basic', label: 'Basic Information' },
|
||||
{ key: 'documents', label: 'Documents' },
|
||||
{ key: 'settings', label: 'Settings' },
|
||||
] as Array<{ key: Tab; label: string }>}>
|
||||
{(t) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
style={{
|
||||
height: '36px',
|
||||
padding: '0 16px',
|
||||
'border-radius': '8px',
|
||||
border: tab() === t.key ? '1px solid #FF5E13' : '1px solid #E5E7EB',
|
||||
background: tab() === t.key ? '#FFF3EE' : '#fff',
|
||||
color: tab() === t.key ? '#FF5E13' : '#6B7280',
|
||||
'font-size': '13px',
|
||||
'font-weight': tab() === t.key ? '700' : '500',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* ── Tab content ───────────────────────────────────────────────── */}
|
||||
<div style={CARD}>
|
||||
<Switch>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Match when={tab() === 'basic'}>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '16px' }}>
|
||||
<For each={getBasicFields(props.roleKey)}>
|
||||
{(field) => (
|
||||
<div style={{ 'grid-column': field.type === 'textarea' ? 'span 2' : 'span 1' }}>
|
||||
<label style={LABEL}>
|
||||
{field.label}
|
||||
<Show when={field.required}>
|
||||
<span style={{ color: '#EF4444' }}> *</span>
|
||||
</Show>
|
||||
</label>
|
||||
<Switch>
|
||||
<Match when={field.type === 'textarea'}>
|
||||
<textarea
|
||||
rows={3}
|
||||
disabled={isLocked()}
|
||||
value={form()[field.key] ?? ''}
|
||||
onInput={(e) => setField(field.key, e.currentTarget.value)}
|
||||
style={{
|
||||
...INPUT,
|
||||
height: 'auto',
|
||||
padding: '10px 12px',
|
||||
resize: 'vertical',
|
||||
opacity: isLocked() ? '0.6' : '1',
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={field.type === 'select'}>
|
||||
<select
|
||||
disabled={isLocked()}
|
||||
value={form()[field.key] ?? ''}
|
||||
onChange={(e) => setField(field.key, e.currentTarget.value)}
|
||||
style={{ ...INPUT, opacity: isLocked() ? '0.6' : '1' }}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
<For each={field.options ?? []}>
|
||||
{(opt) => <option value={opt}>{opt}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<input
|
||||
type={field.type ?? 'text'}
|
||||
disabled={isLocked()}
|
||||
value={form()[field.key] ?? ''}
|
||||
onInput={(e) => setField(field.key, e.currentTarget.value)}
|
||||
style={{ ...INPUT, opacity: isLocked() ? '0.6' : '1' }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={isLocked()}>
|
||||
<p style={{ margin: '16px 0 0', 'font-size': '12px', color: '#9CA3AF' }}>
|
||||
Profile is locked while verification is in progress.
|
||||
</p>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
{/* Documents */}
|
||||
<Match when={tab() === 'documents'}>
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}>
|
||||
<For each={getDocFields(props.roleKey)}>
|
||||
{(doc) => (
|
||||
<div style={{ 'border': '1px dashed #E5E7EB', 'border-radius': '10px', padding: '16px' }}>
|
||||
<p style={{ margin: '0', 'font-size': '13px', 'font-weight': '700', color: '#111827' }}>
|
||||
{doc.label}
|
||||
<Show when={doc.required}>
|
||||
<span style={{ color: '#EF4444' }}> *</span>
|
||||
</Show>
|
||||
</p>
|
||||
<Show when={doc.hint}>
|
||||
<p style={{ margin: '2px 0 10px', 'font-size': '11px', color: '#9CA3AF' }}>{doc.hint}</p>
|
||||
</Show>
|
||||
<Show
|
||||
when={form()[doc.key]}
|
||||
fallback={
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}>
|
||||
<input
|
||||
type="file"
|
||||
id={`file-${doc.key}`}
|
||||
style={{ display: 'none' }}
|
||||
disabled={isLocked()}
|
||||
onChange={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (file) setField(doc.key, file.name);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
for={`file-${doc.key}`}
|
||||
style={{
|
||||
...BTN_GHOST,
|
||||
display: 'inline-flex',
|
||||
'align-items': 'center',
|
||||
'line-height': '1',
|
||||
opacity: isLocked() ? '0.5' : '1',
|
||||
cursor: isLocked() ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Choose File
|
||||
</label>
|
||||
<span style={{ 'font-size': '12px', color: '#9CA3AF' }}>No file chosen</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '10px' }}>
|
||||
<span style={{
|
||||
'font-size': '12px', 'font-weight': '600', color: '#10B981',
|
||||
background: '#ECFDF5', padding: '4px 10px', 'border-radius': '6px',
|
||||
}}>
|
||||
✓ {form()[doc.key]}
|
||||
</span>
|
||||
<Show when={!isLocked()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setField(doc.key, '')}
|
||||
style={{ ...BTN_GHOST, height: '28px', 'font-size': '11px', padding: '0 10px' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
{/* Settings */}
|
||||
<Match when={tab() === 'settings'}>
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '20px' }}>
|
||||
<div>
|
||||
<p style={{ margin: '0 0 4px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Password & Login</p>
|
||||
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>Change your account password.</p>
|
||||
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={LABEL}>Current Password</label>
|
||||
<input type="password" style={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={LABEL}>New Password</label>
|
||||
<input type="password" style={INPUT} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ 'border-top': '1px solid #F3F4F6', 'padding-top': '16px' }}>
|
||||
<p style={{ margin: '0 0 4px', 'font-size': '15px', 'font-weight': '700', color: '#111827' }}>Notification Preferences</p>
|
||||
<p style={{ margin: '0 0 12px', 'font-size': '13px', color: '#6B7280' }}>Choose how you receive notifications.</p>
|
||||
<For each={['Email Notifications', 'In-App Notifications', 'SMS Alerts']}>
|
||||
{(label) => (
|
||||
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', padding: '10px 0', 'border-bottom': '1px solid #F3F4F6' }}>
|
||||
<span style={{ 'font-size': '13px', color: '#374151', 'font-weight': '500' }}>{label}</span>
|
||||
<input type="checkbox" checked style={{ width: '16px', height: '16px', cursor: 'pointer' }} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
{/* ── Save button (not on settings) ─────────────────────────────── */}
|
||||
<Show when={tab() !== 'settings'}>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px', 'margin-top': '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving() || isLocked()}
|
||||
style={{ ...BTN_PRIMARY, opacity: saving() || isLocked() ? '0.6' : '1' }}
|
||||
>
|
||||
{saving() ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<Show when={saveMsg()}>
|
||||
<span style={{
|
||||
'font-size': '13px',
|
||||
'font-weight': '600',
|
||||
color: saveMsg().includes('success') ? '#10B981' : '#EF4444',
|
||||
}}>
|
||||
{saveMsg()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
src/components/dashboard/VerificationStatusPage.tsx
Normal file
363
src/components/dashboard/VerificationStatusPage.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* VerificationStatusPage — shows the user their current verification state.
|
||||
* Handles: NOT_SUBMITTED, PENDING, UNDER_REVIEW, DOCUMENTS_REQUESTED,
|
||||
* REVISION_REQUESTED, APPROVED, REJECTED.
|
||||
*/
|
||||
import { Show, createSignal, onMount } from 'solid-js';
|
||||
import { CARD, BTN_ORANGE, BTN_GHOST } from '~/components/DashboardShell';
|
||||
|
||||
const API = '/api/gateway';
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
return fetch(`${API}${path}`, {
|
||||
...opts,
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts?.headers ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Status config ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_CONFIG: Record<string, {
|
||||
emoji: string;
|
||||
label: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
description: string;
|
||||
}> = {
|
||||
NOT_SUBMITTED: {
|
||||
emoji: '📋',
|
||||
label: 'Not Submitted',
|
||||
color: '#6B7280',
|
||||
bg: '#F9FAFB',
|
||||
border: '#E5E7EB',
|
||||
description: 'Complete your My Profile and My Portfolio, then submit for verification to start using the platform.',
|
||||
},
|
||||
PENDING: {
|
||||
emoji: '⏳',
|
||||
label: 'Pending Review',
|
||||
color: '#92400E',
|
||||
bg: '#FFFBEB',
|
||||
border: '#FDE68A',
|
||||
description: 'Your profile has been submitted and is in the review queue. We typically respond within 24–48 hours.',
|
||||
},
|
||||
UNDER_REVIEW: {
|
||||
emoji: '🔍',
|
||||
label: 'Under Review',
|
||||
color: '#1E40AF',
|
||||
bg: '#EEF2FF',
|
||||
border: '#BFDBFE',
|
||||
description: 'Our team is actively reviewing your submission. You will be notified once a decision is made.',
|
||||
},
|
||||
DOCUMENTS_REQUESTED: {
|
||||
emoji: '📎',
|
||||
label: 'Documents Requested',
|
||||
color: '#C2410C',
|
||||
bg: '#FFF7ED',
|
||||
border: '#FED7AA',
|
||||
description: 'Admin has requested additional or clearer documents. Please review the message below and resubmit.',
|
||||
},
|
||||
REVISION_REQUESTED: {
|
||||
emoji: '✏️',
|
||||
label: 'Revision Requested',
|
||||
color: '#C2410C',
|
||||
bg: '#FFF7ED',
|
||||
border: '#FED7AA',
|
||||
description: 'Admin has requested changes to your profile information. Please update and resubmit.',
|
||||
},
|
||||
APPROVED: {
|
||||
emoji: '✅',
|
||||
label: 'Approved',
|
||||
color: '#065F46',
|
||||
bg: '#ECFDF5',
|
||||
border: '#6EE7B7',
|
||||
description: 'Your profile has been verified and approved. You now have full access to the platform.',
|
||||
},
|
||||
REJECTED: {
|
||||
emoji: '❌',
|
||||
label: 'Rejected',
|
||||
color: '#991B1B',
|
||||
bg: '#FEF2F2',
|
||||
border: '#FECACA',
|
||||
description: 'Your verification was rejected. Please review the reason below, update your profile, and resubmit.',
|
||||
},
|
||||
};
|
||||
|
||||
const FLOW_STEPS = [
|
||||
{ key: 'submit', label: 'Submit Profile' },
|
||||
{ key: 'review', label: 'Under Review' },
|
||||
{ key: 'verify', label: 'Verified' },
|
||||
{ key: 'approved', label: 'Approved' },
|
||||
];
|
||||
|
||||
function stepIndex(status: string): number {
|
||||
switch (status) {
|
||||
case 'NOT_SUBMITTED': return 0;
|
||||
case 'PENDING': return 1;
|
||||
case 'UNDER_REVIEW': return 2;
|
||||
case 'DOCUMENTS_REQUESTED':
|
||||
case 'REVISION_REQUESTED': return 2; // still at review stage
|
||||
case 'APPROVED': return 4;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
roleKey: string;
|
||||
onNavigate?: (sidebar: string) => void;
|
||||
}
|
||||
|
||||
export default function VerificationStatusPage(props: Props) {
|
||||
const [status, setStatus] = createSignal('NOT_SUBMITTED');
|
||||
const [docRequest, setDocRequest] = createSignal<string | null>(null);
|
||||
const [rejectionReason, setRejectionReason] = createSignal<string | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = createSignal<string | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [resubmitting, setResubmitting] = createSignal(false);
|
||||
const [resubmitMsg, setResubmitMsg] = createSignal('');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/me/verification-status?roleKey=${props.roleKey}`);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
setStatus(d.status ?? 'NOT_SUBMITTED');
|
||||
setDocRequest(d.document_request ?? null);
|
||||
setRejectionReason(d.rejection_reason ?? null);
|
||||
setUpdatedAt(d.updated_at ?? null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const cfg = () => STATUS_CONFIG[status()] ?? STATUS_CONFIG.NOT_SUBMITTED;
|
||||
const currentStep = () => stepIndex(status());
|
||||
const canResubmit = () => ['DOCUMENTS_REQUESTED', 'REVISION_REQUESTED', 'REJECTED'].includes(status());
|
||||
|
||||
const handleResubmit = async () => {
|
||||
setResubmitting(true);
|
||||
setResubmitMsg('');
|
||||
try {
|
||||
const res = await apiFetch('/api/profile/submit-for-verification', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ roleKey: props.roleKey }),
|
||||
});
|
||||
const d = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setStatus('PENDING');
|
||||
setDocRequest(null);
|
||||
setRejectionReason(null);
|
||||
setResubmitMsg('Resubmitted successfully! We will review your profile.');
|
||||
} else if (res.status === 409) {
|
||||
setResubmitMsg(d.error ?? 'A verification is already in progress.');
|
||||
} else {
|
||||
setResubmitMsg(d.error ?? 'Resubmission failed. Please try again.');
|
||||
}
|
||||
} catch {
|
||||
setResubmitMsg('Network error. Please try again.');
|
||||
} finally {
|
||||
setResubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ 'max-width': '640px' }}>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF' }}>
|
||||
Loading verification status…
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading()}>
|
||||
|
||||
{/* ── Main status card ─────────────────────────────────────────── */}
|
||||
<div style={{
|
||||
...CARD,
|
||||
background: cfg().bg,
|
||||
border: `1px solid ${cfg().border}`,
|
||||
'margin-bottom': '16px',
|
||||
display: 'flex',
|
||||
'flex-direction': 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}>
|
||||
<span style={{ 'font-size': '36px', 'line-height': '1' }}>{cfg().emoji}</span>
|
||||
<div>
|
||||
<p style={{ margin: '0', 'font-size': '11px', 'text-transform': 'uppercase', 'letter-spacing': '0.08em', color: cfg().color, 'font-weight': '700' }}>
|
||||
Verification Status
|
||||
</p>
|
||||
<p style={{ margin: '2px 0 0', 'font-size': '22px', 'font-weight': '800', color: cfg().color }}>
|
||||
{cfg().label}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: '0', 'font-size': '13px', color: '#374151', 'line-height': '1.6' }}>
|
||||
{cfg().description}
|
||||
</p>
|
||||
|
||||
<Show when={updatedAt()}>
|
||||
<p style={{ margin: '0', 'font-size': '11px', color: '#9CA3AF' }}>
|
||||
Last updated: {new Date(updatedAt()!).toLocaleString('en-IN')}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* ── Doc request / rejection reason ──────────────────────────── */}
|
||||
<Show when={docRequest()}>
|
||||
<div style={{
|
||||
...CARD,
|
||||
background: '#FFF7ED',
|
||||
border: '1px solid #FED7AA',
|
||||
'margin-bottom': '16px',
|
||||
}}>
|
||||
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '700', 'text-transform': 'uppercase', 'letter-spacing': '0.06em', color: '#C2410C' }}>
|
||||
Document Request from Admin
|
||||
</p>
|
||||
<p style={{ margin: '0', 'font-size': '14px', color: '#374151', 'line-height': '1.6' }}>
|
||||
{docRequest()}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={rejectionReason()}>
|
||||
<div style={{
|
||||
...CARD,
|
||||
background: '#FEF2F2',
|
||||
border: '1px solid #FECACA',
|
||||
'margin-bottom': '16px',
|
||||
}}>
|
||||
<p style={{ margin: '0 0 6px', 'font-size': '12px', 'font-weight': '700', 'text-transform': 'uppercase', 'letter-spacing': '0.06em', color: '#B91C1C' }}>
|
||||
Rejection Reason
|
||||
</p>
|
||||
<p style={{ margin: '0', 'font-size': '14px', color: '#374151', 'line-height': '1.6' }}>
|
||||
{rejectionReason()}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Progress timeline ──────────────────────────────────────── */}
|
||||
<Show when={status() !== 'APPROVED'}>
|
||||
<div style={{ ...CARD, 'margin-bottom': '16px' }}>
|
||||
<p style={{ margin: '0 0 14px', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>
|
||||
Verification Progress
|
||||
</p>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '0' }}>
|
||||
{FLOW_STEPS.map((step, idx) => {
|
||||
const done = currentStep() > idx;
|
||||
const active = currentStep() === idx + 1;
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', 'flex-direction': 'column', 'align-items': 'center', 'flex-shrink': '0' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
'border-radius': '999px',
|
||||
display: 'flex',
|
||||
'align-items': 'center',
|
||||
'justify-content': 'center',
|
||||
'font-size': '11px',
|
||||
'font-weight': '800',
|
||||
background: done ? '#FF5E13' : active ? '#FFF3EE' : '#F3F4F6',
|
||||
color: done ? '#fff' : active ? '#FF5E13' : '#9CA3AF',
|
||||
border: active ? '2px solid #FF5E13' : '2px solid transparent',
|
||||
}}>
|
||||
{done ? '✓' : idx + 1}
|
||||
</div>
|
||||
<p style={{
|
||||
margin: '4px 0 0',
|
||||
'font-size': '10px',
|
||||
'font-weight': '600',
|
||||
color: done || active ? '#374151' : '#9CA3AF',
|
||||
'white-space': 'nowrap',
|
||||
'text-align': 'center',
|
||||
}}>
|
||||
{step.label}
|
||||
</p>
|
||||
</div>
|
||||
{idx < FLOW_STEPS.length - 1 && (
|
||||
<div style={{
|
||||
flex: '1',
|
||||
height: '2px',
|
||||
background: done ? '#FF5E13' : '#E5E7EB',
|
||||
'margin-bottom': '18px',
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Actions ───────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: '10px', 'flex-wrap': 'wrap' }}>
|
||||
<Show when={status() === 'NOT_SUBMITTED'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onNavigate?.('My Profile')}
|
||||
style={BTN_ORANGE}
|
||||
>
|
||||
Fill My Profile
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onNavigate?.('My Portfolio')}
|
||||
style={BTN_GHOST}
|
||||
>
|
||||
Fill My Portfolio
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={canResubmit()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onNavigate?.('My Profile')}
|
||||
style={BTN_GHOST}
|
||||
>
|
||||
Update My Profile
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResubmit}
|
||||
disabled={resubmitting()}
|
||||
style={{ ...BTN_ORANGE, opacity: resubmitting() ? '0.7' : '1' }}
|
||||
>
|
||||
{resubmitting() ? 'Resubmitting…' : 'Resubmit for Verification'}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={resubmitMsg()}>
|
||||
<p style={{
|
||||
margin: '12px 0 0',
|
||||
'font-size': '13px',
|
||||
'font-weight': '600',
|
||||
color: resubmitMsg().includes('successfully') ? '#10B981' : '#EF4444',
|
||||
}}>
|
||||
{resubmitMsg()}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
{/* ── Approved state: success message ───────────────────────── */}
|
||||
<Show when={status() === 'APPROVED'}>
|
||||
<div style={{ ...CARD, background: '#ECFDF5', border: '1px solid #6EE7B7', 'text-align': 'center', padding: '32px' }}>
|
||||
<p style={{ margin: '0', 'font-size': '48px' }}>🎉</p>
|
||||
<p style={{ margin: '12px 0 4px', 'font-size': '20px', 'font-weight': '800', color: '#065F46' }}>
|
||||
You're Verified!
|
||||
</p>
|
||||
<p style={{ margin: '0', 'font-size': '14px', color: '#047857', 'line-height': '1.6' }}>
|
||||
Your profile is approved. Start exploring opportunities on Nxtgauge.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import { Show, createEffect, createMemo, createResource, createSignal, onMount } from 'solid-js';
|
||||
import { Match, Show, Switch, createEffect, createMemo, createResource, createSignal, onMount } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { useAuth, RequireAuth } from '~/lib/auth';
|
||||
import DashboardDesignPreview from '~/components/admin/DashboardDesignPreview';
|
||||
import DashboardShell from '~/components/DashboardShell';
|
||||
import ProfilePage from '~/components/dashboard/ProfilePage';
|
||||
import PortfolioPage from '~/components/dashboard/PortfolioPage';
|
||||
import VerificationStatusPage from '~/components/dashboard/VerificationStatusPage';
|
||||
|
||||
// Sidebar items that have real implementations (not the preview mock)
|
||||
const REAL_PAGES = ['my profile', 'my portfolio', 'verification'];
|
||||
|
||||
type RoleKey =
|
||||
| 'COMPANY'
|
||||
|
|
@ -279,16 +286,48 @@ export default function RuntimeDashboardPage() {
|
|||
return { userName: userName(), userId: userId(), rolePrefix: prefix };
|
||||
});
|
||||
|
||||
const isRealPage = createMemo(() =>
|
||||
REAL_PAGES.includes(activeSidebar().toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<RequireAuth>
|
||||
<main style={{ 'min-height': '100vh', background: '#f3f4f6' }}>
|
||||
<div>
|
||||
<main style={{ 'min-height': '100vh', background: '#F3F4F6' }}>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div style={cardStyle}>Loading dashboard...</div>
|
||||
<Show when={loading()}>
|
||||
<div style={cardStyle}>Loading dashboard…</div>
|
||||
</Show>
|
||||
|
||||
<Show when={ready()}>
|
||||
|
||||
{/* ── Real pages: DashboardShell + actual components ── */}
|
||||
<Show when={isRealPage()}>
|
||||
<DashboardShell
|
||||
sidebarItems={sidebarItems()}
|
||||
activeSidebar={activeSidebar()}
|
||||
onSidebarSelect={setActiveSidebar}
|
||||
roleKey={role()}
|
||||
userName={userName()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={activeSidebar().toLowerCase() === 'my profile'}>
|
||||
<ProfilePage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'my portfolio'}>
|
||||
<PortfolioPage roleKey={role()} />
|
||||
</Match>
|
||||
<Match when={activeSidebar().toLowerCase() === 'verification'}>
|
||||
<VerificationStatusPage
|
||||
roleKey={role()}
|
||||
onNavigate={setActiveSidebar}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</DashboardShell>
|
||||
</Show>
|
||||
|
||||
<Show when={ready()}>
|
||||
{/* ── All other views: DashboardDesignPreview mock ── */}
|
||||
<Show when={!isRealPage()}>
|
||||
<DashboardDesignPreview
|
||||
status={bundle()?.status ?? 'ACTIVE'}
|
||||
sidebarItems={sidebarItems()}
|
||||
|
|
@ -306,7 +345,8 @@ export default function RuntimeDashboardPage() {
|
|||
liveData={liveData()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</Show>
|
||||
</main>
|
||||
</RequireAuth>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue