mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { Input } from "@mantine/core";
|
||||
import { forwardRef } from "react";
|
||||
import { Input, TextInput } from "@mantine/core";
|
||||
import {
|
||||
Controller,
|
||||
type Control,
|
||||
@@ -32,17 +31,6 @@ export const toEthiopianE164 = (raw?: string | null): string => {
|
||||
return `+251${digits}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The text input rendered inside react-phone-number-input, styled to match the
|
||||
* portal's Mantine fields (44px height, 10px radius, edr border). Must forward
|
||||
* the ref and accept native input props for the library to drive it.
|
||||
*/
|
||||
const StyledInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
function StyledInput(props, ref) {
|
||||
return <input {...props} ref={ref} className="edr-phone-input" />;
|
||||
},
|
||||
);
|
||||
|
||||
export interface PhoneFieldProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
@@ -75,10 +63,10 @@ export function PhoneField({
|
||||
required={required}
|
||||
error={error}
|
||||
styles={{
|
||||
label: { fontWeight: 600, fontSize: 13, color: "#10202F", marginBottom: 6 },
|
||||
label: { fontWeight: 600, fontSize: 14, color: "#10202F", },
|
||||
}}
|
||||
>
|
||||
<div className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}>
|
||||
<div className={`edr-phone-wrapper ${error ? " edr-phone-wrapper--error" : ""}`}>
|
||||
<RPNInput
|
||||
international
|
||||
defaultCountry="ET"
|
||||
@@ -86,10 +74,9 @@ export function PhoneField({
|
||||
addInternationalOption
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
inputComponent={TextInput}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
inputComponent={StyledInput}
|
||||
/>
|
||||
</div>
|
||||
</Input.Wrapper>
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
import { Modal, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Globe2,
|
||||
UploadCloud,
|
||||
User,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
|
||||
import NationalitySelect from "@/pages/settings/NationalitySelect";
|
||||
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyNationality,
|
||||
@@ -12,12 +37,8 @@ import type {
|
||||
import { companiesService } from "@/services/companies.service";
|
||||
import type { UpdateProfilePayload } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
|
||||
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
|
||||
import NationalitySelect from "@/pages/settings/NationalitySelect";
|
||||
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
|
||||
|
||||
/** Form steps shared by CompanyProfileForm and ForwarderForm. */
|
||||
/** Form steps rendered by CompanyProfileForm. */
|
||||
type FormStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
@@ -34,6 +55,58 @@ const FORM_STEPS: FormStep[] = [
|
||||
"additional",
|
||||
];
|
||||
|
||||
/** The full onboarding journey: the two pre-form phases + the form steps. */
|
||||
type WizardStep = "nationality" | "role" | FormStep;
|
||||
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS];
|
||||
|
||||
/** Icon + title + description shown in the global dialog header per step. */
|
||||
const STEP_META: Record<
|
||||
WizardStep,
|
||||
{ icon: ReactNode; title: string; description: string }
|
||||
> = {
|
||||
nationality: {
|
||||
icon: <Globe2 size={20} />,
|
||||
title: "Where is your company registered?",
|
||||
description: "This determines the documents we'll ask you to provide.",
|
||||
},
|
||||
role: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "What does your company do?",
|
||||
description:
|
||||
"Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.",
|
||||
},
|
||||
company: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "Company Information",
|
||||
description: "Tell us about your company and its registration details.",
|
||||
},
|
||||
personnel: {
|
||||
icon: <User size={20} />,
|
||||
title: "General Manager",
|
||||
description: "Who is the general manager of the company?",
|
||||
},
|
||||
contact: {
|
||||
icon: <UserCheck size={20} />,
|
||||
title: "Contact Person",
|
||||
description: "Who should we reach out to about this account?",
|
||||
},
|
||||
poa: {
|
||||
icon: <FileText size={20} />,
|
||||
title: "Power of Attorney",
|
||||
description: "Optionally add a representative with power of attorney.",
|
||||
},
|
||||
documents: {
|
||||
icon: <UploadCloud size={20} />,
|
||||
title: "Upload Documents",
|
||||
description: "Provide the required company documents.",
|
||||
},
|
||||
additional: {
|
||||
icon: <CheckCircle2 size={20} />,
|
||||
title: "Business License",
|
||||
description: "Upload a business license for each operational profile.",
|
||||
},
|
||||
};
|
||||
|
||||
interface OnboardingWizardDialogProps {
|
||||
opened: boolean;
|
||||
/** Dismiss the dialog (user clicked the close icon). */
|
||||
@@ -98,6 +171,9 @@ export default function OnboardingWizardDialog({
|
||||
// Newly-selected business-license files per company_profile id.
|
||||
const [licenseFiles, setLicenseFiles] = useState<Record<string, File[]>>({});
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
// Mirror of CompanyProfileForm's active step so the global header + progress
|
||||
// pill can reflect it (the form no longer renders its own stepper).
|
||||
const [formStep, setFormStep] = useState<FormStep>(resumeFormStep);
|
||||
|
||||
// Saved profile data, for rehydrating the form fields after a refresh.
|
||||
const profileQuery = useQuery(
|
||||
@@ -164,6 +240,15 @@ export default function OnboardingWizardDialog({
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Mirror the form's step locally (for the header/pill) and persist it.
|
||||
const handleStepChange = useCallback(
|
||||
(step: string) => {
|
||||
setFormStep(step as FormStep);
|
||||
persistStep(step);
|
||||
},
|
||||
[persistStep],
|
||||
);
|
||||
|
||||
// The company query may resolve AFTER this dialog mounts (it's kept mounted by
|
||||
// the gate), so the phase/roles/nationality initial state can be stale — a
|
||||
// draft that already exists would otherwise leave us stuck on the first
|
||||
@@ -239,12 +324,10 @@ export default function OnboardingWizardDialog({
|
||||
existingFiles: p.licenseFiles ?? [],
|
||||
}));
|
||||
|
||||
const titleHint =
|
||||
phase === "nationality"
|
||||
? "Where is your company registered?"
|
||||
: phase === "role"
|
||||
? "Tell us what your company does to get started."
|
||||
: "Set up your company profile to finish.";
|
||||
// The active step across the whole journey, driving the header + progress pill.
|
||||
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
||||
const stepMeta = STEP_META[activeStep];
|
||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||
|
||||
const formProps = {
|
||||
documentSettingCode: documentSettingCode(effectiveNationality),
|
||||
@@ -257,7 +340,7 @@ export default function OnboardingWizardDialog({
|
||||
hideFirstStepBack: true,
|
||||
initialStep: resumeFormStep,
|
||||
resyncOpen: opened,
|
||||
onStepChange: persistStep,
|
||||
onStepChange: handleStepChange,
|
||||
onSaveStep: saveStep,
|
||||
rehydrate: profileQuery.data ?? null,
|
||||
roleProfiles,
|
||||
@@ -279,63 +362,98 @@ export default function OnboardingWizardDialog({
|
||||
keepMounted
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
styles={{
|
||||
header: {
|
||||
alignItems:"flex-start"
|
||||
},
|
||||
title: {
|
||||
flex: 1
|
||||
}
|
||||
}}
|
||||
title={
|
||||
<Stack gap={2}>
|
||||
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
|
||||
Complete your onboarding
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{titleHint}
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Group gap="sm" mb={4}>
|
||||
{stepMeta.icon}
|
||||
<Title order={3}>{stepMeta.title}</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm">
|
||||
{stepMeta.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect value={nationality} onChange={setNationality} />
|
||||
<RoleContinueBar
|
||||
disabled={!nationality}
|
||||
onClick={handleNationalityContinue}
|
||||
/>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} />
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<RoleContinueBar
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
onClick={handleRolesContinue}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
<Stack gap="xl">
|
||||
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : <ArrowRight size={16} />
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleContinueBar({
|
||||
disabled,
|
||||
loading,
|
||||
onClick,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
/**
|
||||
* Continuous progress pill: a single rounded track that fills left-to-right as
|
||||
* the user advances, with faint ticks marking each step boundary.
|
||||
*/
|
||||
function ProgressPill({ current, total }: { current: number; total: number }) {
|
||||
const pct = total > 0 ? ((current + 1) / total) * 100 : 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Setting up…" : "Continue"}
|
||||
</button>
|
||||
<Box className="relative h-1.5 w-full overflow-hidden rounded-full bg-edr-border">
|
||||
<Box
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-[var(--mantine-color-edr-green-6)] transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
.edr-phone-wrapper .PhoneInputCountry {
|
||||
margin: 0;
|
||||
padding: 0 10px;
|
||||
height: 44px;
|
||||
border: 1px solid #e6ecf2;
|
||||
border-radius: 10px;
|
||||
height: 2.25rem;
|
||||
border: 0.0625rem solid #b0bfce;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user