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 { 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 { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service'; import { FreightMeService } from './freight-me.service';
@Module({ @Module({
controllers: [FreightMeController], imports: [TypeOrmModule.forFeature([User])],
providers: [FreightMeService], controllers: [FreightMeController, CheckAvailabilityController],
providers: [FreightMeService, CheckAvailabilityService],
}) })
export class FreightAuthModule {} export class FreightAuthModule {}

View File

@@ -1183,9 +1183,11 @@ export class CompaniesService {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin); const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) { if (!businessInfo) {
throw new BadRequestException( 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 { CompanyType, CompanyStatus } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -17,10 +17,7 @@ export class CreateCompanyDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Length(10, 10) @Length(10, 10, { message: 'TIN must be exactly 10 digits' })
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
tin!: string; tin!: string;
@IsOptional() @IsOptional()

View File

@@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
managerName!: string; managerName!: string;
managerEmail?: string; managerEmail?: string;
managerPhone!: string; managerPhone!: string;
tinTaken?: boolean;
constructor(data: CompanyRegistrationData) { constructor(data: CompanyRegistrationData) {
this.licenceNumber = data.licenceNumber; this.licenceNumber = data.licenceNumber;
@@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
this.managerName = data.managerName; this.managerName = data.managerName;
this.managerEmail = data.managerEmail; this.managerEmail = data.managerEmail;
this.managerPhone = data.managerPhone; 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 { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -34,10 +34,7 @@ export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@Length(10, 10) @Length(10, 10, { message: 'TIN must be exactly 10 digits' })
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
tin?: string; tin?: string;
@IsOptional() @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, Users,
Wallet, Wallet,
} from "lucide-react"; } from "lucide-react";
import { useEffect } from "react";
import { import {
Navigate, Navigate,
Outlet, Outlet,
@@ -135,17 +136,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <LayoutDashboard />, icon: <LayoutDashboard />,
}, },
{ {
label: "User Management", label: "Staff",
href: "/um", href: "/um",
icon: <Users />, icon: <Users />,
}, },
{ {
label: "Booking requests", label: "Bookings",
href: "/dashboard/booking-requests", href: "/dashboard/booking-requests",
icon: <FileText />, icon: <FileText />,
}, },
{ {
label: "Contract requests", label: "Contracts",
href: "/dashboard/contract-requests", href: "/dashboard/contract-requests",
icon: <FileSignature />, icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view, permission: FREIGHT_PERMS.contracts.view,
@@ -174,7 +175,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
title: "Operations", title: "Operations",
items: [ items: [
{ {
label: "Document Clearance", label: "Clearance",
href: "/dashboard/contracts/clearance", href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />, icon: <ShieldCheck />,
permission: [ permission: [
@@ -312,7 +313,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
title: "Port & Terminal", title: "Port & Terminal",
items: [ items: [
{ {
label: "Import Operations", label: "Imports",
href: "/dashboard/import-warehouse", href: "/dashboard/import-warehouse",
icon: <PackageOpen />, icon: <PackageOpen />,
children: [ children: [
@@ -344,7 +345,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
], ],
}, },
{ {
label: "Export Operations", label: "Exports",
href: "/dashboard/export-warehouse", href: "/dashboard/export-warehouse",
icon: <Truck />, icon: <Truck />,
children: [ children: [
@@ -516,6 +517,38 @@ const filterSidebarByPermission = (
.filter((section) => section.items.length > 0); .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 DashboardShell = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -541,6 +574,14 @@ const DashboardShell = () => {
: null : null
: 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)) { if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
return <Navigate to={glClearanceHome} replace />; 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"; } from "react";
import type { SidebarItem, SidebarSection } from "./types"; import type { SidebarItem, SidebarSection } from "./types";
import { Link } from "react-router-dom";
export interface FreightSidebarProps { export interface FreightSidebarProps {
sections: SidebarSection[]; sections: SidebarSection[];
@@ -35,15 +36,15 @@ const BRAND_LOGO = "/assets/logo.svg";
const navClassNames = (active: boolean) => const navClassNames = (active: boolean) =>
active active
? { ? {
root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", 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!", label: "text-edr-primary-dark! font-medium! text-sm!",
section: "text-edr-primary-dark!", section: "text-edr-primary-dark!",
} }
: { : {
root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", 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!", label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
section: "text-edr-text!", section: "text-edr-text!",
}; };
const itemKey = (parentKey: string, item: SidebarItem, index: number) => const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
`${parentKey}/${item.href ?? item.label}/${index}`; `${parentKey}/${item.href ?? item.label}/${index}`;
@@ -65,7 +66,9 @@ const FreightSidebar = ({
const isHrefActive = useCallback( const isHrefActive = useCallback(
(href: string) => { (href: string) => {
const normalized = href.toLowerCase(); const normalized = href.toLowerCase();
return activePath === normalized || activePath.startsWith(`${normalized}/`); return (
activePath === normalized || activePath.startsWith(`${normalized}/`)
);
}, },
[activePath], [activePath],
); );
@@ -109,9 +112,7 @@ const FreightSidebar = ({
if (hasChildren) { if (hasChildren) {
const isLink = !!item.href; const isLink = !!item.href;
const active = const active = isLink ? isHrefActive(item.href!) : false;
(isLink ? isHrefActive(item.href!) : false) ||
branchActive(item.children!);
const isOpen = openMap[key] ?? false; const isOpen = openMap[key] ?? false;
return ( return (
@@ -124,7 +125,7 @@ const FreightSidebar = ({
active={active} active={active}
opened={isOpen} opened={isOpen}
classNames={navClassNames(active)} classNames={navClassNames(active)}
onClick={ () => toggle(key)} onClick={() => toggle(key)}
rightSection={ rightSection={
<Box <Box
component="span" component="span"
@@ -140,7 +141,9 @@ const FreightSidebar = ({
<ChevronDown <ChevronDown
size={16} size={16}
className="text-edr-muted transition-transform duration-200" className="text-edr-muted transition-transform duration-200"
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }} style={{
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
}}
/> />
</Box> </Box>
} }
@@ -161,8 +164,9 @@ const FreightSidebar = ({
label={item.label} label={item.label}
leftSection={item.icon} leftSection={item.icon}
active={active} active={active}
component={Link}
classNames={navClassNames(active)} classNames={navClassNames(active)}
onClick={() => onNavigate?.(item.href!)} to={item.href!}
/> />
); );
}, },
@@ -178,7 +182,7 @@ const FreightSidebar = ({
tt="uppercase" tt="uppercase"
px="sm" px="sm"
mb={6} mb={6}
className={ "text-edr-muted!" } className={"text-edr-muted!"}
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }} style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
> >
{section.title} {section.title}
@@ -232,14 +236,24 @@ const FreightSidebar = ({
</Box> </Box>
</Group> </Group>
{onClose && ( {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} /> <X size={18} className="text-edr-muted" strokeWidth={1.8} />
</UnstyledButton> </UnstyledButton>
)} )}
</Box> </Box>
{/* Nav */} {/* 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> <Stack gap="lg">{renderedSections}</Stack>
</AppShell.Section> </AppShell.Section>
</AppShell.Navbar> </AppShell.Navbar>

View File

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

View File

@@ -1,162 +1,40 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { import {
Eye, Alert,
EyeOff, Box,
ArrowUpRight, Button,
Globe, Center,
ChevronDown, Group,
} from "lucide-react"; Image,
PasswordInput,
PinInput,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth"; 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. */ /** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
const normaliseIdentifier = (raw: string): string => { const normaliseIdentifier = (raw: string): string => {
const v = raw.trim(); const v = raw.trim();
const digits = v.replace(/\D/g, ""); const digits = v.replace(/\D/g, "");
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { 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 `+251${local}`;
} }
return v.toLowerCase(); return v.toLowerCase();
}; };
const LOGIN_IMAGE = "/assets/login.png";
const EDR_LOGO = "/assets/logo.svg"; 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 LoginPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { login, verifyMfa } = useAuth(); const { login, verifyMfa } = useAuth();
@@ -165,7 +43,6 @@ const LoginPage = () => {
const [otp, setOtp] = useState(""); const [otp, setOtp] = useState("");
const [needsMfa, setNeedsMfa] = useState(false); const [needsMfa, setNeedsMfa] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [normalizedIdentifier, setNormalizedIdentifier] = useState(""); const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -179,15 +56,14 @@ const LoginPage = () => {
setNormalizedIdentifier(normalized); setNormalizedIdentifier(normalized);
const result = await login({ email: normalized, password }); const result = await login({ email: normalized, password });
console.log(result);
if (result.mfaRequired) { if (result.mfaRequired) {
setNeedsMfa(true); setNeedsMfa(true);
return; return;
} }
// navigate("/dashboard/overview", { replace: true }); // navigate("/dashboard/overview", { replace: true });
} catch { } catch (err) {
setError("Unable to sign in with those credentials."); setError(extractApiError(err).message);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -201,194 +77,132 @@ const LoginPage = () => {
try { try {
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() }); await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
navigate("/dashboard/overview", { replace: true }); navigate("/dashboard/overview", { replace: true });
} catch { } catch (err) {
setError("Unable to verify the one-time code."); setError(extractApiError(err).message);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const loginForm = ( const loginForm = (
<form className="flex w-full flex-col" onSubmit={handleSubmit}> <Box component="form" onSubmit={handleSubmit}>
<div className="mb-4 flex justify-center sm:mb-6"> <Center mb={{ base: "md", sm: "lg" }}>
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" /> <Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
</div> </Center>
<div className="mb-4 space-y-1.5 text-center sm:mb-5"> <Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl"> <Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
Get Started Welcome back!
</h1> </Title>
<p className="text-sm leading-relaxed text-gray-500"> <Text size="sm" c="dimmed">
Log in to access the freight backoffice & explore all logistics Log in to access the freight backoffice & explore all logistics
resources. resources.
</p> </Text>
</div> </Stack>
<div className="flex w-full flex-col gap-4"> <Stack gap="md">
<div className="space-y-1.5"> <TextInput
<label className="text-sm font-medium text-gray-800"> label="Email or Phone"
Email or Phone <span className="text-red-500">*</span> placeholder="name@company.com or 09XXXXXXXX"
</label> autoComplete="username"
<input required
type="text" disabled={submitting}
value={identifier} value={identifier}
onChange={(event) => setIdentifier(event.target.value)} onChange={(event) => setIdentifier(event.target.value)}
placeholder="name@company.com or 09XXXXXXXX" />
autoComplete="username"
className={fieldClass}
/>
</div>
<div className="space-y-1.5"> <PasswordInput
<label className="text-sm font-medium text-gray-800"> label="Password"
Password <span className="text-red-500">*</span> placeholder="Enter your password"
</label> required
<div className="relative"> disabled={submitting}
<input value={password}
type={showPassword ? "text" : "password"} onChange={(event) => setPassword(event.target.value)}
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>
{error ? ( {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} {error}
</div> </Alert>
) : null} ) : null}
<button <Button type="submit" color="edr-green" fullWidth loading={submitting}>
type="submit" Sign In
disabled={submitting} </Button>
className={primaryButtonClass} </Stack>
> </Box>
{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>
); );
const mfaForm = ( const mfaForm = (
<form className="flex w-full flex-col" onSubmit={handleVerifyMfa}> <Box component="form" onSubmit={handleVerifyMfa}>
<div className="mb-4 flex justify-center sm:mb-6"> <Center mb={{ base: "md", sm: "lg" }}>
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" /> <Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
</div> </Center>
<div className="mb-4 space-y-1.5 text-center sm:mb-5"> <Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl"> <Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
Multi-factor verification Multi-factor verification
</h1> </Title>
<p className="text-sm leading-relaxed text-gray-500"> <Text size="sm" c="dimmed">
We sent a verification code to{" "} We sent a verification code to{" "}
<span className="font-medium text-gray-700"> <Text component="span" fw={500} c="var(--mantine-color-text)">
{normalizedIdentifier} {normalizedIdentifier}
</span> </Text>
. Enter it below to complete sign in. . Enter it below to complete sign in.
</p> </Text>
</div> </Stack>
<div className="flex w-full flex-col gap-4"> <Stack gap="md">
<div className="space-y-1.5"> <Stack gap={6} align="center">
<label className="text-sm font-medium text-gray-800"> <Text size="sm" fw={500} c="edr-text">
Verification code <span className="text-red-500">*</span> Verification code
</label> </Text>
<input <PinInput
length={6}
type="number"
oneTimeCode
value={otp} value={otp}
onChange={(event) => setOtp(event.target.value)} placeholder="0"
placeholder="Enter the code" disabled={submitting}
className={fieldClass} styles={{ input: { textAlign: "center" } }}
onChange={setOtp}
/> />
</div> </Stack>
{error ? ( {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} {error}
</div> </Alert>
) : null} ) : null}
<div className="flex w-full gap-3"> <Group grow>
<button <Button
type="button" type="button"
variant="default"
leftSection={<ArrowLeft size={14} />}
disabled={submitting}
onClick={() => { onClick={() => {
setNeedsMfa(false); setNeedsMfa(false);
setOtp(""); setOtp("");
setError(null); 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 Back
</button> </Button>
<button <Button
type="submit" type="submit"
disabled={submitting} color="edr-green"
className={`${primaryButtonClass} min-w-0 flex-1`} loading={submitting}
disabled={otp.trim().length !== 6}
> >
{submitting ? "Verifying..." : "Verify"} Verify
</button> </Button>
</div> </Group>
</div> </Stack>
</form> </Box>
); );
return ( return <AuthShell>{!needsMfa ? loginForm : mfaForm}</AuthShell>;
<>
<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>
</>
);
}; };
export default LoginPage; 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 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"; 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 = 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"; "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 = 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"; "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 = () => ( const RightPanelDecor = () => (
<div <div
className="pointer-events-none absolute inset-0 overflow-hidden" className="pointer-events-none absolute inset-0 overflow-hidden"
@@ -63,7 +48,7 @@ const RightPanelDecor = () => (
export interface AuthShellProps { export interface AuthShellProps {
children: ReactNode; 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; tagline?: string;
taglineBody?: string; taglineBody?: string;
} }
@@ -72,45 +57,63 @@ const LeftPanel = ({
tagline, tagline,
taglineBody, taglineBody,
}: Pick<AuthShellProps, "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]"> <Box
<img 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]"
src={LOGIN_IMAGE} style={{ background: LEFT_PANEL_BG }}
alt="Ethio Djibouti Railway" >
className="absolute inset-0 h-auto w-full object-cover object-center" {/* Top-left: logo, title, description — stacked, left aligned. */}
/> <Stack
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/15 to-[#1a5c34]/45" /> gap="xl"
<LeftPanelDecor /> className="relative z-10 p-8 lg:p-10"
style={{ maxWidth: 520 }}
<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 <Image
src={EDR_LOGO} src={EDR_LOGO}
alt="EDR Freight" 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"> <Stack gap="sm">
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md"> <Title
<div className="mb-2 flex items-center gap-2"> order={1}
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" /> c="white"
<span className="text-sm font-semibold text-white"> fz={38}
{tagline ?? "Empower Your Freight Operations"} fw={800}
</span> lh={1.08}
</div> style={{ letterSpacing: "-0.02em" }}
<p className="text-sm leading-relaxed text-white/85"> >
{tagline ?? "Ethiopian Djibouti Railway"}
</Title>
<Text fz="md" lh={1.6} c="rgba(255,255,255,0.82)" maw={440}>
{taglineBody ?? {taglineBody ??
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."} "Sign in to book shipments, track cargo, and manage your freight on the EthioDjibouti Railway platform."}
</p> </Text>
</div> </Stack>
</div> </Stack>
</div>
{/* 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 = () => ( 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"> <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> <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"> <div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
<a <Link
href="#" to="#"
className="font-semibold text-gray-700 transition-colors hover:text-primary" className="font-semibold text-gray-700 transition-colors hover:text-primary"
> >
Terms &amp; Conditions Terms &amp; Conditions
</a> </Link>
<a <Link
href="#" to="#"
className="font-semibold text-gray-700 transition-colors hover:text-primary" className="font-semibold text-gray-700 transition-colors hover:text-primary"
> >
Privacy Policy Privacy Policy
</a> </Link>
<a <Link
href="#" to="#"
className="font-semibold text-gray-700 transition-colors hover:text-primary" className="font-semibold text-gray-700 transition-colors hover:text-primary"
> >
Help &amp; Support Help &amp; Support
</a> </Link>
</div> </div>
</div> </div>
); );
@@ -169,8 +172,8 @@ export default function AuthShell({
</div> </div>
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain"> <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="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"> <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} {children}
</div> </div>
</div> </div>

View File

@@ -7,9 +7,11 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { useEffect, useRef } from "react";
import type { UseFormRegisterReturn } from "react-hook-form"; 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 { useETradeData } from "@/hooks/useETradeData";
import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
interface ETradeInfoProps { interface ETradeInfoProps {
@@ -22,6 +24,8 @@ interface ETradeInfoProps {
onDataLoaded: (data: CompanyRegistrationData) => void; onDataLoaded: (data: CompanyRegistrationData) => void;
} }
const isValidTin = (tin: string) => tin.length === 10;
export default function ETradeInfo({ export default function ETradeInfo({
tin, tin,
register, register,
@@ -30,53 +34,100 @@ export default function ETradeInfo({
}: ETradeInfoProps) { }: ETradeInfoProps) {
const mutation = useETradeData(); const mutation = useETradeData();
const isLoading = mutation.isPending; 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 () => { const handleFetch = async () => {
if (!tin || tin.length !== 10 || !tin.startsWith("00")) return; if (!isValidTin(tin)) return;
const result = await mutation.mutateAsync(tin); const result = await mutation.mutateAsync(tin);
if (result) { if (result && !result.tinTaken) {
onDataLoaded(result); 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.isError && mutation.error
? (mutation.error as any).message || ? extractApiError(mutation.error)
"Failed to fetch company information. Please try again." : 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; : null;
return ( return (
<Stack gap="md"> <Stack gap="md">
<Group align="flex-start" grow> <Group align="flex-start" grow>
<TextInput <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" placeholder="0012345678"
maxLength={10} maxLength={10}
error={error} error={error}
{...register} {...register}
/> />
<Button {errorMessage && (
variant="filled" <Button
color="edr-green" variant="filled"
onClick={handleFetch} color="edr-green"
disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading} onClick={handleFetch}
leftSection={ disabled={!isValidTin(tin) || isLoading}
isLoading ? <Loader size={16} /> : <Download size={16} /> leftSection={
} isLoading ? <Loader size={16} /> : <Download size={16} />
mt="24px" }
> mt="24px"
{isLoading ? "Getting..." : "Get Data"} >
</Button> {isLoading ? "Getting..." : "Get Data"}
</Button>
)}
</Group> </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 && ( {errorMessage && (
<Alert <Alert
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}
color="red" 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> </Alert>
)} )}

View File

@@ -70,6 +70,8 @@ interface RoleLicenseStepProps {
/** Newly-selected files per profile id (not yet uploaded). */ /** Newly-selected files per profile id (not yet uploaded). */
value: Record<string, File[]>; value: Record<string, File[]>;
onChange: (value: Record<string, File[]>) => void; 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, profiles,
value, value,
onChange, onChange,
errors,
}: RoleLicenseStepProps) { }: RoleLicenseStepProps) {
const setFiles = (profileId: string, files: File[]) => { const setFiles = (profileId: string, files: File[]) => {
onChange({ ...value, [profileId]: files }); onChange({ ...value, [profileId]: files });
@@ -123,6 +126,11 @@ export default function RoleLicenseStep({
file={buildLicenseSetting(profile.id, label)} file={buildLicenseSetting(profile.id, label)}
value={{ [LICENSE_FILE_KEY]: selected }} value={{ [LICENSE_FILE_KEY]: selected }}
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined} uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
errors={
errors?.[profile.id]
? { [LICENSE_FILE_KEY]: errors[profile.id] }
: undefined
}
onChange={(v) => { onChange={(v) => {
const next = v[LICENSE_FILE_KEY]; const next = v[LICENSE_FILE_KEY];
const files = Array.isArray(next) ? next : next ? [next] : []; const files = Array.isArray(next) ? next : next ? [next] : [];

View File

@@ -14,6 +14,7 @@ export const URL_CONSTANTS = {
SET_PASSWORD: "/api/auth/set-password", SET_PASSWORD: "/api/auth/set-password",
ME: "/api/auth/me", ME: "/api/auth/me",
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code", GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
CHECK_AVAILABILITY: "/api/auth/check-availability",
}, },
OTP: { OTP: {

View File

@@ -11,7 +11,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query"; 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 { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common"; import { SmartFileInput } from "@edr/ui-common";
import { getMinFiles } from "@/types/fileUploadSettings";
import { api } from "@/services/api"; import { api } from "@/services/api";
import RoleLicenseStep, { import RoleLicenseStep, {
type RoleLicenseProfile, 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 // "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 // source step and disables them (kept mirrored while linked); unchecking clears
// them and re-enables editing. // them and re-enables editing.
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
const [contactSameAsGm, setContactSameAsGm] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false);
const [poaSameAsContact, setPoaSameAsContact] = 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 gmName = watch("generalManagerName");
const gmEmail = watch("generalManagerEmail"); const gmEmail = watch("generalManagerEmail");
const gmPhone = watch("generalManagerPhone"); const gmPhone = watch("generalManagerPhone");
@@ -341,6 +359,72 @@ export default function CompanyProfileForm({
const hasDocuments = Boolean(uploadSetting?.fields?.length); 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 // 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 // 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: // (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 () => { const nextStep = async () => {
userNavigatedRef.current = true; userNavigatedRef.current = true;
// The documents step auto-uploads whatever the user selected as they // The documents step hard-blocks on required company documents and a
// continue (partial uploads are allowed — required-doc completeness is // business license per operational profile before it auto-uploads and
// re-checked on resume). A failed upload holds them on the step. // submits — no partial-completion path forward.
if (step === "documents") { 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) { if (onUploadDocuments) {
setSaving(true); setSaving(true);
try { 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); setSaveError(null);
handleSubmit((data) => onSubmit(buildPayload(data, user)))(); handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return; return;
@@ -450,8 +531,6 @@ export default function CompanyProfileForm({
onDataLoaded={handleETradeDataLoaded} onDataLoaded={handleETradeDataLoaded}
/> />
<Divider my="sm" />
<TextInput <TextInput
label="Company Name" label="Company Name"
placeholder="Global Logistics Ltd" placeholder="Global Logistics Ltd"
@@ -507,7 +586,7 @@ export default function CompanyProfileForm({
from eTrade · read-only from eTrade · read-only
</Text> </Text>
</Group> </Group>
<SimpleGrid cols={2} spacing="md"> <SimpleGrid cols={2} spacing="sm">
<ReadOnlyField <ReadOnlyField
label="License Number" label="License Number"
value={watch("licenceNumber")} value={watch("licenceNumber")}
@@ -586,22 +665,19 @@ export default function CompanyProfileForm({
{step === "personnel" && ( {step === "personnel" && (
<> <>
<Group justify="space-between" align="center"> <Text fw={600} size="sm" c="edr-text">
<Text fw={600} size="sm" c="edr-text"> General Manager
General Manager </Text>
</Text> <LinkCheckboxCard
{etradeOwner && ( checked={gmSameAsOwner}
<Button onToggle={toggleGmSameAsOwner}
variant="light" title="Same as business owner"
color="edr-green" description={
size="xs" etradeOwner
leftSection={<UserCheck size={14} />} ? "Reuse the eTrade-registered owner's name and phone (email from your account). Uncheck to enter different details."
onClick={useOwnerAsManager} : "Reuse your account's name, email and phone. Uncheck to enter different details."
> }
Use owner as manager />
</Button>
)}
</Group>
<TextInput <TextInput
label="Name" label="Name"
placeholder="Abebe Bikila" placeholder="Abebe Bikila"
@@ -737,15 +813,17 @@ export default function CompanyProfileForm({
file={uploadSetting} file={uploadSetting}
value={documentFiles} value={documentFiles}
uploadedKeys={uploadedDocumentKeys} uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
containerClassName="lg:grid grid-cols-2 items-stretch" containerClassName="lg:grid grid-cols-2 items-stretch"
onChange={setDocumentFiles} onChange={handleDocumentFilesChange}
/> />
)} )}
<RoleLicenseStep <RoleLicenseStep
profiles={roleProfiles ?? []} profiles={roleProfiles ?? []}
value={licenseFiles ?? {}} value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => { })} onChange={handleLicenseFilesChange}
errors={licenseFieldErrors}
/> />
</> </>
)} )}

View File

@@ -1,9 +1,11 @@
import { type FormEvent, useState } from "react"; import { type FormEvent, useState } from "react";
import { Eye, EyeOff } from "lucide-react"; import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { useLocation, useNavigate } from "react-router-dom"; import { AlertCircle } from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import useAuth from "@/hooks/useAuth"; 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"; const EDR_LOGO = "/assets/edr-logo.png";
@@ -24,7 +26,6 @@ export default function LoginPage() {
const { login } = useAuth(); const { login } = useAuth();
const [identifier, setIdentifier] = useState(""); const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -41,8 +42,8 @@ export default function LoginPage() {
} else { } else {
setError(result.error.message); setError(result.error.message);
} }
} catch { } catch (err) {
setError("An unexpected error occurred"); setError(extractApiError(err).message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -64,60 +65,45 @@ export default function LoginPage() {
</p> </p>
</div> </div>
<div className="flex w-full flex-col gap-4"> <Stack gap="md">
<div className="space-y-1.5"> <TextInput
<label className="text-sm font-medium text-gray-800"> label="Email or Phone"
Email or Phone <span className="text-red-500">*</span> placeholder="name@company.com or 09XXXXXXXX"
</label> autoComplete="username"
<input required
type="text" disabled={loading}
value={identifier} value={identifier}
onChange={(event) => setIdentifier(event.target.value)} onChange={(event) => setIdentifier(event.target.value)}
placeholder="name@company.com or 09XXXXXXXX" />
<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} disabled={loading}
autoComplete="username" value={password}
className={fieldClass} onChange={(event) => setPassword(event.target.value)}
/> />
</div> </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 ? ( {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} {error}
</div> </Alert>
) : null} ) : null}
<button type="submit" disabled={loading} className={primaryButtonClass}> <Button type="submit" color="edr-green" fullWidth loading={loading}>
{loading ? "Signing in..." : "Sign In"} Sign In
</button> </Button>
<p className="text-center text-sm text-gray-500"> <p className="text-center text-sm text-gray-500">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
@@ -129,7 +115,7 @@ export default function LoginPage() {
Create an account Create an account
</button> </button>
</p> </p>
</div> </Stack>
</form> </form>
</AuthShell> </AuthShell>
); );

View File

@@ -41,7 +41,10 @@ const passwordRequirements = [
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
{ label: "One lowercase 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 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; ] as const;
const userSchema = z const userSchema = z
@@ -52,8 +55,14 @@ const userSchema = z
.min(1, "Phone number is required") .min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"), .refine(isValidPhone, "Enter a valid phone number"),
userType: z.string(), userType: z.string(),
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), firstName: z.object({
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), 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 password: z
.string() .string()
.min(8, "Password must be at least 8 characters") .min(8, "Password must be at least 8 characters")
@@ -132,12 +141,30 @@ export default function SignupPage() {
const passwordValue = watch("password") ?? ""; const passwordValue = watch("password") ?? "";
// Step 1 — form is valid: send a fresh code to the chosen channel, then // Step 1 — form is valid: make sure the email/phone aren't already
// move to the OTP challenge. // registered, then send a fresh code to the chosen channel and move to
// the OTP challenge.
const requestOtp = async (data: FormData) => { const requestOtp = async (data: FormData) => {
setError(null); setError(null);
setSending(true); setSending(true);
try { 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( await api.auth.sendOTP.call(
channel === "email" ? { email: data.email } : { phone: data.phone }, 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." 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="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" ? ( {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"> <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"> <h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Create account Create account
@@ -236,7 +262,7 @@ export default function SignupPage() {
</p> </p>
</div> </div>
<Stack gap="md"> <Stack gap="sm">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<TextInput <TextInput
label="First name" label="First name"
@@ -318,15 +344,25 @@ export default function SignupPage() {
{passwordRequirements.map((req) => { {passwordRequirements.map((req) => {
const met = req.test(passwordValue); const met = req.test(passwordValue);
return ( return (
<div key={req.label} className="flex items-center gap-2"> <div
key={req.label}
className="flex items-center gap-2"
>
<span <span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${ className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500" ? "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>
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}> <span
className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}
>
{req.label} {req.label}
</span> </span>
</div> </div>
@@ -346,7 +382,11 @@ export default function SignupPage() {
/> />
{error ? ( {error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}> <Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{error} {error}
</Alert> </Alert>
) : null} ) : null}
@@ -396,7 +436,11 @@ export default function SignupPage() {
</div> </div>
{otpError ? ( {otpError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}> <Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
>
{otpError} {otpError}
</Alert> </Alert>
) : null} ) : null}

View File

@@ -66,6 +66,8 @@ import type {
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { import type {
AuthUser, AuthUser,
CheckAvailabilityPayload,
CheckAvailabilityResponse,
GenerateVerificationCodePayload, GenerateVerificationCodePayload,
LoginPayload, LoginPayload,
LoginResponse, LoginResponse,
@@ -107,6 +109,11 @@ export const api = {
"setPassword", "setPassword",
authService.setPassword, authService.setPassword,
), ),
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
"auth",
"checkAvailability",
authService.checkAvailability,
),
sendOTP: endpoint<OtpPayload, OtpResponse>( sendOTP: endpoint<OtpPayload, OtpResponse>(
"auth", "auth",
"sendOTP", "sendOTP",

View File

@@ -1,14 +1,16 @@
import { URL_CONSTANTS } from "@/constants/URLS"; import { URL_CONSTANTS } from "@/constants/URLS";
import type { import type {
AuthUser, AuthUser,
GenerateVerificationCodePayload, CheckAvailabilityPayload,
LoginPayload, CheckAvailabilityResponse,
LoginResponse, GenerateVerificationCodePayload,
OtpPayload, LoginPayload,
OtpResponse, LoginResponse,
SetPasswordPayload, OtpPayload,
SignupPayload, OtpResponse,
SignupResponse, SetPasswordPayload,
SignupPayload,
SignupResponse,
} from "@/types/auth"; } from "@/types/auth";
import { client } from "@/utils/api"; import { client } from "@/utils/api";
import { ApiResponse } from "@edr/types"; import { ApiResponse } from "@edr/types";
@@ -23,7 +25,7 @@ export const authService = {
}, },
createUser: async (body: SignupPayload) => { 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, URL_CONSTANTS.USERS.SIGN_UP,
body, body,
); );
@@ -31,9 +33,7 @@ export const authService = {
}, },
getMyInfo: async () => { getMyInfo: async () => {
const res = await client.get<AuthUser>( const res = await client.get<AuthUser>(URL_CONSTANTS.USERS.ME);
URL_CONSTANTS.USERS.ME,
);
return res.data; return res.data;
}, },
@@ -53,6 +53,14 @@ export const authService = {
return res.data.data; 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) => { sendOTP: async (body: OtpPayload) => {
const res = await client.post<ApiResponse<OtpResponse>>( const res = await client.post<ApiResponse<OtpResponse>>(
URL_CONSTANTS.OTP.SEND, URL_CONSTANTS.OTP.SEND,

View File

@@ -45,6 +45,16 @@ export interface OtpResponse {
message: string; message: string;
} }
export interface CheckAvailabilityPayload {
email?: string;
phone?: string;
}
export interface CheckAvailabilityResponse {
emailTaken: boolean;
phoneTaken: boolean;
}
export interface SetPasswordPayload { export interface SetPasswordPayload {
newPassword: string; newPassword: string;
confirmPassword: string; confirmPassword: string;

View File

@@ -76,4 +76,6 @@ export interface CompanyRegistrationData {
managerName: string; managerName: string;
managerEmail?: string; managerEmail?: string;
managerPhone: 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({ export function SmartFileInput({
file, file,
value, value,
@@ -407,228 +477,352 @@ export function SmartFileInput({
</p> </p>
)} )}
{/* Selected Files List */} {/*
{currentFiles.length > 0 && ( Multiple-file fields (default variant) render as ONE integrated
<div className="flex flex-col gap-2"> drag-and-drop surface. Uploaded files live INSIDE the dropzone as
{currentFiles.map((fileObj, idx) => ( lightweight rows — part of the surface, not separate cards — with
<div the "add more" prompt on the same surface below them. A full-cover
key={`${fileObj.name}-${idx}`} transparent input makes clicking anywhere (outside a file row)
className={cn( open the picker; the prompt is pointer-transparent so clicks fall
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs", through to it, while file rows and their controls sit above it.
fieldError ? "border-destructive/30" : "border-border", */}
)} {variant === "default" && field.isMultiple ? (
> <div
<div className="flex items-center gap-3 min-w-0"> onDragOver={(e) => handleDrag(e, field.fileKey, true)}
<div className="p-2 bg-muted rounded-md flex items-center justify-center"> onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
<FileIcon name={fileObj.name} className="h-5 w-5" /> onDrop={(e) => handleDrop(e, field)}
</div> className={cn(
"relative flex flex-col gap-2.5 rounded-xl border-2 border-dashed p-4 transition-all",
<div className="min-w-0"> isDragOver
<p ? "border-primary bg-primary/5 dark:bg-primary/10"
className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" : fieldError
title={fileObj.name} ? "border-destructive/70"
> : "border-border bg-card/40 hover:border-primary/40",
{fileObj.name} disabled && "pointer-events-none opacity-50",
</p> )}
<div className="flex items-center gap-2 mt-0.5"> >
<span className="text-xs text-muted-foreground"> {/* Click anywhere on the surface (except a file row) to browse */}
{formatBytes(fileObj.size)} {!reachedLimit && (
</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>
<input <input
type="file" type="file"
ref={(el) => { multiple
if (fileInputRefs.current) {
fileInputRefs.current[field.fileKey] = el;
}
}}
multiple={field.isMultiple}
accept={acceptString} accept={acceptString}
disabled={disabled} disabled={disabled}
onChange={(e) => handleFileSelect(e, field)} onChange={(e) => handleFileSelect(e, field)}
className="hidden" 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}`}
<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}`}
/> />
)}
<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"> {(existingForField.length > 0 || currentFiles.length > 0) && (
{isDragOver ? ( <div className="relative z-10 flex flex-col gap-1.5">
<UploadCloud className="h-5 w-5 animate-bounce" /> {/* Already-saved (server) files — view/download only */}
) : ( {existingForField.map((f, idx) => (
<CheckCircle2 className="h-5 w-5" /> <div
)} key={`${f.url}-${idx}`}
</div> className="flex items-center gap-2.5 rounded-lg bg-background/70 px-3 py-2 ring-1 ring-inset ring-border/60"
>
<div className="min-w-0 flex-1"> <FileIcon name={f.name} className="h-4 w-4 shrink-0" />
<p className="text-sm font-semibold text-foreground"> <div className="min-w-0 flex-1">
{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 <ExistingFileLink
key={`${f.url}-${idx}`}
file={f} file={f}
onViewFile={onViewFile} onViewFile={onViewFile}
className="max-w-none font-medium text-foreground hover:text-primary"
showSize showSize
/> />
))} </div>
<span className="shrink-0 text-[11px] text-muted-foreground">
Saved
</span>
</div> </div>
) : ( ))}
<p className="mt-0.5 text-xs text-muted-foreground">
{isDragOver {/* Just-added (in-memory) files */}
? "Release to replace the document on file." {currentFiles.map((fileObj, idx) => (
: "Saved to your application. Drag a new file here or click to replace it."} <div
</p> 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> >
<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( className={cn(
"h-6 w-6 text-muted-foreground", "rounded-full bg-muted text-muted-foreground",
isDragOver && "text-primary animate-bounce", 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>
)}
</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"> {/* Dropzone area */}
Drag & drop your file here, or{" "} {!reachedLimit &&
<span className="text-primary font-bold hover:underline"> (variant === "minimal" ? (
browse <div className="flex flex-wrap items-center gap-3">
</span> <Button
</p> 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"> <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">
Supported formats:{" "} {isDragOver ? (
{field.allowedExtensions.join(", ").toUpperCase() || <UploadCloud className="h-5 w-5 animate-bounce" />
"All"} ) : (
</p> <CheckCircle2 className="h-5 w-5" />
</div> )}
))} </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 */} {/* Validation Error Message */}
{fieldError && ( {fieldError && (