diff --git a/public/favicon.ico b/public/favicon.ico index fb282da..b9433c6 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/traceworks-logo-color.png b/public/traceworks-logo-color.png new file mode 100755 index 0000000..4721d13 Binary files /dev/null and b/public/traceworks-logo-color.png differ diff --git a/public/traceworks-logo-white.svg b/public/traceworks-logo-white.svg new file mode 100755 index 0000000..0e8fc7b --- /dev/null +++ b/public/traceworks-logo-white.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/dashboard/RoleWizard.tsx b/src/components/dashboard/RoleWizard.tsx index eadfce3..614c549 100644 --- a/src/components/dashboard/RoleWizard.tsx +++ b/src/components/dashboard/RoleWizard.tsx @@ -10,20 +10,104 @@ * ProfilePage.tsx) since its documents step submits via a dedicated bulk * multipart endpoint (submitCompanyProfileWithDocuments) rather than the * per-file immediate-upload flow every other role's document endpoints use. + * + * Pre-fill (shared identity docs): + * Pass `prefilledDocs` — a map of fieldId → already-uploaded URL — to + * pre-populate identity fields (Aadhaar, PAN, selfie, etc.) that the user + * already submitted for another role. Fields are pre-filled when they are + * marked `identity_shared: true` in the schema OR their ID matches + * CONVENTION_IDENTITY_FIELD_IDS. The user can always click "Change" to + * re-upload. */ import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js"; import { CARD, BTN_GHOST, INPUT, LABEL, BTN_PRIMARY } from "~/components/DashboardShell"; import { request, uploadDocument } from "~/lib/api"; import { updateJobSeekerCustomData } from "~/lib/job-seeker-custom-data"; import { fetchOnboardingSchemaForRole } from "~/lib/runtime/storage"; +import { isIdentityField } from "~/lib/role-utils"; import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeOnboardingStep } from "~/lib/runtime/types"; interface Props { roleKey: string; - rolePrefix: string; // e.g. "jobseeker", "photographers" — matches the role's document-upload API path + rolePrefix: string; // e.g. "job-seekers", "photographers" — matches the role's document-upload API path + /** Pre-filled doc URLs from the user's existing role profile. fieldId → url */ + prefilledDocs?: Record; onSubmitted: (status: string) => void; } +/** Props for the reusable file-upload control sub-component. */ +interface FileControlProps { + field: RuntimeOnboardingField; + inputId: string; + isReused: boolean; + hasUrl: boolean; + uploading: boolean; + error: string; + onFileChange: (file: File) => void; +} + +/** + * A self-contained file-upload control rendered as a proper SolidJS component + * so that reactivity from parent signals flows correctly through its props. + */ +function FileControl(props: FileControlProps) { + return ( + <> + { + const file = e.currentTarget.files?.[0]; + if (file) props.onFileChange(file); + e.currentTarget.value = ""; + }} + /> + + + No file chosen + +

{props.error}

+
+ + } + > +
+ + ✓ Uploaded + + } + > + + ♻ Reused from your existing profile + + + +
+
+ + ); +} + function fieldRequired(field: RuntimeOnboardingField): boolean { return Boolean(field.required); } @@ -34,6 +118,8 @@ export default function RoleWizard(props: Props) { const [wizardStep, setWizardStep] = createSignal(0); const [form, setForm] = createSignal>({}); const [docUrls, setDocUrls] = createSignal>({}); + /** Tracks which doc fields were pre-filled from another role (shown with reuse badge). */ + const [reusedDocs, setReusedDocs] = createSignal>(new Set()); const [docErrors, setDocErrors] = createSignal>({}); const [docUploading, setDocUploading] = createSignal>({}); const [portfolioForm, setPortfolioForm] = createSignal>({}); @@ -44,6 +130,30 @@ export default function RoleWizard(props: Props) { onMount(async () => { const cfg = await fetchOnboardingSchemaForRole(props.roleKey); setConfig(cfg); + + // Pre-fill shared identity docs passed in from SwitchServicesPage + if (props.prefilledDocs && cfg) { + const preUrls: Record = {}; + const reused = new Set(); + + for (const step of cfg.steps) { + for (const field of step.fields) { + if (field.type !== "file") continue; + if (!isIdentityField(field.id, Boolean(field.identity_shared))) continue; + const existingUrl = props.prefilledDocs[field.id]; + if (existingUrl) { + preUrls[field.id] = existingUrl; + reused.add(field.id); + } + } + } + + if (Object.keys(preUrls).length > 0) { + setDocUrls(preUrls); + setReusedDocs(reused); + } + } + setLoading(false); }); @@ -74,26 +184,29 @@ export default function RoleWizard(props: Props) { const handleFileSelect = async (field: RuntimeOnboardingField, file: File) => { setDocUploading((prev) => ({ ...prev, [field.id]: true })); setDocErrors((prev) => ({ ...prev, [field.id]: "" })); + // Once the user uploads a new file, it's no longer "reused" + setReusedDocs((prev) => { const next = new Set(prev); next.delete(field.id); return next; }); try { const result = await uploadDocument(props.rolePrefix, file, field.id); const url = result?.url ?? result?.file_url ?? result?.path ?? result?.file_name ?? "uploaded"; setDocUrls((prev) => ({ ...prev, [field.id]: url })); - } catch (err: any) { - setDocErrors((prev) => ({ ...prev, [field.id]: err?.message ?? "Upload failed" })); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Upload failed"; + setDocErrors((prev) => ({ ...prev, [field.id]: msg })); } finally { setDocUploading((prev) => ({ ...prev, [field.id]: false })); } }; - const savePortfolio = async () => { + const savePortfolio = async (portfolioSnapshot: Record) => { const model = config()?.portfolioModel; if (!model || model === "none") return; if (model === "custom_data") { - await updateJobSeekerCustomData((current) => ({ ...current, job_seeker_portfolio: portfolioForm() })); + await updateJobSeekerCustomData((current) => ({ ...current, job_seeker_portfolio: portfolioSnapshot })); } else if (model === "professional") { await request("/api/profile", { method: "PATCH", - body: { roleKey: props.roleKey, profile_data: portfolioForm() }, + body: { roleKey: props.roleKey, profile_data: portfolioSnapshot }, }).catch(() => undefined); } }; @@ -101,9 +214,11 @@ export default function RoleWizard(props: Props) { const handleSubmit = async () => { setSubmitting(true); setSubmitMsg(""); + // Snapshot all reactive values synchronously before the first await + const portfolioSnapshot = portfolioForm(); + const profile_data = { ...form(), ...docUrls() }; try { - await savePortfolio(); - const profile_data = { ...form(), ...docUrls() }; + await savePortfolio(portfolioSnapshot); await request("/api/profile", { method: "PATCH", body: { roleKey: props.roleKey, profile_data } }); const { status } = await request("/api/profile/submit-for-verification", { method: "POST", @@ -113,15 +228,14 @@ export default function RoleWizard(props: Props) { setSubmitSuccess(true); setTimeout(() => props.onSubmitted("PENDING"), 2500); } - } catch (err: any) { - setSubmitMsg(err?.message || "Submission failed. Please try again."); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Submission failed. Please try again."; + setSubmitMsg(msg); } finally { setSubmitting(false); } }; - const fieldNote = (field: RuntimeOnboardingField) => null; - return ( Loading...

}> -
- { - const file = e.currentTarget.files?.[0]; - if (file) void handleFileSelect(field, file); - e.currentTarget.value = ""; - }} - /> - - - ✓ Uploaded - - - No file chosen - - -

{docErrors()[field.id]}

-
-
+ void handleFileSelect(field, file) + } + />
- {fieldNote(field)} )} @@ -262,35 +360,18 @@ export default function RoleWizard(props: Props) {

{field.helperText}

- - { - const file = e.currentTarget.files?.[0]; - if (file) void handleFileSelect(field, file); - e.currentTarget.value = ""; - }} - /> - - No file chosen - + void handleFileSelect(field, file) } - > - - ✓ Uploaded - - - -

{docErrors()[field.id]}

-
+ /> )} @@ -332,7 +413,9 @@ export default function RoleWizard(props: Props) { {(field) => { const value = step.type === "documents" - ? (docUrls()[field.id] ? "Uploaded" : "—") + ? (docUrls()[field.id] + ? (reusedDocs().has(field.id) ? "Reused from existing profile" : "Uploaded") + : "—") : step.type === "portfolio" ? (portfolioForm()[field.id] || "—") : (form()[field.id] || "—"); diff --git a/src/components/dashboard/SwitchServicesPage.tsx b/src/components/dashboard/SwitchServicesPage.tsx index 1cbaa7e..8a6f5ee 100644 --- a/src/components/dashboard/SwitchServicesPage.tsx +++ b/src/components/dashboard/SwitchServicesPage.tsx @@ -1,6 +1,8 @@ import { For, Show, createSignal, onMount } from 'solid-js'; -import { RefreshCw } from 'lucide-solid'; +import { RefreshCw, ArrowLeft } from 'lucide-solid'; import { BTN_GHOST, BTN_PRIMARY, CARD } from '~/components/DashboardShell'; +import RoleWizard from '~/components/dashboard/RoleWizard'; +import { roleKeyToPrefix, isIdentityField } from '~/lib/role-utils'; const API = ''; const NAVY = '#0D0D2A'; @@ -30,21 +32,50 @@ async function apiFetch(path: string, opts?: RequestInit) { } const REGISTER_OPTIONS = [ - { key: 'PHOTOGRAPHER', label: 'Photographer' }, - { key: 'MAKEUP_ARTIST', label: 'Makeup Artist' }, - { key: 'TUTOR', label: 'Tutor' }, - { key: 'DEVELOPER', label: 'Developer' }, - { key: 'VIDEO_EDITOR', label: 'Video Editor' }, - { key: 'UGC_CONTENT_CREATOR', label: 'UGC Content Creator' }, - { key: 'GRAPHIC_DESIGNER', label: 'Graphic Designer' }, - { key: 'SOCIAL_MEDIA_MANAGER', label: 'Social Media Manager' }, - { key: 'FITNESS_TRAINER', label: 'Fitness Trainer' }, - { key: 'CATERING_SERVICES', label: 'Catering Services' }, - { key: 'COMPANY', label: 'Company' }, - { key: 'CUSTOMER', label: 'Customer' }, - { key: 'JOB_SEEKER', label: 'Job Seeker' }, + { key: 'PHOTOGRAPHER', label: 'Photographer' }, + { key: 'MAKEUP_ARTIST', label: 'Makeup Artist' }, + { key: 'TUTOR', label: 'Tutor' }, + { key: 'DEVELOPER', label: 'Developer' }, + { key: 'VIDEO_EDITOR', label: 'Video Editor' }, + { key: 'UGC_CONTENT_CREATOR', label: 'UGC Content Creator' }, + { key: 'GRAPHIC_DESIGNER', label: 'Graphic Designer' }, + { key: 'SOCIAL_MEDIA_MANAGER', label: 'Social Media Manager' }, + { key: 'FITNESS_TRAINER', label: 'Fitness Trainer' }, + { key: 'CATERING_SERVICES', label: 'Catering Services' }, + { key: 'COMPANY', label: 'Company' }, + { key: 'CUSTOMER', label: 'Customer' }, + { key: 'JOB_SEEKER', label: 'Job Seeker' }, ]; +/** + * Fetches the user's active role profile and extracts doc URLs for fields + * that are identity fields (shared across roles). These are passed as + * `prefilledDocs` to RoleWizard so the user doesn't have to re-upload + * Aadhaar / PAN / selfie etc. when registering a second role. + */ +async function fetchSharedIdentityDocs(activeRoleKey: string): Promise> { + if (!activeRoleKey) return {}; + try { + const res = await apiFetch(`/api/profile?roleKey=${activeRoleKey}`); + if (!res.ok) return {}; + const data = await res.json().catch(() => ({})); + const profile: Record = data?.profile_data ?? data ?? {}; + + // Keep only string values (URLs / field values) for fields whose key + // looks like an identity document — we don't know the schema here, so + // we use the convention-based check (adminFlagged = false). + const shared: Record = {}; + for (const [key, val] of Object.entries(profile)) { + if (typeof val === 'string' && val && isIdentityField(key, false)) { + shared[key] = val; + } + } + return shared; + } catch { + return {}; + } +} + export default function SwitchServicesPage() { const [roles, setRoles] = createSignal([]); const [activeRole, setActiveRole] = createSignal(''); @@ -53,6 +84,12 @@ export default function SwitchServicesPage() { const [msg, setMsg] = createSignal(''); const [err, setErr] = createSignal(''); + // ── wizard state ────────────────────────────────────────────────────────── + /** When set, the wizard is shown inline for this role key. */ + const [wizardRole, setWizardRole] = createSignal(null); + const [wizardLabel, setWizardLabel] = createSignal(''); + const [sharedDocs, setSharedDocs] = createSignal>({}); + const loadRoles = async () => { setLoading(true); setErr(''); @@ -84,7 +121,7 @@ export default function SwitchServicesPage() { onMount(loadRoles); - const registerRole = async (roleKey: string) => { + const registerRole = async (roleKey: string, label: string) => { setBusyRole(roleKey); setMsg(''); setErr(''); @@ -98,8 +135,16 @@ export default function SwitchServicesPage() { setErr(data.error || data.message || 'Failed to register role.'); return; } - setMsg(`${roleKey} registered. You can switch after refresh/login.`); + + // Fetch identity docs from the user's current active role to pre-fill + const shared = await fetchSharedIdentityDocs(activeRole()); + setSharedDocs(shared); + await loadRoles(); + + // Launch the verification wizard inline + setWizardLabel(label); + setWizardRole(roleKey); } catch { setErr('Network error while registering role.'); } finally { @@ -163,8 +208,22 @@ export default function SwitchServicesPage() { const hasRole = (roleKey: string) => roles().some((r) => String(r.role_key || '').toUpperCase() === roleKey); const isActive = (roleKey: string) => activeRole() === roleKey; + const exitWizard = () => { + setWizardRole(null); + setSharedDocs({}); + setWizardLabel(''); + }; + + const onWizardSubmitted = async (_status: string) => { + exitWizard(); + await loadRoles(); + setMsg('Your verification has been submitted. We\'ll review it shortly.'); + }; + + // ── wizard view ─────────────────────────────────────────────────────────── return (
+ {/* Header */}
- - - + } + > + +

- Switch Services + + {wizardLabel()} — Verification +

- Manage approved roles and register additional services. + + Complete your profile to submit for admin approval. +

- -
{msg()}
-
- -
{err()}
+ {/* Inline wizard — shown after successful registration */} + + -
-
-

My Roles

- + {/* Main list + registration — hidden while wizard is open */} + + +
{msg()}
+
+ +
{err()}
+
+ + {/* My Roles */} +
+
+

My Roles

+ +
+ +

Loading roles...

+
+ +

No roles found for this user.

+
+ 0}> +
+ + {(r) => { + const rk = () => String(r.role_key || '').toUpperCase(); + const isPending = () => String(r.status || '').toUpperCase() === 'PENDING'; + return ( +
+
+

{r.role_name || r.role_key}

+

+ Status: {String(r.status || 'APPROVED').replace(/_/g, ' ')} {r.approved_at ? `• Approved ${new Date(r.approved_at).toLocaleString('en-IN')}` : ''} +

+
+
+ + Under Review + + } + > + + Approved + + + + + +
+
+ ); + }} +
+
+
- -

Loading roles...

-
- -

No roles found for this user.

-
- 0}> -
- - {(r) => ( -
-
-

{r.role_name || r.role_key}

-

- Status: {String(r.status || 'APPROVED').replace(/_/g, ' ')} {r.approved_at ? `• Approved ${new Date(r.approved_at).toLocaleString('en-IN')}` : ''} -

-
-
- Approved - -
+ + {/* Register Another Service */} +
+

Register Another Service

+
+ + {(opt) => ( +
+

{opt.label}

+
)}
- -
- -
-

Register Another Service

-
- - {(opt) => ( -
-

{opt.label}

- -
- )} -
-
+
); } diff --git a/src/lib/role-utils.ts b/src/lib/role-utils.ts new file mode 100644 index 0000000..b015e97 --- /dev/null +++ b/src/lib/role-utils.ts @@ -0,0 +1,55 @@ +/** + * Shared role utilities used by ProfilePage, SwitchServicesPage, and any + * other page that needs to map a role key to an API path prefix. + */ + +/** + * Maps a role key (e.g. "PHOTOGRAPHER") to the API path prefix used by that + * role's backend service (e.g. "photographers"). + * + * Must stay in sync with the Axum router mounts in + * nxtgauge-backend-rust/apps/gateway/src/main.rs. + */ +export function roleKeyToPrefix(roleKey: string): string { + switch (roleKey) { + case "JOB_SEEKER": return "job-seekers"; + case "COMPANY": return "company"; + case "CATERING_SERVICES": return "catering-services"; + case "MAKEUP_ARTIST": return "makeup-artists"; + default: + // PHOTOGRAPHER → photographers, DEVELOPER → developers, etc. + return roleKey.toLowerCase().replace(/_/g, "-") + "s"; + } +} + +/** + * Field IDs that are considered "shared identity" documents by convention + * when the onboarding schema has not explicitly marked them with + * `identity_shared: true`. Admins can use these IDs to get automatic + * pre-fill from the user's active role — or they can mark any field with + * `identity_shared: true` to opt into pre-fill explicitly. + */ +export const CONVENTION_IDENTITY_FIELD_IDS = new Set([ + "aadhaar", + "aadhaar_card", + "aadhaar_number", + "aadhar", + "pan", + "pan_card", + "pan_number", + "passport", + "passport_number", + "selfie", + "profile_photo", + "photo_id", + "address_proof", + "government_id", +]); + +/** + * Returns true when a field should be pre-filled from the user's existing + * profile across roles (either admin-flagged or matched by convention). + */ +export function isIdentityField(fieldId: string, adminFlagged: boolean): boolean { + return adminFlagged || CONVENTION_IDENTITY_FIELD_IDS.has(fieldId); +} diff --git a/src/lib/runtime/types.ts b/src/lib/runtime/types.ts index 8b3cf83..f67ceec 100644 --- a/src/lib/runtime/types.ts +++ b/src/lib/runtime/types.ts @@ -46,6 +46,15 @@ export type RuntimeOnboardingField = { * apps/users/src/handlers/profile.rs's save_profile, not just in this UI. */ lockAfterApproval?: boolean; + /** + * When true, this field's value (or uploaded document URL) is considered a + * shared identity credential (e.g. Aadhaar, PAN, selfie) that can be + * pre-filled from the user's existing role profile when they register a + * second role. The user can always replace it. Admins set this in the + * Onboarding Schema Editor; the frontend also pre-fills fields whose IDs + * match CONVENTION_IDENTITY_FIELD_IDS in lib/role-utils.ts as a fallback. + */ + identity_shared?: boolean; multiple?: boolean; options?: RuntimeOption[]; accept?: string;