Merge pull request #462 from Tria-plc/freight/feat/fixes-v1

Freight/feat/fixes v1
This commit is contained in:
Nathnael Wondisha
2026-07-04 12:35:51 +03:00
committed by GitHub
28 changed files with 1239 additions and 758 deletions

View File

@@ -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 });
}
}

View File

@@ -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,
};
}
}

View File

@@ -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 {}

View File

@@ -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 };
}
}

View File

@@ -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()

View File

@@ -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;
}
}

View File

@@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

View File

@@ -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 />;
}

View File

@@ -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 EthioDjibouti 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>
);
}

View File

@@ -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>

View File

@@ -27,7 +27,6 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

View File

@@ -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 EthioDjibouti 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 &amp; 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 &amp; 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>

View File

@@ -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>
)}

View File

@@ -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] : [];

View File

@@ -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: {

View File

@@ -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}
/>
</>
)}

View File

@@ -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&apos;t have an account?{" "}
@@ -129,7 +115,7 @@ export default function LoginPage() {
Create an account
</button>
</p>
</div>
</Stack>
</form>
</AuthShell>
);

View File

@@ -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}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 && (