Add schema-driven verification wizard, replacing per-role hardcoding
All checks were successful
build-and-release / build (push) Successful in 1m42s
All checks were successful
build-and-release / build (push) Successful in 1m42s
Generalizes the COMPANY-only 3-step wizard into RoleWizard, a component driven entirely by the role's active onboarding_configs schema (fetched via fetchOnboardingSchemaForRole) rather than hardcoded field/step lists. Which roles get the wizard, and when, is now a runtime toggle (enableWizardFlow) set through the new admin schema editor: - ProfilePage shows the wizard while unverified/sent-back-for-fixes, falls back to the existing free-form tabs once approved, and greys out (both client- and server-side) any field the schema marks lockAfterApproval once approved — previously isLocked() only covered the in-review window and unlocked everything again at APPROVED. - dashboard.tsx hides My Profile/My Portfolio from the sidebar pre-approval only for roles that have the wizard enabled (roles not yet rolled out keep direct access, so they aren't left with no way to complete their profile), and shows a checkmark on the Verification nav item once approved. Rolling out to more roles is now purely an admin-panel config change — no further code needed, since RoleWizard has no role-specific branches. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5fad33634a
commit
f4b9885974
6 changed files with 496 additions and 17 deletions
|
|
@ -87,6 +87,8 @@ interface Props {
|
|||
roleKey: string;
|
||||
userName?: string;
|
||||
isAdmin?: boolean;
|
||||
/** Shows a checkmark on the Verification nav item once the profile is approved. */
|
||||
verificationApproved?: boolean;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
|
|
@ -196,6 +198,9 @@ export default function DashboardShell(props: Props) {
|
|||
<SidebarIcon label={item} />
|
||||
</span>
|
||||
{titleCase(item)}
|
||||
<Show when={props.verificationApproved && item.toLowerCase() === "verification"}>
|
||||
<span style={{ "margin-left": "auto", color: "#10B981", "font-weight": "700" }}>✓</span>
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import {
|
|||
type BasicField,
|
||||
type DocField,
|
||||
} from "~/lib/profile-fields-config";
|
||||
import { fetchOnboardingSchemaForRole } from "~/lib/runtime/storage";
|
||||
import RoleWizard from "~/components/dashboard/RoleWizard";
|
||||
|
||||
// The K8s ingress routes /api/* directly to the Rust gateway service in
|
||||
// production — there is no /api/gateway rewrite layer, so paths below are
|
||||
|
|
@ -566,8 +568,32 @@ export default function ProfilePage(props: Props) {
|
|||
const [wizardSubmitMsg, setWizardSubmitMsg] = createSignal("");
|
||||
const [wizardSubmitSuccess, setWizardSubmitSuccess] = createSignal(false);
|
||||
|
||||
// ── Runtime onboarding config: wizard-vs-tabs + per-field post-approval lock ──
|
||||
// Admin-configurable via the Onboarding Schema Editor (onboarding_configs
|
||||
// table) — nothing here is hardcoded per role.
|
||||
const [wizardEnabled, setWizardEnabled] = createSignal(false);
|
||||
const [lockedFieldIds, setLockedFieldIds] = createSignal<Set<string>>(new Set());
|
||||
|
||||
const isCompany = () => props.roleKey === "COMPANY";
|
||||
|
||||
const rolePrefix = () =>
|
||||
props.roleKey === "JOB_SEEKER"
|
||||
? "jobseeker"
|
||||
: props.roleKey === "COMPANY"
|
||||
? "companies"
|
||||
: props.roleKey.toLowerCase().replace(/_/g, "-") + "s";
|
||||
|
||||
// Statuses during which the guided wizard (rather than free-form tabs)
|
||||
// should be shown: first-time setup, or the admin sent it back for fixes.
|
||||
const WIZARD_STATUSES = ["NOT_SUBMITTED", "DOCUMENTS_REQUESTED", "REVISION_REQUESTED", "REJECTED"];
|
||||
const showWizard = () => wizardEnabled() && WIZARD_STATUSES.includes(verificationStatus());
|
||||
|
||||
// A field is permanently locked once the profile has ever been approved,
|
||||
// if the active onboarding schema marked it lockAfterApproval — enforced
|
||||
// server-side too (apps/users/src/handlers/profile.rs), this just keeps
|
||||
// the UI honest about it.
|
||||
const fieldLocked = (key: string) => isLocked() || (verificationStatus() === "APPROVED" && lockedFieldIds().has(key));
|
||||
|
||||
const requiresPortfolio = () => props.roleKey === "JOB_SEEKER" || Boolean(PORTFOLIO_PREFIX[props.roleKey]);
|
||||
|
||||
const refreshPortfolioSubmission = async (): Promise<string[]> => {
|
||||
|
|
@ -651,6 +677,18 @@ export default function ProfilePage(props: Props) {
|
|||
|
||||
// Load saved profile + verification status on mount
|
||||
onMount(async () => {
|
||||
void fetchOnboardingSchemaForRole(props.roleKey).then((cfg) => {
|
||||
if (!cfg) return;
|
||||
setWizardEnabled(Boolean(cfg.enableWizardFlow));
|
||||
const locked = new Set<string>();
|
||||
for (const step of cfg.steps) {
|
||||
for (const field of step.fields) {
|
||||
if (field.lockAfterApproval) locked.add(field.id);
|
||||
}
|
||||
}
|
||||
setLockedFieldIds(locked);
|
||||
});
|
||||
|
||||
const [profileRes, statusRes] = await Promise.all([
|
||||
apiFetch(`/api/profile?roleKey=${props.roleKey}`),
|
||||
apiFetch(`/api/me/verification-status?roleKey=${props.roleKey}`),
|
||||
|
|
@ -956,7 +994,7 @@ export default function ProfilePage(props: Props) {
|
|||
|
||||
return (
|
||||
<div style={{ "max-width": "760px" }}>
|
||||
<Show when={!isCompany()} fallback={<CompanyWizard
|
||||
<Show when={!(isCompany() && showWizard())} fallback={<CompanyWizard
|
||||
wizardStep={wizardStep}
|
||||
setWizardStep={setWizardStep}
|
||||
wizardDocs={wizardDocs}
|
||||
|
|
@ -981,6 +1019,19 @@ export default function ProfilePage(props: Props) {
|
|||
setWizardSubmitSuccess(false);
|
||||
}}
|
||||
/>}>
|
||||
<Show
|
||||
when={!showWizard()}
|
||||
fallback={
|
||||
<RoleWizard
|
||||
roleKey={props.roleKey}
|
||||
rolePrefix={rolePrefix()}
|
||||
onSubmitted={(status) => {
|
||||
setVerificationStatus(status);
|
||||
props.onVerificationStatusChange?.(status);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Show when={submitMsg()}>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -1095,7 +1146,7 @@ export default function ProfilePage(props: Props) {
|
|||
<Match when={field.type === "textarea"}>
|
||||
<textarea
|
||||
rows={3}
|
||||
disabled={isLocked()}
|
||||
disabled={fieldLocked(field.key)}
|
||||
value={form()[field.key] ?? ""}
|
||||
onInput={(e) => setField(field.key, e.currentTarget.value)}
|
||||
style={{
|
||||
|
|
@ -1103,16 +1154,16 @@ export default function ProfilePage(props: Props) {
|
|||
height: "auto",
|
||||
padding: "10px 12px",
|
||||
resize: "vertical",
|
||||
opacity: isLocked() ? "0.6" : "1",
|
||||
opacity: fieldLocked(field.key) ? "0.6" : "1",
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={field.type === "select"}>
|
||||
<select
|
||||
disabled={isLocked()}
|
||||
disabled={fieldLocked(field.key)}
|
||||
value={form()[field.key] ?? ""}
|
||||
onChange={(e) => setField(field.key, e.currentTarget.value)}
|
||||
style={{ ...INPUT, opacity: isLocked() ? "0.6" : "1" }}
|
||||
style={{ ...INPUT, opacity: fieldLocked(field.key) ? "0.6" : "1" }}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
<For each={field.options ?? []}>
|
||||
|
|
@ -1123,14 +1174,19 @@ export default function ProfilePage(props: Props) {
|
|||
<Match when={true}>
|
||||
<input
|
||||
type={field.type ?? "text"}
|
||||
disabled={isLocked()}
|
||||
disabled={fieldLocked(field.key)}
|
||||
value={form()[field.key] ?? ""}
|
||||
onInput={(e) => setField(field.key, e.currentTarget.value)}
|
||||
style={{ ...INPUT, opacity: isLocked() ? "0.6" : "1" }}
|
||||
style={{ ...INPUT, opacity: fieldLocked(field.key) ? "0.6" : "1" }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
{fieldNote(field.key, field.label, !!field.required, field.type)}
|
||||
<Show when={verificationStatus() === "APPROVED" && lockedFieldIds().has(field.key)}>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "11px", color: "#9CA3AF" }}>
|
||||
Locked after verification.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -1189,7 +1245,7 @@ export default function ProfilePage(props: Props) {
|
|||
type="file"
|
||||
id={`file-${doc.key}`}
|
||||
style={{ display: "none" }}
|
||||
disabled={isLocked()}
|
||||
disabled={fieldLocked(doc.key)}
|
||||
onChange={async (e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
|
|
@ -1218,8 +1274,8 @@ export default function ProfilePage(props: Props) {
|
|||
display: "inline-flex",
|
||||
"align-items": "center",
|
||||
"line-height": "1",
|
||||
opacity: isLocked() ? "0.5" : "1",
|
||||
cursor: isLocked() ? "not-allowed" : "pointer",
|
||||
opacity: fieldLocked(doc.key) ? "0.5" : "1",
|
||||
cursor: fieldLocked(doc.key) ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
Choose File
|
||||
|
|
@ -1243,7 +1299,7 @@ export default function ProfilePage(props: Props) {
|
|||
>
|
||||
✓ Uploaded
|
||||
</span>
|
||||
<Show when={!isLocked()}>
|
||||
<Show when={!fieldLocked(doc.key)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -1348,6 +1404,7 @@ export default function ProfilePage(props: Props) {
|
|||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
351
src/components/dashboard/RoleWizard.tsx
Normal file
351
src/components/dashboard/RoleWizard.tsx
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* RoleWizard — schema-driven verification wizard, generalized from the
|
||||
* COMPANY-only 3-step wizard that used to live inline in ProfilePage.tsx.
|
||||
* Steps/fields/lock-after-approval flags come from the role's active
|
||||
* onboarding_configs schema (admin-editable via the Onboarding Schema Editor),
|
||||
* fetched through fetchOnboardingSchemaForRole() — nothing here is
|
||||
* per-role-hardcoded.
|
||||
*
|
||||
* COMPANY keeps using the separate CompanyWizard component (still in
|
||||
* 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.
|
||||
*/
|
||||
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 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
|
||||
onSubmitted: (status: string) => void;
|
||||
}
|
||||
|
||||
function fieldRequired(field: RuntimeOnboardingField): boolean {
|
||||
return Boolean(field.required);
|
||||
}
|
||||
|
||||
export default function RoleWizard(props: Props) {
|
||||
const [config, setConfig] = createSignal<RuntimeOnboardingConfig | null>(null);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [wizardStep, setWizardStep] = createSignal(0);
|
||||
const [form, setForm] = createSignal<Record<string, string>>({});
|
||||
const [docUrls, setDocUrls] = createSignal<Record<string, string>>({});
|
||||
const [docErrors, setDocErrors] = createSignal<Record<string, string>>({});
|
||||
const [docUploading, setDocUploading] = createSignal<Record<string, boolean>>({});
|
||||
const [portfolioForm, setPortfolioForm] = createSignal<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = createSignal(false);
|
||||
const [submitMsg, setSubmitMsg] = createSignal("");
|
||||
const [submitSuccess, setSubmitSuccess] = createSignal(false);
|
||||
|
||||
onMount(async () => {
|
||||
const cfg = await fetchOnboardingSchemaForRole(props.roleKey);
|
||||
setConfig(cfg);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const steps = createMemo<RuntimeOnboardingStep[]>(() => config()?.steps ?? []);
|
||||
const currentStep = createMemo(() => steps()[wizardStep()]);
|
||||
|
||||
const setField = (key: string, val: string) => setForm((prev) => ({ ...prev, [key]: val }));
|
||||
const setPortfolioField = (key: string, val: string) => setPortfolioForm((prev) => ({ ...prev, [key]: val }));
|
||||
|
||||
const stepIsComplete = (step: RuntimeOnboardingStep | undefined): boolean => {
|
||||
if (!step) return true;
|
||||
if (step.type === "documents") {
|
||||
return step.fields.filter(fieldRequired).every((f) => Boolean(docUrls()[f.id]));
|
||||
}
|
||||
if (step.type === "portfolio") {
|
||||
return step.fields.filter(fieldRequired).every((f) => String(portfolioForm()[f.id] ?? "").trim().length > 0);
|
||||
}
|
||||
if (step.type === "review") return true;
|
||||
// basic
|
||||
return step.fields.filter(fieldRequired).every((f) => String(form()[f.id] ?? "").trim().length > 0);
|
||||
};
|
||||
|
||||
const canAdvance = createMemo(() => stepIsComplete(currentStep()));
|
||||
|
||||
const handleFileSelect = async (field: RuntimeOnboardingField, file: File) => {
|
||||
setDocUploading((prev) => ({ ...prev, [field.id]: true }));
|
||||
setDocErrors((prev) => ({ ...prev, [field.id]: "" }));
|
||||
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" }));
|
||||
} finally {
|
||||
setDocUploading((prev) => ({ ...prev, [field.id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const savePortfolio = async () => {
|
||||
const model = config()?.portfolioModel;
|
||||
if (!model || model === "none") return;
|
||||
if (model === "custom_data") {
|
||||
await updateJobSeekerCustomData((current) => ({ ...current, job_seeker_portfolio: portfolioForm() }));
|
||||
} else if (model === "professional") {
|
||||
await request("/api/profile", {
|
||||
method: "PATCH",
|
||||
body: { roleKey: props.roleKey, profile_data: portfolioForm() },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
setSubmitMsg("");
|
||||
try {
|
||||
await savePortfolio();
|
||||
const profile_data = { ...form(), ...docUrls() };
|
||||
await request("/api/profile", { method: "PATCH", body: { roleKey: props.roleKey, profile_data } });
|
||||
const { status } = await request("/api/profile/submit-for-verification", {
|
||||
method: "POST",
|
||||
body: { roleKey: props.roleKey, profile_data },
|
||||
});
|
||||
if (status === 200 || status === 201) {
|
||||
setSubmitSuccess(true);
|
||||
props.onSubmitted("PENDING");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSubmitMsg(err?.message || "Submission failed. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fieldNote = (field: RuntimeOnboardingField) => null;
|
||||
|
||||
return (
|
||||
<Show when={!loading()} fallback={<p style={{ "font-size": "13px", color: "#6B7280" }}>Loading...</p>}>
|
||||
<Show
|
||||
when={config() && steps().length > 0}
|
||||
fallback={
|
||||
<p style={{ "font-size": "13px", color: "#6B7280" }}>
|
||||
No verification wizard is configured for this role yet.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Show when={submitSuccess()}>
|
||||
<div style={{ ...CARD, "margin-bottom": "16px", padding: "24px", background: "#ECFDF5", border: "1px solid #6EE7B7", "text-align": "center" }}>
|
||||
<div style={{ "font-size": "36px", "margin-bottom": "8px" }}>✓</div>
|
||||
<h3 style={{ margin: "0 0 8px", "font-size": "18px", color: "#065F46" }}>Submitted!</h3>
|
||||
<p style={{ margin: "0 0 16px", "font-size": "13px", color: "#065F46" }}>
|
||||
We'll review your profile and notify you once it's checked.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!submitSuccess()}>
|
||||
{/* ── Stepper ─────────────────────────────────────────────── */}
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "8px", "margin-bottom": "24px" }}>
|
||||
<For each={steps()}>
|
||||
{(step, i) => (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: "32px", height: "32px", "border-radius": "50%",
|
||||
display: "flex", "align-items": "center", "justify-content": "center",
|
||||
"font-weight": "700", "font-size": "14px",
|
||||
background: wizardStep() === i() ? "#FF5E13" : wizardStep() > i() ? "#FFF" : "#F3F4F6",
|
||||
color: wizardStep() === i() ? "#FFF" : wizardStep() > i() ? "#FF5E13" : "#9CA3AF",
|
||||
border: wizardStep() >= i() ? "2px solid #FF5E13" : "2px solid #E5E7EB",
|
||||
}}
|
||||
>
|
||||
{wizardStep() > i() ? "✓" : i() + 1}
|
||||
</div>
|
||||
<Show when={i() < steps().length - 1}>
|
||||
<div style={{ flex: 1, height: "2px", background: wizardStep() > i() ? "#FF5E13" : "#E5E7EB" }} />
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p style={{ "font-size": "13px", color: "#6B7280", "margin-bottom": "16px" }}>
|
||||
Step {wizardStep() + 1} of {steps().length} — {currentStep()?.title}
|
||||
</p>
|
||||
|
||||
<div style={CARD}>
|
||||
{/* ── basic ─────────────────────────────────────────────── */}
|
||||
<Show when={currentStep()?.type === "basic" || !currentStep()?.type}>
|
||||
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "10px" }}>
|
||||
<For each={currentStep()?.fields ?? []}>
|
||||
{(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}
|
||||
value={form()[field.id] ?? ""}
|
||||
onInput={(e) => setField(field.id, e.currentTarget.value)}
|
||||
style={{ ...INPUT, height: "auto", padding: "10px 12px", resize: "vertical" }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={field.type === "select"}>
|
||||
<select value={form()[field.id] ?? ""} onChange={(e) => setField(field.id, e.currentTarget.value)} style={{ ...INPUT }}>
|
||||
<option value="">Select…</option>
|
||||
<For each={field.options ?? []}>{(opt) => <option value={opt.value}>{opt.label}</option>}</For>
|
||||
</select>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<input
|
||||
type={field.type ?? "text"}
|
||||
value={form()[field.id] ?? ""}
|
||||
onInput={(e) => setField(field.id, e.currentTarget.value)}
|
||||
style={{ ...INPUT }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
{fieldNote(field)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── documents ─────────────────────────────────────────── */}
|
||||
<Show when={currentStep()?.type === "documents"}>
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
<For each={currentStep()?.fields ?? []}>
|
||||
{(field) => (
|
||||
<div style={{ border: "1px dashed #E5E7EB", "border-radius": "10px", padding: "16px" }}>
|
||||
<p style={{ margin: "0", "font-size": "13px", "font-weight": "700", color: "#111827" }}>
|
||||
{field.label}
|
||||
<Show when={field.required}><span style={{ color: "#EF4444" }}> *</span></Show>
|
||||
</p>
|
||||
<Show when={field.helperText}>
|
||||
<p style={{ margin: "2px 0 10px", "font-size": "11px", color: "#9CA3AF" }}>{field.helperText}</p>
|
||||
</Show>
|
||||
<Show
|
||||
when={docUrls()[field.id]}
|
||||
fallback={
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
|
||||
<input
|
||||
type="file"
|
||||
id={`rw-file-${field.id}`}
|
||||
style={{ display: "none" }}
|
||||
disabled={docUploading()[field.id]}
|
||||
onChange={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (file) void handleFileSelect(field, file);
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<label for={`rw-file-${field.id}`} style={{ ...BTN_GHOST, display: "inline-flex", "align-items": "center", "line-height": "1", cursor: "pointer" }}>
|
||||
{docUploading()[field.id] ? "Uploading…" : "Choose File"}
|
||||
</label>
|
||||
<span style={{ "font-size": "12px", color: "#9CA3AF" }}>No file chosen</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span style={{ "font-size": "12px", "font-weight": "600", color: "#10B981", background: "#ECFDF5", padding: "4px 10px", "border-radius": "6px" }}>
|
||||
✓ Uploaded
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={docErrors()[field.id]}>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "11px", color: "#EF4444" }}>{docErrors()[field.id]}</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── portfolio ─────────────────────────────────────────── */}
|
||||
<Show when={currentStep()?.type === "portfolio"}>
|
||||
<div style={{ display: "grid", gap: "10px" }}>
|
||||
<For each={currentStep()?.fields ?? []}>
|
||||
{(field) => (
|
||||
<div>
|
||||
<label style={LABEL}>
|
||||
{field.label}
|
||||
<Show when={field.required}><span style={{ color: "#EF4444" }}> *</span></Show>
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={portfolioForm()[field.id] ?? ""}
|
||||
onInput={(e) => setPortfolioField(field.id, e.currentTarget.value)}
|
||||
style={{ ...INPUT, height: "auto", padding: "10px 12px", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── review ────────────────────────────────────────────── */}
|
||||
<Show when={currentStep()?.type === "review"}>
|
||||
<div style={{ padding: "16px", background: "#F9FAFB", "border-radius": "8px" }}>
|
||||
<For each={steps().filter((s) => s.type !== "review")}>
|
||||
{(step) => (
|
||||
<div style={{ "margin-bottom": "12px" }}>
|
||||
<p style={{ margin: "0 0 4px", "font-size": "12px", "font-weight": "700", color: "#111827" }}>{step.title}</p>
|
||||
<For each={step.fields}>
|
||||
{(field) => {
|
||||
const value = step.type === "documents"
|
||||
? (docUrls()[field.id] ? "Uploaded" : "—")
|
||||
: step.type === "portfolio"
|
||||
? (portfolioForm()[field.id] || "—")
|
||||
: (form()[field.id] || "—");
|
||||
return (
|
||||
<p style={{ margin: "2px 0", "font-size": "12px", color: "#374151" }}>
|
||||
<strong>{field.label}:</strong> {value}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── nav buttons ───────────────────────────────────────── */}
|
||||
<div style={{ display: "flex", "justify-content": "space-between", "margin-top": "24px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWizardStep((s) => s - 1)}
|
||||
disabled={wizardStep() === 0}
|
||||
style={{ ...BTN_GHOST, opacity: wizardStep() === 0 ? 0.5 : 1 }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<Show
|
||||
when={wizardStep() < steps().length - 1}
|
||||
fallback={
|
||||
<button type="button" onClick={handleSubmit} disabled={submitting()} style={{ ...BTN_PRIMARY, opacity: submitting() ? 0.7 : 1 }}>
|
||||
{submitting() ? "Submitting..." : "Submit for Verification"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWizardStep((s) => s + 1)}
|
||||
disabled={!canAdvance()}
|
||||
style={{ ...BTN_PRIMARY, opacity: canAdvance() ? 1 : 0.5 }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={submitMsg()}>
|
||||
<div style={{ "margin-top": "12px", padding: "10px 14px", background: "#FEF2F2", border: "1px solid #FECACA", color: "#B91C1C", "font-size": "13px", "font-weight": "600", "border-radius": "6px" }}>
|
||||
{submitMsg()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,6 +83,36 @@ export function getRuntimeOnboardingSchema(input: { schemaId?: string; roleKey?:
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a role's active verification-wizard schema from the backend
|
||||
* (GET /api/config/onboarding/by-key/{roleKey}, public, admin-authored via
|
||||
* the Onboarding Schema Editor). Falls back to the localStorage-based
|
||||
* getRuntimeOnboardingSchema() only if the request fails, so the wizard can
|
||||
* still render something during a transient network error.
|
||||
*/
|
||||
export async function fetchOnboardingSchemaForRole(roleKey: string): Promise<RuntimeOnboardingConfig | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/config/onboarding/by-key/${encodeURIComponent(roleKey.toUpperCase())}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return getRuntimeOnboardingSchema({ roleKey });
|
||||
const data = await res.json();
|
||||
const schemaJson = data?.schema_json;
|
||||
if (!schemaJson || !Array.isArray(schemaJson.steps)) return getRuntimeOnboardingSchema({ roleKey });
|
||||
return {
|
||||
schemaId: String(data?.id ?? `${roleKey}_v${data?.version ?? 1}`),
|
||||
roleKey: roleKey.toUpperCase(),
|
||||
version: typeof data?.version === 'number' ? data.version : 1,
|
||||
steps: schemaJson.steps,
|
||||
portfolioModel: schemaJson.portfolioModel,
|
||||
enableWizardFlow: Boolean(schemaJson.enableWizardFlow),
|
||||
};
|
||||
} catch {
|
||||
return getRuntimeOnboardingSchema({ roleKey });
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOnboardingSubmission(payload: Omit<OnboardingSubmission, 'id' | 'submittedAt'>): OnboardingSubmission {
|
||||
const next: OnboardingSubmission = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ export type RuntimeOnboardingField = {
|
|||
helperText?: string;
|
||||
defaultValue?: string | number | boolean | string[];
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* If true, this field (or document) becomes permanently read-only once the
|
||||
* profile's verification status reaches APPROVED — enforced server-side in
|
||||
* apps/users/src/handlers/profile.rs's save_profile, not just in this UI.
|
||||
*/
|
||||
lockAfterApproval?: boolean;
|
||||
multiple?: boolean;
|
||||
options?: RuntimeOption[];
|
||||
accept?: string;
|
||||
|
|
@ -49,19 +55,29 @@ export type RuntimeOnboardingField = {
|
|||
visibleWhen?: RuntimeVisibilityCondition[];
|
||||
};
|
||||
|
||||
export type RuntimeStepType = 'basic' | 'documents' | 'portfolio' | 'review';
|
||||
|
||||
export type RuntimeOnboardingStep = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** How the wizard should render this step; unrecognized/missing defaults to "basic". */
|
||||
type?: RuntimeStepType;
|
||||
visibleWhen?: RuntimeVisibilityCondition[];
|
||||
fields: RuntimeOnboardingField[];
|
||||
};
|
||||
|
||||
export type RuntimePortfolioModel = 'none' | 'custom_data' | 'professional';
|
||||
|
||||
export type RuntimeOnboardingConfig = {
|
||||
schemaId: string;
|
||||
roleKey: string;
|
||||
version: number;
|
||||
steps: RuntimeOnboardingStep[];
|
||||
/** Which persistence model the portfolio step (if any) should save into. */
|
||||
portfolioModel?: RuntimePortfolioModel;
|
||||
/** Admin-configurable per-role toggle: render the wizard flow, or fall back to legacy tabs. */
|
||||
enableWizardFlow?: boolean;
|
||||
};
|
||||
|
||||
export type UploadedFileMeta = {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useAuth, RequireAuth, getToken } from "~/lib/auth";
|
||||
import { fetchOnboardingSchemaForRole } from "~/lib/runtime/storage";
|
||||
import DashboardDesignPreview from "~/components/admin/DashboardDesignPreview";
|
||||
import DashboardShell from "~/components/DashboardShell";
|
||||
import ProfilePage from "~/components/dashboard/ProfilePage";
|
||||
|
|
@ -560,7 +561,8 @@ function mergeSidebar(
|
|||
_role: RoleKey,
|
||||
runtimeSidebar: string[],
|
||||
userRoles: string[] = [],
|
||||
verificationStatus?: string
|
||||
verificationStatus?: string,
|
||||
wizardEnabled = false
|
||||
): string[] {
|
||||
const base = ROLE_BASED_SIDEBAR[_role] || [];
|
||||
const fromRuntime = runtimeSidebar.map((item) => String(item || "").trim()).filter(Boolean);
|
||||
|
|
@ -580,14 +582,21 @@ function mergeSidebar(
|
|||
const status = String(verificationStatus || "").toUpperCase();
|
||||
const approved = status === "APPROVED";
|
||||
if (!approved && status) {
|
||||
// Once a role's verification wizard is enabled (admin-configurable, see
|
||||
// OnboardingSchemaEditor), My Profile / My Portfolio move inside the
|
||||
// wizard itself and stay hidden from the sidebar until approved — the
|
||||
// wizard is the only way to complete them pre-approval. Roles that
|
||||
// haven't been rolled out to the wizard yet keep the old behavior
|
||||
// (profile/portfolio remain directly accessible) so they aren't left
|
||||
// with no way to fill in their profile at all.
|
||||
const restricted = new Set([
|
||||
"my profile",
|
||||
"help center",
|
||||
"settings",
|
||||
"verification",
|
||||
...(PROFESSIONAL_ROLE_SET.has(_role) || _role === "JOB_SEEKER"
|
||||
? ["my portfolio", "credits"]
|
||||
: []),
|
||||
...(wizardEnabled ? [] : ["my profile"]),
|
||||
...(!wizardEnabled && (PROFESSIONAL_ROLE_SET.has(_role) || _role === "JOB_SEEKER")
|
||||
? ["my portfolio", "credits"]
|
||||
: []),
|
||||
]);
|
||||
merged = merged.filter((item) => restricted.has(item.trim().toLowerCase()));
|
||||
}
|
||||
|
|
@ -742,6 +751,10 @@ export default function RuntimeDashboardPage() {
|
|||
});
|
||||
|
||||
const [bundle] = createResource(() => role(), loadRoleBundle);
|
||||
const [wizardEnabledForRole] = createResource(
|
||||
() => role(),
|
||||
async (r) => Boolean((await fetchOnboardingSchemaForRole(r))?.enableWizardFlow)
|
||||
);
|
||||
const activeSidebarKey = createMemo(() => normalizeSidebarKey(activeSidebar()));
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -754,7 +767,13 @@ export default function RuntimeDashboardPage() {
|
|||
);
|
||||
|
||||
const sidebarItems = createMemo(() =>
|
||||
mergeSidebar(role(), bundle()?.sidebarItems || [], bundle()?.userRoles || [], effectiveVerificationStatus())
|
||||
mergeSidebar(
|
||||
role(),
|
||||
bundle()?.sidebarItems || [],
|
||||
bundle()?.userRoles || [],
|
||||
effectiveVerificationStatus(),
|
||||
wizardEnabledForRole() ?? false
|
||||
)
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -833,6 +852,7 @@ export default function RuntimeDashboardPage() {
|
|||
roleKey={role()}
|
||||
userName={userName()}
|
||||
isAdmin={isAdmin()}
|
||||
verificationApproved={String(effectiveVerificationStatus() || '').toUpperCase() === 'APPROVED'}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={activeSidebarKey() === "my dashboard"}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue