mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 05:58:19 +00:00
432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Center,
|
|
Group,
|
|
Paper,
|
|
Stack,
|
|
Text,
|
|
Title,
|
|
rem,
|
|
} from "@mantine/core";
|
|
import {
|
|
IconArrowLeft,
|
|
IconArrowRight,
|
|
IconCheck,
|
|
IconCircleCheck,
|
|
IconLogout2,
|
|
IconMapPin,
|
|
IconUser,
|
|
} from "@tabler/icons-react";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useApiMutation } from "@ema-platform/api";
|
|
import { notify, useErrorHandler } from "@ema-platform/ui";
|
|
import {
|
|
authStorage,
|
|
setUser,
|
|
setCurrentProfile,
|
|
logout,
|
|
type AuthUser,
|
|
type CurrentProfile,
|
|
} from "@ema-platform/auth";
|
|
import { useAppDispatch, useAppSelector } from "../../../store/hooks";
|
|
import {
|
|
ProfileFormContent,
|
|
profileSchema,
|
|
type ProfileValues,
|
|
} from "../../profile/components/ProfileFormContent";
|
|
import {
|
|
AddressFormContent,
|
|
addressSchema,
|
|
type AddressValues,
|
|
} from "../../profile/components/AddressFormContent";
|
|
|
|
const STEPS = [
|
|
{ label: "Profile", icon: IconUser },
|
|
{ label: "Address", icon: IconMapPin },
|
|
];
|
|
|
|
function StepIndicator({
|
|
active,
|
|
completed,
|
|
}: {
|
|
active: number;
|
|
completed: number[];
|
|
}) {
|
|
return (
|
|
<Box mb={32}>
|
|
<Group gap={0} align="center" wrap="nowrap">
|
|
{STEPS.map((step, i) => {
|
|
const isDone = completed.includes(i);
|
|
const isCurrent = active === i;
|
|
return (
|
|
<Group
|
|
key={i}
|
|
gap={0}
|
|
align="center"
|
|
style={{ flex: i < STEPS.length - 1 ? 1 : "none" }}
|
|
>
|
|
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
|
|
<Box
|
|
style={{
|
|
width: rem(40),
|
|
height: rem(40),
|
|
borderRadius: "50%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: isDone
|
|
? "var(--mantine-color-blue-8)"
|
|
: isCurrent
|
|
? "var(--mantine-color-blue-7)"
|
|
: "var(--mantine-color-gray-1)",
|
|
border: isCurrent
|
|
? "2.5px solid var(--mantine-color-blue-5)"
|
|
: "2px solid transparent",
|
|
boxShadow:
|
|
isCurrent || isDone
|
|
? "0 2px 8px rgba(34, 139, 230, 0.2)"
|
|
: "none",
|
|
flexShrink: 0,
|
|
transition: "all 0.2s ease",
|
|
}}
|
|
>
|
|
{isDone ? (
|
|
<IconCheck size={18} color="white" stroke={2.5} />
|
|
) : (
|
|
<Text fw={700} fz="sm" c={isCurrent ? "white" : "gray.5"}>
|
|
{i + 1}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Text
|
|
fz="xs"
|
|
fw={isCurrent ? 700 : 400}
|
|
c={isCurrent ? "blue.7" : "dimmed"}
|
|
style={{ whiteSpace: "nowrap" }}
|
|
>
|
|
{step.label}
|
|
</Text>
|
|
</Stack>
|
|
|
|
{i < STEPS.length - 1 && (
|
|
<Box
|
|
style={{
|
|
flex: 1,
|
|
height: rem(2),
|
|
backgroundColor: isDone
|
|
? "var(--mantine-color-blue-8)"
|
|
: "var(--mantine-color-gray-2)",
|
|
marginBottom: rem(22),
|
|
}}
|
|
/>
|
|
)}
|
|
</Group>
|
|
);
|
|
})}
|
|
</Group>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function ProfileSetupPage() {
|
|
const navigate = useNavigate();
|
|
const dispatch = useAppDispatch();
|
|
const user = useAppSelector((state) => state.auth.user);
|
|
const [active, setActive] = useState(0);
|
|
const [completed, setCompleted] = useState<number[]>([]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [professions, setProfessions] = useState<
|
|
Array<{ id: string; name: { en: string } }>
|
|
>([]);
|
|
const [professionsLoading, setProfessionsLoading] = useState(true);
|
|
const [profileTrigger] = useApiMutation<{ id: string }>();
|
|
const [addressTrigger] = useApiMutation<{ id: string }>();
|
|
const [meTrigger] = useApiMutation<AuthUser>();
|
|
const [profileCheckTrigger] = useApiMutation<{
|
|
total: number;
|
|
items: CurrentProfile[];
|
|
}>();
|
|
const [fetchProfessions] = useApiMutation<{
|
|
count: number;
|
|
items: Array<{ id: string; name: { en: string } }>;
|
|
}>();
|
|
const { handleError } = useErrorHandler();
|
|
const fetched = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (fetched.current) return;
|
|
fetched.current = true;
|
|
fetchProfessions({ url: "/professions?take=100", method: "GET" })
|
|
.unwrap()
|
|
.then((data) => setProfessions(data.items ?? []))
|
|
.catch(() => setProfessions([]))
|
|
.finally(() => setProfessionsLoading(false));
|
|
}, [fetchProfessions]);
|
|
|
|
const professionOptions = useMemo(
|
|
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
|
[professions],
|
|
);
|
|
|
|
const professionNameMap = useMemo(() => {
|
|
const map: Record<string, string> = {};
|
|
professions.forEach((p) => {
|
|
map[p.id] = p.name.en;
|
|
});
|
|
return map;
|
|
}, [professions]);
|
|
|
|
const nameParts = useMemo(
|
|
() => (user?.name?.en || "").trim().split(/\s+/),
|
|
[user],
|
|
);
|
|
const profileDefaults: ProfileValues = useMemo(
|
|
() => ({
|
|
professionId: "",
|
|
firstName: nameParts[0] || "",
|
|
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(" ") : "",
|
|
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : "",
|
|
gender: "",
|
|
dob: "",
|
|
pob: "",
|
|
maritalStatus: "",
|
|
}),
|
|
[nameParts],
|
|
);
|
|
|
|
const addressDefaults: AddressValues = useMemo(
|
|
() => ({
|
|
idType: "",
|
|
idNumber: "",
|
|
nationality: "",
|
|
primaryPhoneNumber: user?.phoneNumber || "",
|
|
secondaryPhoneNumber: "",
|
|
email: user?.email || "",
|
|
regionId: "",
|
|
cityId: "",
|
|
subcityId: "",
|
|
woredaId: "",
|
|
kebeleId: "",
|
|
streetAddress: "",
|
|
postalAddress: "",
|
|
emergencyContactName: "",
|
|
emergencyContactPhone: "",
|
|
emergencyContactRelation: "",
|
|
}),
|
|
[user],
|
|
);
|
|
|
|
const {
|
|
register: profileRegister,
|
|
handleSubmit: profileHandleSubmit,
|
|
formState: { errors: profileErrors },
|
|
setValue: profileSetValue,
|
|
watch: profileWatch,
|
|
trigger: profileTriggerValidation,
|
|
} = useForm<ProfileValues>({
|
|
resolver: zodResolver(profileSchema),
|
|
defaultValues: profileDefaults,
|
|
});
|
|
|
|
const {
|
|
register: addressRegister,
|
|
handleSubmit: addressHandleSubmit,
|
|
formState: { errors: addressErrors },
|
|
setValue: addressSetValue,
|
|
watch: addressWatch,
|
|
trigger: addressTriggerValidation,
|
|
} = useForm<AddressValues>({
|
|
resolver: zodResolver(addressSchema),
|
|
defaultValues: addressDefaults,
|
|
});
|
|
|
|
const onNext = async () => {
|
|
const valid = await profileTriggerValidation();
|
|
if (!valid) return;
|
|
setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
|
|
setActive((c) => c + 1);
|
|
};
|
|
|
|
const onSubmitAddress = async () => {
|
|
const valid = await addressTriggerValidation();
|
|
if (!valid) return;
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const pv = profileWatch();
|
|
const av = addressWatch();
|
|
const selectedProfessionName = professionNameMap[pv.professionId] ?? "";
|
|
|
|
const addressData = await addressTrigger({
|
|
url: `/addresss`,
|
|
method: "POST",
|
|
body: {
|
|
idType: av.idType,
|
|
idNumber: av.idNumber,
|
|
nationality: av.nationality,
|
|
primaryPhoneNumber: av.primaryPhoneNumber,
|
|
secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
|
|
email: av.email || undefined,
|
|
regionId: av.regionId || undefined,
|
|
cityId: av.cityId || undefined,
|
|
subcityId: av.subcityId || undefined,
|
|
woredaId: av.woredaId || undefined,
|
|
kebeleId: av.kebeleId || undefined,
|
|
streetAddress: av.streetAddress || undefined,
|
|
postalAddess: av.postalAddress || undefined,
|
|
emergencyContactName: av.emergencyContactName || undefined,
|
|
emergencyContactPhone: av.emergencyContactPhone || undefined,
|
|
emergencyContactRelation: av.emergencyContactRelation || undefined,
|
|
},
|
|
}).unwrap();
|
|
|
|
const profileResult = await profileTrigger({
|
|
url: "/profiles",
|
|
method: "POST",
|
|
body: {
|
|
userId: user?.id,
|
|
type: "SEAFARER",
|
|
professionId: pv.professionId,
|
|
firstName: pv.firstName,
|
|
middleName: pv.middleName,
|
|
lastName: pv.lastName,
|
|
gender: pv.gender,
|
|
dob: pv.dob,
|
|
pob: pv.pob || undefined,
|
|
maritalStatus: pv.maritalStatus,
|
|
addressId: addressData.id,
|
|
},
|
|
}).unwrap();
|
|
authStorage.setProfileId(profileResult.id);
|
|
|
|
const me = await meTrigger({ url: "/auth/me", method: "GET" }).unwrap();
|
|
dispatch(setUser(me));
|
|
|
|
// POST /profiles doesn't return the profession relation the portal's nav/route
|
|
// guard reads (PortalLayout.tsx) — re-fetch with it, same as LoginPage does on login.
|
|
try {
|
|
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
|
|
const profileCheck = await profileCheckTrigger({
|
|
url: `/profiles?q=${encodeURIComponent(q)}`,
|
|
method: "GET",
|
|
}).unwrap();
|
|
if (profileCheck.total > 0 && profileCheck.items.length > 0) {
|
|
dispatch(setCurrentProfile(profileCheck.items[0]));
|
|
}
|
|
} catch {
|
|
// non-fatal — sidebar/route guard falls back to FALLBACK_ACCESS
|
|
}
|
|
|
|
notify.success("Profile setup complete!");
|
|
navigate("/dashboard");
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<Center mih="100vh">
|
|
<Text c="dimmed">Please log in first.</Text>
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Center mih="100vh" bg="gray.0">
|
|
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
|
|
<Stack gap="md">
|
|
<div>
|
|
<Title order={3}>Complete Your Profile</Title>
|
|
<Text fz="sm" c="dimmed">
|
|
Set up your profile and address to get started
|
|
</Text>
|
|
</div>
|
|
|
|
<StepIndicator active={active} completed={completed} />
|
|
|
|
{active === 0 && (
|
|
<>
|
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
|
Personal Information
|
|
</Text>
|
|
<ProfileFormContent
|
|
register={profileRegister}
|
|
errors={profileErrors}
|
|
setValue={profileSetValue}
|
|
watch={profileWatch}
|
|
trigger={profileTriggerValidation}
|
|
professionsLoading={professionsLoading}
|
|
professionOptions={professionOptions}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{active === 1 && (
|
|
<>
|
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
|
Identity & Contact
|
|
</Text>
|
|
<AddressFormContent
|
|
register={addressRegister}
|
|
errors={addressErrors}
|
|
setValue={addressSetValue}
|
|
watch={addressWatch}
|
|
trigger={addressTriggerValidation}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<Group justify="space-between" mt="xl">
|
|
<Button
|
|
variant="default"
|
|
color="gray"
|
|
leftSection={<IconLogout2 size={16} />}
|
|
onClick={() => {
|
|
dispatch(logout());
|
|
navigate("/login");
|
|
}}
|
|
>
|
|
Sign out
|
|
</Button>
|
|
<Group gap="sm">
|
|
{active > 0 && (
|
|
<Button
|
|
variant="default"
|
|
leftSection={<IconArrowLeft size={16} />}
|
|
onClick={() => setActive((c) => c - 1)}
|
|
>
|
|
Previous
|
|
</Button>
|
|
)}
|
|
{active < STEPS.length - 1 ? (
|
|
<Button
|
|
rightSection={<IconArrowRight size={16} />}
|
|
onClick={onNext}
|
|
>
|
|
Next Step
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
color="blue"
|
|
leftSection={<IconCircleCheck size={16} />}
|
|
onClick={onSubmitAddress}
|
|
loading={submitting}
|
|
>
|
|
Complete Setup
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
</Stack>
|
|
</Paper>
|
|
</Center>
|
|
);
|
|
}
|