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 = "";
+ }}
+ />
+
+
+ {props.uploading ? "Uploading…" : "Choose File"}
+
+ No file chosen
+
+ {props.error}
+
+
+ }
+ >
+
+
+ ✓ Uploaded
+
+ }
+ >
+
+ ♻ Reused from your existing profile
+
+
+
+ Change
+
+
+
+ >
+ );
+}
+
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 = "";
- }}
- />
-
- {docUploading()[field.id] ? "Uploading…" : "Choose File"}
-
-
- ✓ 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 = "";
- }}
- />
-
- {docUploading()[field.id] ? "Uploading…" : "Choose File"}
-
- 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
-
Refresh
+ {/* Main list + registration — hidden while wizard is open */}
+
+
+ {msg()}
+
+
+ {err()}
+
+
+ {/* 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
+
+
+
+ switchRole(rk())}
+ disabled={busyRole() === rk() || isActive(rk())}
+ style={{ ...BTN_PRIMARY, height: '30px', 'font-size': '12px', padding: '0 10px', opacity: busyRole() === rk() || isActive(rk()) ? '0.7' : '1' }}
+ >
+ {isActive(rk()) ? 'Active' : busyRole() === rk() ? 'Switching...' : 'Switch'}
+
+
+
+
+ );
+ }}
+
+
+
-
- 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
- switchRole(String(r.role_key || '').toUpperCase())}
- disabled={busyRole() === String(r.role_key || '').toUpperCase() || isActive(String(r.role_key || '').toUpperCase())}
- style={{ ...BTN_PRIMARY, height: '30px', 'font-size': '12px', padding: '0 10px', opacity: busyRole() === String(r.role_key || '').toUpperCase() || isActive(String(r.role_key || '').toUpperCase()) ? '0.7' : '1' }}
- >
- {isActive(String(r.role_key || '').toUpperCase()) ? 'Active' : busyRole() === String(r.role_key || '').toUpperCase() ? 'Switching...' : 'Switch'}
-
-
+
+ {/* Register Another Service */}
+
+
Register Another Service
+
+
+ {(opt) => (
+
+
{opt.label}
+
registerRole(opt.key, opt.label)}
+ style={{ ...BTN_PRIMARY, height: '30px', 'font-size': '12px', padding: '0 10px', 'margin-top': '8px', width: '100%', opacity: hasRole(opt.key) || busyRole() === opt.key ? '0.7' : '1' }}
+ >
+ {hasRole(opt.key) ? 'Registered' : busyRole() === opt.key ? 'Registering...' : 'Register'}
+
)}
-
-
-
-
-
Register Another Service
-
-
- {(opt) => (
-
-
{opt.label}
-
registerRole(opt.key)}
- style={{ ...BTN_PRIMARY, height: '30px', 'font-size': '12px', padding: '0 10px', 'margin-top': '8px', width: '100%', opacity: hasRole(opt.key) || busyRole() === opt.key ? '0.7' : '1' }}
- >
- {hasRole(opt.key) ? 'Registered' : busyRole() === opt.key ? 'Registering...' : 'Register'}
-
-
- )}
-
-
+
);
}
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;