feat(profile): add 3-step wizard for COMPANY role with single submit button
Some checks failed
build-and-release / build (push) Failing after 1m15s
Some checks failed
build-and-release / build (push) Failing after 1m15s
This commit is contained in:
parent
ec4bb48358
commit
3d6a81d4ff
1 changed files with 506 additions and 1 deletions
|
|
@ -19,7 +19,7 @@ import {
|
|||
isValidLocation,
|
||||
isValidURL,
|
||||
} from "~/lib/form-validation";
|
||||
import { request, uploadDocument } from "~/lib/api";
|
||||
import { request, uploadDocument, submitCompanyProfileWithDocuments } from "~/lib/api";
|
||||
import {
|
||||
getBasicFields,
|
||||
getDocFields,
|
||||
|
|
@ -101,6 +101,444 @@ interface Props {
|
|||
|
||||
type Tab = "basic" | "documents";
|
||||
|
||||
// ── CompanyWizard — 3-step verification wizard for COMPANY role ──────────────
|
||||
interface CompanyWizardProps {
|
||||
wizardStep: () => number;
|
||||
setWizardStep: (s: number | ((prev: number) => number)) => void;
|
||||
wizardDocs: () => Record<string, File>;
|
||||
setWizardDocs: (
|
||||
s: Record<string, File> | ((prev: Record<string, File>) => Record<string, File>)
|
||||
) => void;
|
||||
wizardDocErrors: () => Record<string, string>;
|
||||
setWizardDocErrors: (
|
||||
s: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)
|
||||
) => void;
|
||||
wizardSubmitting: () => boolean;
|
||||
wizardSubmitMsg: () => string;
|
||||
wizardSubmitSuccess: () => boolean;
|
||||
wizardCanAdvance: () => boolean;
|
||||
handleWizardSubmit: () => void;
|
||||
form: () => Record<string, string>;
|
||||
setField: (key: string, val: string) => void;
|
||||
basicFields: () => Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
}>;
|
||||
getDocFields: (roleKey: string) => Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
}>;
|
||||
fieldNote: (
|
||||
key: string,
|
||||
label: string,
|
||||
required: boolean,
|
||||
fieldType?: string
|
||||
) => any;
|
||||
wizardReset: () => void;
|
||||
}
|
||||
|
||||
function CompanyWizard(props: CompanyWizardProps) {
|
||||
const stepLabels = ["Basic Information", "Documents", "Review & Submit"];
|
||||
|
||||
const formatBytes = (n: number) => {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Success card ─────────────────────────────────────────────── */}
|
||||
<Show when={props.wizardSubmitSuccess()}>
|
||||
<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 within 24–48 hours.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (typeof window !== "undefined") window.location.reload();
|
||||
}}
|
||||
style={{ ...BTN_PRIMARY }}
|
||||
>
|
||||
View Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.wizardSubmitSuccess()}>
|
||||
{/* ── Stepper ────────────────────────────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
"margin-bottom": "24px",
|
||||
}}
|
||||
>
|
||||
<For each={[0, 1, 2]}>
|
||||
{(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:
|
||||
props.wizardStep() === i
|
||||
? "#FF5E13"
|
||||
: props.wizardStep() > i
|
||||
? "#FFF"
|
||||
: "#F3F4F6",
|
||||
color:
|
||||
props.wizardStep() === i
|
||||
? "#FFF"
|
||||
: props.wizardStep() > i
|
||||
? "#FF5E13"
|
||||
: "#9CA3AF",
|
||||
border:
|
||||
props.wizardStep() >= i
|
||||
? "2px solid #FF5E13"
|
||||
: "2px solid #E5E7EB",
|
||||
}}
|
||||
>
|
||||
{props.wizardStep() > i ? "✓" : i + 1}
|
||||
</div>
|
||||
<Show when={i < 2}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: "2px",
|
||||
background: props.wizardStep() > i ? "#FF5E13" : "#E5E7EB",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
"font-size": "13px",
|
||||
color: "#6B7280",
|
||||
"margin-bottom": "16px",
|
||||
}}
|
||||
>
|
||||
Step {props.wizardStep() + 1} of 3 — {stepLabels[props.wizardStep()]}
|
||||
</p>
|
||||
|
||||
{/* ── Step body ─────────────────────────────────────────────── */}
|
||||
<div style={CARD}>
|
||||
<Show when={props.wizardStep() === 0}>
|
||||
<h3 style={{ margin: "0 0 4px", "font-size": "15px", color: "#111827" }}>
|
||||
Tell us about your company
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 16px", "font-size": "13px", color: "#6B7280" }}>
|
||||
Fill in your company details to get started.
|
||||
</p>
|
||||
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "10px" }}>
|
||||
<For each={props.basicFields()}>
|
||||
{(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={props.form()[field.key] ?? ""}
|
||||
onInput={(e) => props.setField(field.key, e.currentTarget.value)}
|
||||
style={{
|
||||
...INPUT,
|
||||
height: "auto",
|
||||
padding: "10px 12px",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={field.type === "select"}>
|
||||
<select
|
||||
value={props.form()[field.key] ?? ""}
|
||||
onChange={(e) => props.setField(field.key, e.currentTarget.value)}
|
||||
style={{ ...INPUT }}
|
||||
>
|
||||
<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"}
|
||||
value={props.form()[field.key] ?? ""}
|
||||
onInput={(e) => props.setField(field.key, e.currentTarget.value)}
|
||||
style={{ ...INPUT }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
{props.fieldNote(field.key, field.label, !!field.required, field.type)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.wizardStep() === 1}>
|
||||
<h3 style={{ margin: "0 0 4px", "font-size": "15px", color: "#111827" }}>
|
||||
Upload your verification documents
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 16px", "font-size": "13px", color: "#6B7280" }}>
|
||||
We need to verify your business. Upload the following:
|
||||
</p>
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
|
||||
<For each={props.getDocFields("COMPANY")}>
|
||||
{(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={props.wizardDocs()[doc.key]}
|
||||
fallback={
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
|
||||
<input
|
||||
type="file"
|
||||
id={`wiz-file-${doc.key}`}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
props.setWizardDocs((prev) => ({ ...prev, [doc.key]: file }));
|
||||
props.setWizardDocErrors((prev) => ({ ...prev, [doc.key]: "" }));
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
for={`wiz-file-${doc.key}`}
|
||||
style={{
|
||||
...BTN_GHOST,
|
||||
display: "inline-flex",
|
||||
"align-items": "center",
|
||||
"line-height": "1",
|
||||
cursor: "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",
|
||||
}}
|
||||
>
|
||||
✓ {props.wizardDocs()[doc.key].name} ({formatBytes(props.wizardDocs()[doc.key].size)})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
props.setWizardDocs((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[doc.key];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
...BTN_GHOST,
|
||||
height: "28px",
|
||||
"font-size": "11px",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.wizardDocErrors()[doc.key]}>
|
||||
<p style={{ margin: "4px 0 0", "font-size": "11px", color: "#EF4444" }}>
|
||||
{props.wizardDocErrors()[doc.key]}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.wizardStep() === 2}>
|
||||
<h3 style={{ margin: "0 0 4px", "font-size": "15px", color: "#111827" }}>
|
||||
Review your information
|
||||
</h3>
|
||||
<p style={{ margin: "0 0 16px", "font-size": "13px", color: "#6B7280" }}>
|
||||
Please confirm everything looks correct before submitting.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
background: "#F9FAFB",
|
||||
"border-radius": "8px",
|
||||
"margin-bottom": "16px",
|
||||
}}
|
||||
>
|
||||
<For each={props.basicFields()}>
|
||||
{(field) => (
|
||||
<p style={{ margin: "4px 0", "font-size": "13px", color: "#374151" }}>
|
||||
<strong>{field.label}:</strong>{" "}
|
||||
{String(props.form()[field.key] || "").trim() || (
|
||||
<span style={{ color: "#9CA3AF" }}>—</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
background: "#F9FAFB",
|
||||
"border-radius": "8px",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: "0 0 6px", "font-size": "13px", "font-weight": "700", color: "#111827" }}>
|
||||
Documents:
|
||||
</p>
|
||||
<For each={props.getDocFields("COMPANY")}>
|
||||
{(doc) => {
|
||||
const f = props.wizardDocs()[doc.key];
|
||||
return (
|
||||
<p style={{ margin: "2px 0", "font-size": "12px", color: "#374151" }}>
|
||||
<strong>{doc.label}:</strong>{" "}
|
||||
{f ? `${f.name} (${formatBytes(f.size)})` : <span style={{ color: "#9CA3AF" }}>—</span>}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* ── Navigation buttons ──────────────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"justify-content": "space-between",
|
||||
"margin-top": "24px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.setWizardStep((s) => s - 1)}
|
||||
disabled={props.wizardStep() === 0}
|
||||
style={{ ...BTN_GHOST, opacity: props.wizardStep() === 0 ? 0.5 : 1 }}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<Show
|
||||
when={props.wizardStep() < 2}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.handleWizardSubmit}
|
||||
disabled={props.wizardSubmitting()}
|
||||
style={{ ...BTN_PRIMARY, opacity: props.wizardSubmitting() ? 0.7 : 1 }}
|
||||
>
|
||||
{props.wizardSubmitting() ? "Submitting..." : "Submit for Verification"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.setWizardStep((s) => s + 1)}
|
||||
disabled={!props.wizardCanAdvance()}
|
||||
style={{
|
||||
...BTN_PRIMARY,
|
||||
opacity: props.wizardCanAdvance() ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.wizardSubmitMsg() && props.wizardStep() === 2}>
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{props.wizardSubmitMsg()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilePage(props: Props) {
|
||||
const [tab, setTab] = createSignal<Tab>("basic");
|
||||
const [form, setForm] = createSignal<Record<string, string>>({});
|
||||
|
|
@ -117,6 +555,16 @@ export default function ProfilePage(props: Props) {
|
|||
const [photoUploading, setPhotoUploading] = createSignal(false);
|
||||
const [photoMsg, setPhotoMsg] = createSignal("");
|
||||
|
||||
// ── Wizard state (COMPANY role only) ────────────────────────────────────
|
||||
const [wizardStep, setWizardStep] = createSignal(0);
|
||||
const [wizardDocs, setWizardDocs] = createSignal<Record<string, File>>({});
|
||||
const [wizardDocErrors, setWizardDocErrors] = createSignal<Record<string, string>>({});
|
||||
const [wizardSubmitting, setWizardSubmitting] = createSignal(false);
|
||||
const [wizardSubmitMsg, setWizardSubmitMsg] = createSignal("");
|
||||
const [wizardSubmitSuccess, setWizardSubmitSuccess] = createSignal(false);
|
||||
|
||||
const isCompany = () => props.roleKey === "COMPANY";
|
||||
|
||||
const requiresPortfolio = () => props.roleKey === "JOB_SEEKER" || Boolean(PORTFOLIO_PREFIX[props.roleKey]);
|
||||
|
||||
const refreshPortfolioSubmission = async (): Promise<string[]> => {
|
||||
|
|
@ -461,8 +909,64 @@ export default function ProfilePage(props: Props) {
|
|||
missingPortfolioLabels().length === 0
|
||||
);
|
||||
|
||||
// ── Wizard helpers (COMPANY only) ───────────────────────────────────────
|
||||
const wizardCanAdvance = createMemo(() => {
|
||||
if (wizardStep() === 0) return missingBasicLabels().length === 0;
|
||||
if (wizardStep() === 1) {
|
||||
return requiredDocFields().every((doc) => !!wizardDocs()[doc.key]);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleWizardSubmit = async () => {
|
||||
setWizardSubmitting(true);
|
||||
setWizardSubmitMsg("");
|
||||
try {
|
||||
const docs = Object.entries(wizardDocs()).map(([k, f]) => ({
|
||||
documentType: k,
|
||||
file: f,
|
||||
}));
|
||||
await submitCompanyProfileWithDocuments(
|
||||
{ roleKey: props.roleKey, profile_data: form() },
|
||||
docs
|
||||
);
|
||||
setWizardSubmitSuccess(true);
|
||||
setVerificationStatus("PENDING");
|
||||
props.onVerificationStatusChange?.("PENDING");
|
||||
} catch (err: any) {
|
||||
setWizardSubmitMsg(err?.message ?? "Submission failed. Please try again.");
|
||||
} finally {
|
||||
setWizardSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ "max-width": "760px" }}>
|
||||
<Show when={!isCompany()} fallback={<CompanyWizard
|
||||
wizardStep={wizardStep}
|
||||
setWizardStep={setWizardStep}
|
||||
wizardDocs={wizardDocs}
|
||||
setWizardDocs={setWizardDocs}
|
||||
wizardDocErrors={wizardDocErrors}
|
||||
setWizardDocErrors={setWizardDocErrors}
|
||||
wizardSubmitting={wizardSubmitting}
|
||||
wizardSubmitMsg={wizardSubmitMsg}
|
||||
wizardSubmitSuccess={wizardSubmitSuccess}
|
||||
wizardCanAdvance={wizardCanAdvance}
|
||||
handleWizardSubmit={handleWizardSubmit}
|
||||
form={form}
|
||||
setField={setField}
|
||||
basicFields={basicFields}
|
||||
getDocFields={getDocFields}
|
||||
fieldNote={fieldNote}
|
||||
wizardReset={() => {
|
||||
setWizardStep(0);
|
||||
setWizardDocs({});
|
||||
setWizardDocErrors({});
|
||||
setWizardSubmitMsg("");
|
||||
setWizardSubmitSuccess(false);
|
||||
}}
|
||||
/>}>
|
||||
<Show when={submitMsg()}>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -823,6 +1327,7 @@ export default function ProfilePage(props: Props) {
|
|||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue