mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #462 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Query } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
|
||||
import { CheckAvailabilityService } from "./check-availability.service";
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("auth")
|
||||
@Public()
|
||||
export class CheckAvailabilityController {
|
||||
constructor(
|
||||
private readonly checkAvailabilityService: CheckAvailabilityService,
|
||||
) {}
|
||||
|
||||
@Get("check-availability")
|
||||
@ApiOperation({
|
||||
summary: "Check whether an email and/or phone number is already registered",
|
||||
})
|
||||
check(@Query("email") email?: string, @Query("phone") phone?: string) {
|
||||
return this.checkAvailabilityService.check({ email, phone });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
export interface CheckAvailabilityQuery {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityResult {
|
||||
emailTaken: boolean;
|
||||
phoneTaken: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CheckAvailabilityService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async check({
|
||||
email,
|
||||
phone,
|
||||
}: CheckAvailabilityQuery): Promise<CheckAvailabilityResult> {
|
||||
if (!email && !phone) {
|
||||
throw new BadRequestException("email or phone is required");
|
||||
}
|
||||
|
||||
const matches = await this.userRepository.find({
|
||||
where: [
|
||||
...(email ? [{ email }] : []),
|
||||
...(phone ? [{ phoneNumber: phone }] : []),
|
||||
],
|
||||
select: { id: true, email: true, phoneNumber: true },
|
||||
});
|
||||
|
||||
return {
|
||||
emailTaken: email ? matches.some((user) => user.email === email) : false,
|
||||
phoneTaken: phone
|
||||
? matches.some((user) => user.phoneNumber === phone)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { FreightMeController } from './freight-me.controller';
|
||||
import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FreightMeController],
|
||||
providers: [FreightMeService],
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [FreightMeController, CheckAvailabilityController],
|
||||
providers: [FreightMeService, CheckAvailabilityService],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
|
||||
@@ -1183,9 +1183,11 @@ export class CompaniesService {
|
||||
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
||||
if (!businessInfo) {
|
||||
throw new BadRequestException(
|
||||
"No business license found for this TIN. Please check the number and try again.",
|
||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||
);
|
||||
}
|
||||
return this.etradeService.extractRegistrationData(businessInfo);
|
||||
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||
return { ...registrationData, tinTaken };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
|
||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
|
||||
@@ -17,10 +17,7 @@ export class CreateCompanyDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^00\d{8}$/, {
|
||||
message: 'TIN must be 10 digits starting with 00',
|
||||
})
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
tin!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
||||
managerName!: string;
|
||||
managerEmail?: string;
|
||||
managerPhone!: string;
|
||||
tinTaken?: boolean;
|
||||
|
||||
constructor(data: CompanyRegistrationData) {
|
||||
this.licenceNumber = data.licenceNumber;
|
||||
@@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
||||
this.managerName = data.managerName;
|
||||
this.managerEmail = data.managerEmail;
|
||||
this.managerPhone = data.managerPhone;
|
||||
this.tinTaken = data.tinTaken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
|
||||
import { CompanyNationality } from '../entities/company.entity';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
|
||||
@@ -34,10 +34,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(10, 10)
|
||||
@Matches(/^00\d{8}$/, {
|
||||
message: 'TIN must be 10 digits starting with 00',
|
||||
})
|
||||
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.jpg
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.png
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 KiB |
@@ -23,6 +23,7 @@ import {
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
@@ -135,17 +136,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "User Management",
|
||||
label: "Staff",
|
||||
href: "/um",
|
||||
icon: <Users />,
|
||||
},
|
||||
{
|
||||
label: "Booking requests",
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Contract requests",
|
||||
label: "Contracts",
|
||||
href: "/dashboard/contract-requests",
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
@@ -174,7 +175,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
title: "Operations",
|
||||
items: [
|
||||
{
|
||||
label: "Document Clearance",
|
||||
label: "Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: [
|
||||
@@ -312,7 +313,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Import Operations",
|
||||
label: "Imports",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
children: [
|
||||
@@ -344,7 +345,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Export Operations",
|
||||
label: "Exports",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
children: [
|
||||
@@ -516,6 +517,38 @@ const filterSidebarByPermission = (
|
||||
.filter((section) => section.items.length > 0);
|
||||
};
|
||||
|
||||
const APP_TITLE = "EDR Freight Backoffice";
|
||||
|
||||
/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */
|
||||
const flattenSidebarItems = (
|
||||
sections: SidebarSection[],
|
||||
): { href: string; label: string }[] =>
|
||||
sections.flatMap((section) =>
|
||||
section.items.flatMap((item) => [
|
||||
...(item.href ? [{ href: item.href, label: item.label }] : []),
|
||||
...(item.children ?? [])
|
||||
.filter((child): child is SidebarItem & { href: string } =>
|
||||
Boolean(child.href),
|
||||
)
|
||||
.map((child) => ({ href: child.href, label: child.label })),
|
||||
]),
|
||||
);
|
||||
|
||||
/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */
|
||||
const findActiveSidebarLabel = (
|
||||
pathname: string,
|
||||
sections: SidebarSection[],
|
||||
): string | undefined => {
|
||||
const path = pathname.toLowerCase();
|
||||
const candidates = flattenSidebarItems(sections)
|
||||
.map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() }))
|
||||
.sort((a, b) => b.href.length - a.href.length);
|
||||
|
||||
return candidates.find(
|
||||
({ href }) => path === href || path.startsWith(`${href}/`),
|
||||
)?.label;
|
||||
};
|
||||
|
||||
const DashboardShell = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -541,6 +574,14 @@ const DashboardShell = () => {
|
||||
: null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const activeLabel = findActiveSidebarLabel(
|
||||
location.pathname,
|
||||
sidebarSections,
|
||||
);
|
||||
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
||||
}, [location.pathname, sidebarSections]);
|
||||
|
||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||
return <Navigate to={glClearanceHome} replace />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Image, Stack, Text, Title } from "@mantine/core";
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
|
||||
const EDR_IMAGE = "/assets/edr_image.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
/** Muted deep-green brand wash for the left panel. */
|
||||
const LEFT_PANEL_BG =
|
||||
"linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)";
|
||||
|
||||
/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */
|
||||
const IMAGE_FADE_MASK =
|
||||
"linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)";
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Headline shown in the top-left of the green panel. */
|
||||
tagline?: string;
|
||||
taglineBody?: string;
|
||||
}
|
||||
|
||||
const LeftPanel = ({
|
||||
tagline,
|
||||
taglineBody,
|
||||
}: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||
<Box
|
||||
className="relative hidden shrink-0 overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.12)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]"
|
||||
style={{ background: LEFT_PANEL_BG }}
|
||||
>
|
||||
{/* Top-left: logo, title, description — stacked, left aligned. */}
|
||||
<Stack
|
||||
gap="xl"
|
||||
className="relative z-10 p-8 lg:p-10"
|
||||
style={{ maxWidth: 520 }}
|
||||
>
|
||||
<Image
|
||||
src={EDR_LOGO}
|
||||
alt="EDR Freight"
|
||||
h={40}
|
||||
w="auto"
|
||||
fit="contain"
|
||||
style={{ filter: "brightness(0) invert(1)", alignSelf: "flex-start" }}
|
||||
/>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title
|
||||
order={1}
|
||||
c="white"
|
||||
fz={38}
|
||||
fw={800}
|
||||
lh={1.08}
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tagline ?? "Ethiopian Djibouti Railway"}
|
||||
</Title>
|
||||
<Text fz="md" lh={1.6} c="rgba(255,255,255,0.82)" maw={440}>
|
||||
{taglineBody ??
|
||||
"Manage bookings, track cargo, and run day-to-day logistics for the Ethio–Djibouti Railway from a single backoffice."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */}
|
||||
<Image
|
||||
src={EDR_IMAGE}
|
||||
alt=""
|
||||
aria-hidden
|
||||
fit="cover"
|
||||
pos="absolute"
|
||||
right={0}
|
||||
bottom={0}
|
||||
w="80%"
|
||||
h="60%"
|
||||
style={{
|
||||
WebkitMaskImage: IMAGE_FADE_MASK,
|
||||
maskImage: IMAGE_FADE_MASK,
|
||||
pointerEvents: "none",
|
||||
opacity: 0.9,
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const RightPanelDecor = () => (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
||||
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full text-gray-200/40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="auth-grid"
|
||||
width="28"
|
||||
height="28"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#auth-grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LanguageSelector = () => (
|
||||
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
||||
<Globe className="h-4 w-4 text-gray-500" />
|
||||
<span>Eng</span>
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function AuthShell({
|
||||
children,
|
||||
tagline,
|
||||
taglineBody,
|
||||
}: AuthShellProps) {
|
||||
return (
|
||||
<div className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]">
|
||||
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
|
||||
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-white shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
||||
<RightPanelDecor />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="flex min-h-full justify-center">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "react";
|
||||
|
||||
import type { SidebarItem, SidebarSection } from "./types";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export interface FreightSidebarProps {
|
||||
sections: SidebarSection[];
|
||||
@@ -35,15 +36,15 @@ const BRAND_LOGO = "/assets/logo.svg";
|
||||
const navClassNames = (active: boolean) =>
|
||||
active
|
||||
? {
|
||||
root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
|
||||
label: "text-edr-primary-dark! font-medium! text-sm!",
|
||||
section: "text-edr-primary-dark!",
|
||||
}
|
||||
root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
|
||||
label: "text-edr-primary-dark! font-medium! text-sm!",
|
||||
section: "text-edr-primary-dark!",
|
||||
}
|
||||
: {
|
||||
root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
|
||||
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
|
||||
section: "text-edr-text!",
|
||||
};
|
||||
root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
|
||||
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
|
||||
section: "text-edr-text!",
|
||||
};
|
||||
|
||||
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
|
||||
`${parentKey}/${item.href ?? item.label}/${index}`;
|
||||
@@ -65,7 +66,9 @@ const FreightSidebar = ({
|
||||
const isHrefActive = useCallback(
|
||||
(href: string) => {
|
||||
const normalized = href.toLowerCase();
|
||||
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
||||
return (
|
||||
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
||||
);
|
||||
},
|
||||
[activePath],
|
||||
);
|
||||
@@ -109,9 +112,7 @@ const FreightSidebar = ({
|
||||
|
||||
if (hasChildren) {
|
||||
const isLink = !!item.href;
|
||||
const active =
|
||||
(isLink ? isHrefActive(item.href!) : false) ||
|
||||
branchActive(item.children!);
|
||||
const active = isLink ? isHrefActive(item.href!) : false;
|
||||
const isOpen = openMap[key] ?? false;
|
||||
|
||||
return (
|
||||
@@ -124,7 +125,7 @@ const FreightSidebar = ({
|
||||
active={active}
|
||||
opened={isOpen}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={ () => toggle(key)}
|
||||
onClick={() => toggle(key)}
|
||||
rightSection={
|
||||
<Box
|
||||
component="span"
|
||||
@@ -140,7 +141,9 @@ const FreightSidebar = ({
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="text-edr-muted transition-transform duration-200"
|
||||
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
@@ -161,8 +164,9 @@ const FreightSidebar = ({
|
||||
label={item.label}
|
||||
leftSection={item.icon}
|
||||
active={active}
|
||||
component={Link}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={() => onNavigate?.(item.href!)}
|
||||
to={item.href!}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -178,7 +182,7 @@ const FreightSidebar = ({
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mb={6}
|
||||
className={ "text-edr-muted!" }
|
||||
className={"text-edr-muted!"}
|
||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||
>
|
||||
{section.title}
|
||||
@@ -232,14 +236,24 @@ const FreightSidebar = ({
|
||||
</Box>
|
||||
</Group>
|
||||
{onClose && (
|
||||
<UnstyledButton onClick={onClose} hiddenFrom="sm" aria-label="Close sidebar">
|
||||
<UnstyledButton
|
||||
onClick={onClose}
|
||||
hiddenFrom="sm"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Nav */}
|
||||
<AppShell.Section grow component={ScrollArea} type="never" px="sm" pb="md">
|
||||
<AppShell.Section
|
||||
grow
|
||||
component={ScrollArea}
|
||||
type="never"
|
||||
px="sm"
|
||||
pb="md"
|
||||
>
|
||||
<Stack gap="lg">{renderedSections}</Stack>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
@@ -27,7 +27,6 @@ export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,162 +1,40 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowUpRight,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
const normaliseIdentifier = (raw: string): string => {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||
const local = digits.startsWith("251")
|
||||
? digits.slice(3)
|
||||
: digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
};
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
const LeftPanelDecor = () => (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<svg
|
||||
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
||||
<circle
|
||||
key={ring}
|
||||
cx="200"
|
||||
cy="200"
|
||||
r={60 + ring * 36}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const RightPanelDecor = () => (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
||||
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full text-gray-200/40"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="login-grid"
|
||||
width="28"
|
||||
height="28"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#login-grid)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LeftPanel = () => (
|
||||
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
||||
<img
|
||||
src={LOGIN_IMAGE}
|
||||
alt="Ethio Djibouti Railway"
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
|
||||
<LeftPanelDecor />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||
<img
|
||||
src={EDR_LOGO}
|
||||
alt="EDR Freight"
|
||||
className="h-7 w-auto brightness-0 invert sm:h-9"
|
||||
/>
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
Support
|
||||
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
|
||||
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span className="text-sm font-semibold text-white">
|
||||
Empower Your Freight Operations
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/85">
|
||||
Sign in to manage bookings, track cargo, and run logistics operations
|
||||
on the Ethio Djibouti Railway freight platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LanguageSelector = () => (
|
||||
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
||||
<Globe className="h-4 w-4 text-gray-500" />
|
||||
<span>Eng</span>
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const FormFooter = () => (
|
||||
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
||||
<span className="shrink-0">© 2026 EDR Freight</span>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
||||
<a
|
||||
href="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Help & Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login, verifyMfa } = useAuth();
|
||||
@@ -165,7 +43,6 @@ const LoginPage = () => {
|
||||
const [otp, setOtp] = useState("");
|
||||
const [needsMfa, setNeedsMfa] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -179,15 +56,14 @@ const LoginPage = () => {
|
||||
setNormalizedIdentifier(normalized);
|
||||
|
||||
const result = await login({ email: normalized, password });
|
||||
console.log(result);
|
||||
if (result.mfaRequired) {
|
||||
setNeedsMfa(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// navigate("/dashboard/overview", { replace: true });
|
||||
} catch {
|
||||
setError("Unable to sign in with those credentials.");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -201,194 +77,132 @@ const LoginPage = () => {
|
||||
try {
|
||||
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
|
||||
navigate("/dashboard/overview", { replace: true });
|
||||
} catch {
|
||||
setError("Unable to verify the one-time code.");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loginForm = (
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Center mb={{ base: "md", sm: "lg" }}>
|
||||
<Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
|
||||
</Center>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Get Started
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
<Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
|
||||
<Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
|
||||
Welcome back!
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Log in to access the freight backoffice & explore all logistics
|
||||
resources.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email or Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className={`${fieldClass} pr-11`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
{submitting ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Need an account?{" "}
|
||||
<a href="#" className="font-semibold text-primary hover:underline">
|
||||
Contact your admin
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<Button type="submit" color="edr-green" fullWidth loading={submitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const mfaForm = (
|
||||
<form className="flex w-full flex-col" onSubmit={handleVerifyMfa}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
<Box component="form" onSubmit={handleVerifyMfa}>
|
||||
<Center mb={{ base: "md", sm: "lg" }}>
|
||||
<Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
|
||||
</Center>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
<Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
|
||||
<Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
|
||||
Multi-factor verification
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
We sent a verification code to{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
<Text component="span" fw={500} c="var(--mantine-color-text)">
|
||||
{normalizedIdentifier}
|
||||
</span>
|
||||
</Text>
|
||||
. Enter it below to complete sign in.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Verification code <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
<Stack gap="md">
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otp}
|
||||
onChange={(event) => setOtp(event.target.value)}
|
||||
placeholder="Enter the code"
|
||||
className={fieldClass}
|
||||
placeholder="0"
|
||||
disabled={submitting}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtp}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<button
|
||||
<Group grow>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setNeedsMfa(false);
|
||||
setOtp("");
|
||||
setError(null);
|
||||
}}
|
||||
className="h-11 min-w-0 flex-1 rounded-full border border-gray-200 bg-white text-sm font-semibold text-gray-700 transition-colors hover:border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className={`${primaryButtonClass} min-w-0 flex-1`}
|
||||
color="edr-green"
|
||||
loading={submitting}
|
||||
disabled={otp.trim().length !== 6}
|
||||
>
|
||||
{submitting ? "Verifying..." : "Verify"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
|
||||
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||
<LeftPanel />
|
||||
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
||||
<RightPanelDecor />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
{!needsMfa ? loginForm : mfaForm}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return <AuthShell>{!needsMfa ? loginForm : mfaForm}</AuthShell>;
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
BIN
apps/edr-freight-web/portal/public/assets/edr_image.jpg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/edr-freight-web/portal/public/assets/edr_image.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 KiB |
@@ -1,40 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
|
||||
import { Box, Image, Stack, Text, Title } from "@mantine/core";
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_IMAGE = "/assets/edr_image.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
/** Muted deep-green brand wash for the left panel. */
|
||||
const LEFT_PANEL_BG =
|
||||
"linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)";
|
||||
|
||||
/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */
|
||||
const IMAGE_FADE_MASK =
|
||||
"linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)";
|
||||
|
||||
export const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
export const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
const LeftPanelDecor = () => (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<svg
|
||||
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
||||
<circle
|
||||
key={ring}
|
||||
cx="200"
|
||||
cy="200"
|
||||
r={60 + ring * 36}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const RightPanelDecor = () => (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
@@ -63,7 +48,7 @@ const RightPanelDecor = () => (
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Tagline shown in the highlighted card over the left image panel. */
|
||||
/** Headline shown in the top-left of the green panel. */
|
||||
tagline?: string;
|
||||
taglineBody?: string;
|
||||
}
|
||||
@@ -72,45 +57,63 @@ const LeftPanel = ({
|
||||
tagline,
|
||||
taglineBody,
|
||||
}: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl bg-[#011F12] shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
||||
<img
|
||||
src={LOGIN_IMAGE}
|
||||
alt="Ethio Djibouti Railway"
|
||||
className="absolute inset-0 h-auto w-full object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/15 to-[#1a5c34]/45" />
|
||||
<LeftPanelDecor />
|
||||
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||
<img
|
||||
<Box
|
||||
className="relative hidden shrink-0 overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.12)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]"
|
||||
style={{ background: LEFT_PANEL_BG }}
|
||||
>
|
||||
{/* Top-left: logo, title, description — stacked, left aligned. */}
|
||||
<Stack
|
||||
gap="xl"
|
||||
className="relative z-10 p-8 lg:p-10"
|
||||
style={{ maxWidth: 520 }}
|
||||
>
|
||||
<Image
|
||||
src={EDR_LOGO}
|
||||
alt="EDR Freight"
|
||||
className="h-7 w-auto brightness-0 invert sm:h-9"
|
||||
h={40}
|
||||
w="auto"
|
||||
fit="contain"
|
||||
style={{ filter: "brightness(0) invert(1)", alignSelf: "flex-start" }}
|
||||
/>
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
Support
|
||||
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
|
||||
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{tagline ?? "Empower Your Freight Operations"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/85">
|
||||
<Stack gap="sm">
|
||||
<Title
|
||||
order={1}
|
||||
c="white"
|
||||
fz={38}
|
||||
fw={800}
|
||||
lh={1.08}
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tagline ?? "Ethiopian Djibouti Railway"}
|
||||
</Title>
|
||||
<Text fz="md" lh={1.6} c="rgba(255,255,255,0.82)" maw={440}>
|
||||
{taglineBody ??
|
||||
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"Sign in to book shipments, track cargo, and manage your freight on the Ethio–Djibouti Railway platform."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */}
|
||||
<Image
|
||||
src={EDR_IMAGE}
|
||||
alt=""
|
||||
aria-hidden
|
||||
fit="cover"
|
||||
pos="absolute"
|
||||
right={0}
|
||||
bottom={0}
|
||||
w="80%"
|
||||
h="60%"
|
||||
style={{
|
||||
WebkitMaskImage: IMAGE_FADE_MASK,
|
||||
maskImage: IMAGE_FADE_MASK,
|
||||
opacity: 0.9,
|
||||
pointerEvents: "none",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const LanguageSelector = () => (
|
||||
@@ -125,24 +128,24 @@ const FormFooter = () => (
|
||||
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
|
||||
<span className="shrink-0">© 2026 EDR Freight</span>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
|
||||
<a
|
||||
href="#"
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
</Link>
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
</Link>
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Help & Support
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -169,8 +172,8 @@ export default function AuthShell({
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
<div className="flex min-h-full justify-center">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { AlertCircle, CheckCircle2, Download } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react";
|
||||
import { useETradeData } from "@/hooks/useETradeData";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
interface ETradeInfoProps {
|
||||
@@ -22,6 +24,8 @@ interface ETradeInfoProps {
|
||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
}
|
||||
|
||||
const isValidTin = (tin: string) => tin.length === 10;
|
||||
|
||||
export default function ETradeInfo({
|
||||
tin,
|
||||
register,
|
||||
@@ -30,53 +34,100 @@ export default function ETradeInfo({
|
||||
}: ETradeInfoProps) {
|
||||
const mutation = useETradeData();
|
||||
const isLoading = mutation.isPending;
|
||||
const hasData = mutation.data;
|
||||
const tinTaken = mutation.data?.tinTaken;
|
||||
const hasData =
|
||||
mutation.data && !mutation.data.tinTaken ? mutation.data : null;
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!tin || tin.length !== 10 || !tin.startsWith("00")) return;
|
||||
if (!isValidTin(tin)) return;
|
||||
const result = await mutation.mutateAsync(tin);
|
||||
if (result) {
|
||||
if (result && !result.tinTaken) {
|
||||
onDataLoaded(result);
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage =
|
||||
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
|
||||
// once per distinct value, so retyping the same TIN doesn't refetch.
|
||||
const lastFetchedTin = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
|
||||
lastFetchedTin.current = tin;
|
||||
handleFetch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tin]);
|
||||
|
||||
const apiError =
|
||||
mutation.isError && mutation.error
|
||||
? (mutation.error as any).message ||
|
||||
"Failed to fetch company information. Please try again."
|
||||
? extractApiError(mutation.error)
|
||||
: null;
|
||||
// A 400 here means eTrade simply has no record for this TIN — not a
|
||||
// failure. Soft-pedal it as an FYI, not a red error, so filling in
|
||||
// manually doesn't feel like something went wrong.
|
||||
const notFound = apiError?.statusCode === 400;
|
||||
const errorMessage =
|
||||
apiError && !notFound
|
||||
? apiError.message ||
|
||||
"We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-start" grow>
|
||||
<TextInput
|
||||
label={<>TIN Number (10 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
label={
|
||||
<>
|
||||
TIN Number (10 digits){" "}
|
||||
<span style={{ color: "var(--mantine-color-red-6)" }}>*</span>
|
||||
</>
|
||||
}
|
||||
placeholder="0012345678"
|
||||
maxLength={10}
|
||||
error={error}
|
||||
{...register}
|
||||
/>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
onClick={handleFetch}
|
||||
disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading}
|
||||
leftSection={
|
||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||
}
|
||||
mt="24px"
|
||||
>
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
{errorMessage && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
onClick={handleFetch}
|
||||
disabled={!isValidTin(tin) || isLoading}
|
||||
leftSection={
|
||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||
}
|
||||
mt="24px"
|
||||
>
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{notFound && (
|
||||
<Alert icon={<Info size={16} />} color="gray">
|
||||
We couldn't find a matching business record for this TIN — no
|
||||
problem, just fill in the details below.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Failed to fetch data"
|
||||
title="Couldn't fetch eTrade data"
|
||||
>
|
||||
{errorMessage} You can still fill in the details manually below.
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tinTaken && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="TIN already registered"
|
||||
>
|
||||
This TIN is already registered to another company account. Please
|
||||
double-check the number, or contact support if you believe this is a
|
||||
mistake.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ interface RoleLicenseStepProps {
|
||||
/** Newly-selected files per profile id (not yet uploaded). */
|
||||
value: Record<string, File[]>;
|
||||
onChange: (value: Record<string, File[]>) => void;
|
||||
/** "Business license is required" style error, keyed by profile id. */
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +84,7 @@ export default function RoleLicenseStep({
|
||||
profiles,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
}: RoleLicenseStepProps) {
|
||||
const setFiles = (profileId: string, files: File[]) => {
|
||||
onChange({ ...value, [profileId]: files });
|
||||
@@ -123,6 +126,11 @@ export default function RoleLicenseStep({
|
||||
file={buildLicenseSetting(profile.id, label)}
|
||||
value={{ [LICENSE_FILE_KEY]: selected }}
|
||||
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
||||
errors={
|
||||
errors?.[profile.id]
|
||||
? { [LICENSE_FILE_KEY]: errors[profile.id] }
|
||||
: undefined
|
||||
}
|
||||
onChange={(v) => {
|
||||
const next = v[LICENSE_FILE_KEY];
|
||||
const files = Array.isArray(next) ? next : next ? [next] : [];
|
||||
|
||||
@@ -14,6 +14,7 @@ export const URL_CONSTANTS = {
|
||||
SET_PASSWORD: "/api/auth/set-password",
|
||||
ME: "/api/auth/me",
|
||||
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
|
||||
CHECK_AVAILABILITY: "/api/auth/check-availability",
|
||||
},
|
||||
|
||||
OTP: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
type RoleLicenseProfile,
|
||||
@@ -279,22 +280,39 @@ export default function CompanyProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
/** Fill the General Manager from the eTrade business owner. */
|
||||
const useOwnerAsManager = () => {
|
||||
if (!etradeOwner) return;
|
||||
setValue("generalManagerName", etradeOwner.name);
|
||||
setValue("generalManagerEmail", user.email);
|
||||
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||
|
||||
// General Manager source: the eTrade-registered business owner when a TIN
|
||||
// lookup found one, otherwise the registering user's own account details.
|
||||
const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
|
||||
const gmSourcePhone = etradeOwner
|
||||
? etradeOwner.phone
|
||||
: toEthiopianE164(user.phoneNumber);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner) return;
|
||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerPhone", gmSourcePhone ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]);
|
||||
|
||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
||||
setGmSameAsOwner(checked);
|
||||
if (!checked) {
|
||||
setValue("generalManagerName", "");
|
||||
setValue("generalManagerEmail", "");
|
||||
setValue("generalManagerPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
@@ -341,6 +359,72 @@ export default function CompanyProfileForm({
|
||||
|
||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||
|
||||
// Hard verification for the documents step: required company-level
|
||||
// documents and a business license per operational profile must both be
|
||||
// present before the user can continue.
|
||||
const [documentFieldErrors, setDocumentFieldErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [licenseFieldErrors, setLicenseFieldErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const validateRequiredDocuments = (): Record<string, string> => {
|
||||
const errs: Record<string, string> = {};
|
||||
for (const field of uploadSetting?.fields ?? []) {
|
||||
const min = getMinFiles(field);
|
||||
if (min <= 0) continue;
|
||||
if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue;
|
||||
const v = documentFiles[field.fileKey];
|
||||
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
|
||||
if (count < min) {
|
||||
errs[field.fileKey] = `${field.fileLabel} is required`;
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
};
|
||||
|
||||
// Every role needs at least one license file (existing or newly selected).
|
||||
const validateLicenses = (): Record<string, string> => {
|
||||
const errs: Record<string, string> = {};
|
||||
for (const p of roleProfiles ?? []) {
|
||||
const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0;
|
||||
const hasExisting = p.existingFiles.length > 0;
|
||||
if (!hasNew && !hasExisting) {
|
||||
errs[p.id] = "Business license is required";
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
};
|
||||
|
||||
const handleDocumentFilesChange = (
|
||||
next: Record<string, File | File[] | null>,
|
||||
) => {
|
||||
setDocumentFiles(next);
|
||||
setDocumentFieldErrors((prev) => {
|
||||
if (Object.keys(prev).length === 0) return prev;
|
||||
const updated = { ...prev };
|
||||
for (const key of Object.keys(updated)) {
|
||||
const v = next[key];
|
||||
const hasValue = Array.isArray(v) ? v.length > 0 : v != null;
|
||||
if (hasValue) delete updated[key];
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleLicenseFilesChange = (next: Record<string, File[]>) => {
|
||||
onLicenseChange?.(next);
|
||||
setLicenseFieldErrors((prev) => {
|
||||
if (Object.keys(prev).length === 0) return prev;
|
||||
const updated = { ...prev };
|
||||
for (const id of Object.keys(updated)) {
|
||||
if ((next[id]?.length ?? 0) > 0) delete updated[id];
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// The registration/license details come straight from the eTrade lookup and
|
||||
// are not user-editable — shown as a read-only confirmation once a TIN lookup
|
||||
// (or rehydration) has filled them in. The address fields below are separate:
|
||||
@@ -385,18 +469,21 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Every role needs at least one license file (existing or newly selected).
|
||||
const licenseComplete = (roleProfiles ?? []).every(
|
||||
(p) =>
|
||||
(licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0,
|
||||
);
|
||||
|
||||
const nextStep = async () => {
|
||||
userNavigatedRef.current = true;
|
||||
// The documents step auto-uploads whatever the user selected as they
|
||||
// continue (partial uploads are allowed — required-doc completeness is
|
||||
// re-checked on resume). A failed upload holds them on the step.
|
||||
// The documents step hard-blocks on required company documents and a
|
||||
// business license per operational profile before it auto-uploads and
|
||||
// submits — no partial-completion path forward.
|
||||
if (step === "documents") {
|
||||
const docErrors = validateRequiredDocuments();
|
||||
const licenseErrors = validateLicenses();
|
||||
if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) {
|
||||
setDocumentFieldErrors(docErrors);
|
||||
setLicenseFieldErrors(licenseErrors);
|
||||
setSaveError("Please upload all required documents before continuing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onUploadDocuments) {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -410,12 +497,6 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
}
|
||||
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
"Please upload a business license for each of your operational profiles.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
@@ -450,8 +531,6 @@ export default function CompanyProfileForm({
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
/>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
@@ -507,7 +586,7 @@ export default function CompanyProfileForm({
|
||||
from eTrade · read-only
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReadOnlyField
|
||||
label="License Number"
|
||||
value={watch("licenceNumber")}
|
||||
@@ -586,22 +665,19 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "personnel" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
{etradeOwner && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useOwnerAsManager}
|
||||
>
|
||||
Use owner as manager
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
title="Same as business owner"
|
||||
description={
|
||||
etradeOwner
|
||||
? "Reuse the eTrade-registered owner's name and phone (email from your account). Uncheck to enter different details."
|
||||
: "Reuse your account's name, email and phone. Uncheck to enter different details."
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
@@ -737,15 +813,17 @@ export default function CompanyProfileForm({
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
uploadedKeys={uploadedDocumentKeys}
|
||||
errors={documentFieldErrors}
|
||||
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||
onChange={setDocumentFiles}
|
||||
onChange={handleDocumentFilesChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => { })}
|
||||
onChange={handleLicenseFilesChange}
|
||||
errors={licenseFieldErrors}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
@@ -24,7 +26,6 @@ export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -41,8 +42,8 @@ export default function LoginPage() {
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -64,60 +65,45 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email or Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={loading}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-800">Password</span>
|
||||
<Link
|
||||
to="#"
|
||||
className="text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<PasswordInput
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
className={fieldClass}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<a href="#" className="text-xs font-semibold text-primary hover:underline">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
<Button type="submit" color="edr-green" fullWidth loading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
@@ -129,7 +115,7 @@ export default function LoginPage() {
|
||||
Create an account
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,10 @@ const passwordRequirements = [
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
{
|
||||
label: "One special character",
|
||||
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const userSchema = z
|
||||
@@ -52,8 +55,14 @@ const userSchema = z
|
||||
.min(1, "Phone number is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
userType: z.string(),
|
||||
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
firstName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
lastName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
@@ -132,12 +141,30 @@ export default function SignupPage() {
|
||||
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
// Step 1 — form is valid: send a fresh code to the chosen channel, then
|
||||
// move to the OTP challenge.
|
||||
// Step 1 — form is valid: make sure the email/phone aren't already
|
||||
// registered, then send a fresh code to the chosen channel and move to
|
||||
// the OTP challenge.
|
||||
const requestOtp = async (data: FormData) => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
const availability = await api.auth.checkAvailability.call({
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
});
|
||||
if (availability.emailTaken && availability.phoneTaken) {
|
||||
setError("An account with this email and phone number already exists.");
|
||||
return;
|
||||
}
|
||||
if (availability.emailTaken) {
|
||||
setError("An account with this email already exists.");
|
||||
return;
|
||||
}
|
||||
if (availability.phoneTaken) {
|
||||
setError("An account with this phone number already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.auth.sendOTP.call(
|
||||
channel === "email" ? { email: data.email } : { phone: data.phone },
|
||||
);
|
||||
@@ -221,12 +248,11 @@ export default function SignupPage() {
|
||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
{stage === "form" ? (
|
||||
<form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
|
||||
<form
|
||||
onSubmit={handleSubmit(requestOtp)}
|
||||
className="flex w-full flex-col"
|
||||
>
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
@@ -236,7 +262,7 @@ export default function SignupPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="First name"
|
||||
@@ -318,15 +344,25 @@ export default function SignupPage() {
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<div
|
||||
key={req.label}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||
{met ? (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<X className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
<span
|
||||
className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}
|
||||
>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -346,7 +382,11 @@ export default function SignupPage() {
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -396,7 +436,11 @@ export default function SignupPage() {
|
||||
</div>
|
||||
|
||||
{otpError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
>
|
||||
{otpError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -66,6 +66,8 @@ import type {
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type {
|
||||
AuthUser,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
@@ -107,6 +109,11 @@ export const api = {
|
||||
"setPassword",
|
||||
authService.setPassword,
|
||||
),
|
||||
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
|
||||
"auth",
|
||||
"checkAvailability",
|
||||
authService.checkAvailability,
|
||||
),
|
||||
sendOTP: endpoint<OtpPayload, OtpResponse>(
|
||||
"auth",
|
||||
"sendOTP",
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
AuthUser,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
AuthUser,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
} from "@/types/auth";
|
||||
import { client } from "@/utils/api";
|
||||
import { ApiResponse } from "@edr/types";
|
||||
@@ -23,7 +25,7 @@ export const authService = {
|
||||
},
|
||||
|
||||
createUser: async (body: SignupPayload) => {
|
||||
const res = await client.post<SignupResponse & ApiResponse<void>> (
|
||||
const res = await client.post<SignupResponse & ApiResponse<void>>(
|
||||
URL_CONSTANTS.USERS.SIGN_UP,
|
||||
body,
|
||||
);
|
||||
@@ -31,9 +33,7 @@ export const authService = {
|
||||
},
|
||||
|
||||
getMyInfo: async () => {
|
||||
const res = await client.get<AuthUser>(
|
||||
URL_CONSTANTS.USERS.ME,
|
||||
);
|
||||
const res = await client.get<AuthUser>(URL_CONSTANTS.USERS.ME);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
@@ -53,6 +53,14 @@ export const authService = {
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
checkAvailability: async (params: CheckAvailabilityPayload) => {
|
||||
const res = await client.get<ApiResponse<CheckAvailabilityResponse>>(
|
||||
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,
|
||||
{ params },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
sendOTP: async (body: OtpPayload) => {
|
||||
const res = await client.post<ApiResponse<OtpResponse>>(
|
||||
URL_CONSTANTS.OTP.SEND,
|
||||
|
||||
@@ -45,6 +45,16 @@ export interface OtpResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityPayload {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityResponse {
|
||||
emailTaken: boolean;
|
||||
phoneTaken: boolean;
|
||||
}
|
||||
|
||||
export interface SetPasswordPayload {
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
|
||||
@@ -76,4 +76,6 @@ export interface CompanyRegistrationData {
|
||||
managerName: string;
|
||||
managerEmail?: string;
|
||||
managerPhone: string;
|
||||
/** True when this TIN is already registered to an existing company. */
|
||||
tinTaken?: boolean;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,76 @@ function ExistingFileLink({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A file the user just picked (in memory, not yet persisted). Rendered with a
|
||||
* subtle "just added" entrance + an emerald accent so a fresh upload reads as
|
||||
* distinct from the neutral surrounding surface.
|
||||
*/
|
||||
function NewFileCard({
|
||||
file: fileObj,
|
||||
onRemove,
|
||||
disabled,
|
||||
hasError,
|
||||
inputName,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
disabled?: boolean;
|
||||
hasError?: boolean;
|
||||
inputName: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex items-center justify-between gap-3 rounded-lg border bg-card p-3 shadow-2xs transition",
|
||||
"animate-in fade-in slide-in-from-top-1 duration-300 hover:shadow-xs",
|
||||
hasError
|
||||
? "border-destructive/30"
|
||||
: "border-emerald-300/60 dark:border-emerald-500/25",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex items-center justify-center rounded-md bg-muted p-2">
|
||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate max-w-[200px] text-sm font-medium text-foreground md:max-w-md"
|
||||
title={fileObj.name}
|
||||
>
|
||||
{fileObj.name}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(fileObj.size)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-3 w-3" /> Ready to upload
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
className={cn(
|
||||
"relative z-10 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
aria-label={`Remove file ${fileObj.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Hidden input to represent file details in traditional form submissions */}
|
||||
<input type="hidden" name={inputName} value={fileObj.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartFileInput({
|
||||
file,
|
||||
value,
|
||||
@@ -407,228 +477,352 @@ export function SmartFileInput({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Selected Files List */}
|
||||
{currentFiles.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{currentFiles.map((fileObj, idx) => (
|
||||
<div
|
||||
key={`${fileObj.name}-${idx}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
|
||||
fieldError ? "border-destructive/30" : "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
|
||||
<FileIcon name={fileObj.name} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md"
|
||||
title={fileObj.name}
|
||||
>
|
||||
{fileObj.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(fileObj.size)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => removeFile(field.fileKey, idx)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
aria-label={`Remove file ${fileObj.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Hidden inputs to represent file details in traditional form submissions */}
|
||||
<input
|
||||
type="hidden"
|
||||
name={
|
||||
field.isMultiple
|
||||
? `${field.fileKey}[]`
|
||||
: field.fileKey
|
||||
}
|
||||
value={fileObj.name}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropzone area */}
|
||||
{!reachedLimit &&
|
||||
(variant === "minimal" ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
fileInputRefs.current[field.fileKey]?.click()
|
||||
}
|
||||
className="gap-1.5 cursor-pointer"
|
||||
>
|
||||
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{isUploaded ? "Replace File" : "Upload File"}</span>
|
||||
</Button>
|
||||
{/*
|
||||
Multiple-file fields (default variant) render as ONE integrated
|
||||
drag-and-drop surface. Uploaded files live INSIDE the dropzone as
|
||||
lightweight rows — part of the surface, not separate cards — with
|
||||
the "add more" prompt on the same surface below them. A full-cover
|
||||
transparent input makes clicking anywhere (outside a file row)
|
||||
open the picker; the prompt is pointer-transparent so clicks fall
|
||||
through to it, while file rows and their controls sit above it.
|
||||
*/}
|
||||
{variant === "default" && field.isMultiple ? (
|
||||
<div
|
||||
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
||||
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
className={cn(
|
||||
"relative flex flex-col gap-2.5 rounded-xl border-2 border-dashed p-4 transition-all",
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5 dark:bg-primary/10"
|
||||
: fieldError
|
||||
? "border-destructive/70"
|
||||
: "border-border bg-card/40 hover:border-primary/40",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{/* Click anywhere on the surface (except a file row) to browse */}
|
||||
{!reachedLimit && (
|
||||
<input
|
||||
type="file"
|
||||
ref={(el) => {
|
||||
if (fileInputRefs.current) {
|
||||
fileInputRefs.current[field.fileKey] = el;
|
||||
}
|
||||
}}
|
||||
multiple={field.isMultiple}
|
||||
multiple
|
||||
accept={acceptString}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e, field)}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Accepts:{" "}
|
||||
{field.allowedExtensions.join(", ").toUpperCase() ||
|
||||
"All"}
|
||||
</span>
|
||||
{existingForField.length > 0 && (
|
||||
<div className="flex flex-col gap-1 basis-full">
|
||||
{existingForField.map((f, idx) => (
|
||||
<ExistingFileLink
|
||||
key={`${f.url}-${idx}`}
|
||||
file={f}
|
||||
onViewFile={onViewFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isUploaded ? (
|
||||
// Uploaded state: a solid success panel that still doubles as a
|
||||
// replace target (click anywhere or drag a new file onto it).
|
||||
<div
|
||||
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
||||
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-4 rounded-lg border p-4 transition-all",
|
||||
isDragOver
|
||||
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
|
||||
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
|
||||
disabled &&
|
||||
"opacity-50 pointer-events-none cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple={field.isMultiple}
|
||||
accept={acceptString}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e, field)}
|
||||
id={`file-input-${field.fileKey}`}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label={`Replace ${field.fileLabel}`}
|
||||
className="absolute inset-0 z-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
||||
aria-label={`Add files to ${field.fileLabel}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400">
|
||||
{isDragOver ? (
|
||||
<UploadCloud className="h-5 w-5 animate-bounce" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{isDragOver ? "Drop to replace" : "Document uploaded"}
|
||||
</p>
|
||||
{existingForField.length > 0 ? (
|
||||
<div className="mt-0.5 flex flex-col gap-0.5">
|
||||
{existingForField.map((f, idx) => (
|
||||
{(existingForField.length > 0 || currentFiles.length > 0) && (
|
||||
<div className="relative z-10 flex flex-col gap-1.5">
|
||||
{/* Already-saved (server) files — view/download only */}
|
||||
{existingForField.map((f, idx) => (
|
||||
<div
|
||||
key={`${f.url}-${idx}`}
|
||||
className="flex items-center gap-2.5 rounded-lg bg-background/70 px-3 py-2 ring-1 ring-inset ring-border/60"
|
||||
>
|
||||
<FileIcon name={f.name} className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<ExistingFileLink
|
||||
key={`${f.url}-${idx}`}
|
||||
file={f}
|
||||
onViewFile={onViewFile}
|
||||
className="max-w-none font-medium text-foreground hover:text-primary"
|
||||
showSize
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||
Saved
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{isDragOver
|
||||
? "Release to replace the document on file."
|
||||
: "Saved to your application. Drag a new file here or click to replace it."}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{/* Just-added (in-memory) files */}
|
||||
{currentFiles.map((fileObj, idx) => (
|
||||
<div
|
||||
key={`${fileObj.name}-${idx}`}
|
||||
className="flex items-center gap-2.5 rounded-lg bg-background/70 px-3 py-2 ring-1 ring-inset ring-emerald-300/50 dark:ring-emerald-500/25 animate-in fade-in slide-in-from-top-1 duration-300"
|
||||
>
|
||||
<FileIcon
|
||||
name={fileObj.name}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="truncate text-xs font-medium text-foreground"
|
||||
title={fileObj.name}
|
||||
>
|
||||
{fileObj.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{formatBytes(fileObj.size)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-3 w-3" /> Ready
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => removeFile(field.fileKey, idx)}
|
||||
className={cn(
|
||||
"rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
aria-label={`Remove file ${fileObj.name}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`${field.fileKey}[]`}
|
||||
value={fileObj.name}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reachedLimit ? (
|
||||
<div className="relative z-10 flex items-center justify-center gap-1.5 py-2 text-xs font-medium text-muted-foreground">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||
Maximum of {maxFiles} files reached
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none relative z-10 flex flex-col items-center justify-center gap-1 text-center",
|
||||
existingForField.length > 0 || currentFiles.length > 0
|
||||
? "py-1"
|
||||
: "py-6",
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
Replace
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
||||
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
className={cn(
|
||||
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5 dark:bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/10",
|
||||
fieldError &&
|
||||
"border-destructive hover:border-destructive/80",
|
||||
disabled &&
|
||||
"opacity-50 pointer-events-none cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple={field.isMultiple}
|
||||
accept={acceptString}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e, field)}
|
||||
id={`file-input-${field.fileKey}`}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
|
||||
<UploadCloud
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-6 w-6 text-muted-foreground",
|
||||
isDragOver && "text-primary animate-bounce",
|
||||
"rounded-full bg-muted text-muted-foreground",
|
||||
existingForField.length > 0 || currentFiles.length > 0
|
||||
? "p-1.5"
|
||||
: "p-3",
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<UploadCloud
|
||||
className={cn(
|
||||
existingForField.length > 0 ||
|
||||
currentFiles.length > 0
|
||||
? "h-4 w-4"
|
||||
: "h-6 w-6",
|
||||
isDragOver && "animate-bounce text-primary",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{isDragOver
|
||||
? "Drop your files here"
|
||||
: existingForField.length > 0 ||
|
||||
currentFiles.length > 0
|
||||
? "Add more files, or "
|
||||
: "Drag & drop your files here, or "}
|
||||
{!isDragOver && (
|
||||
<span className="text-primary font-bold">browse</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{field.allowedExtensions.join(", ").toUpperCase() ||
|
||||
"All formats"}
|
||||
{" • "}
|
||||
{currentFiles.length}/{maxFiles} added
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Selected Files List */}
|
||||
{currentFiles.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{currentFiles.map((fileObj, idx) => (
|
||||
<NewFileCard
|
||||
key={`${fileObj.name}-${idx}`}
|
||||
file={fileObj}
|
||||
disabled={disabled}
|
||||
hasError={!!fieldError}
|
||||
inputName={
|
||||
field.isMultiple
|
||||
? `${field.fileKey}[]`
|
||||
: field.fileKey
|
||||
}
|
||||
onRemove={() => removeFile(field.fileKey, idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Drag & drop your file here, or{" "}
|
||||
<span className="text-primary font-bold hover:underline">
|
||||
browse
|
||||
</span>
|
||||
</p>
|
||||
{/* Dropzone area */}
|
||||
{!reachedLimit &&
|
||||
(variant === "minimal" ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
fileInputRefs.current[field.fileKey]?.click()
|
||||
}
|
||||
className="gap-1.5 cursor-pointer"
|
||||
>
|
||||
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
||||
<span>
|
||||
{isUploaded ? "Replace File" : "Upload File"}
|
||||
</span>
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={(el) => {
|
||||
if (fileInputRefs.current) {
|
||||
fileInputRefs.current[field.fileKey] = el;
|
||||
}
|
||||
}}
|
||||
multiple={field.isMultiple}
|
||||
accept={acceptString}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e, field)}
|
||||
className="hidden"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Accepts:{" "}
|
||||
{field.allowedExtensions.join(", ").toUpperCase() ||
|
||||
"All"}
|
||||
</span>
|
||||
{existingForField.length > 0 && (
|
||||
<div className="flex flex-col gap-1 basis-full">
|
||||
{existingForField.map((f, idx) => (
|
||||
<ExistingFileLink
|
||||
key={`${f.url}-${idx}`}
|
||||
file={f}
|
||||
onViewFile={onViewFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isUploaded ? (
|
||||
// Uploaded state: a solid success panel that still doubles as a
|
||||
// replace target (click anywhere or drag a new file onto it).
|
||||
<div
|
||||
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
||||
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-4 rounded-lg border p-4 transition-all",
|
||||
isDragOver
|
||||
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
|
||||
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
|
||||
disabled &&
|
||||
"opacity-50 pointer-events-none cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple={field.isMultiple}
|
||||
accept={acceptString}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e, field)}
|
||||
id={`file-input-${field.fileKey}`}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
aria-label={`Replace ${field.fileLabel}`}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Supported formats:{" "}
|
||||
{field.allowedExtensions.join(", ").toUpperCase() ||
|
||||
"All"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400">
|
||||
{isDragOver ? (
|
||||
<UploadCloud className="h-5 w-5 animate-bounce" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{isDragOver
|
||||
? "Drop to replace"
|
||||
: "Document uploaded"}
|
||||
</p>
|
||||
{existingForField.length > 0 ? (
|
||||
<div className="mt-0.5 flex flex-col gap-0.5">
|
||||
{existingForField.map((f, idx) => (
|
||||
<ExistingFileLink
|
||||
key={`${f.url}-${idx}`}
|
||||
file={f}
|
||||
onViewFile={onViewFile}
|
||||
showSize
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{isDragOver
|
||||
? "Release to replace the document on file."
|
||||
: "Saved to your application. Drag a new file here or click to replace it."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
Replace
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
|
||||
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
className={cn(
|
||||
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5 dark:bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/10",
|
||||
fieldError &&
|
||||
"border-destructive hover:border-destructive/80",
|
||||
disabled &&
|
||||
"opacity-50 pointer-events-none cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple={field.isMultiple}
|
||||
accept={acceptString}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e, field)}
|
||||
id={`file-input-${field.fileKey}`}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
|
||||
<UploadCloud
|
||||
className={cn(
|
||||
"h-6 w-6 text-muted-foreground",
|
||||
isDragOver && "text-primary animate-bounce",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
Drag & drop your file here, or{" "}
|
||||
<span className="text-primary font-bold hover:underline">
|
||||
browse
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Supported formats:{" "}
|
||||
{field.allowedExtensions.join(", ").toUpperCase() ||
|
||||
"All"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Validation Error Message */}
|
||||
{fieldError && (
|
||||
|
||||
Reference in New Issue
Block a user