mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge pull request #462 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
import { Controller, Get, Query } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { Public } from "@edr/api-common";
|
||||||
|
|
||||||
|
import { CheckAvailabilityService } from "./check-availability.service";
|
||||||
|
|
||||||
|
@ApiTags("auth")
|
||||||
|
@Controller("auth")
|
||||||
|
@Public()
|
||||||
|
export class CheckAvailabilityController {
|
||||||
|
constructor(
|
||||||
|
private readonly checkAvailabilityService: CheckAvailabilityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get("check-availability")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Check whether an email and/or phone number is already registered",
|
||||||
|
})
|
||||||
|
check(@Query("email") email?: string, @Query("phone") phone?: string) {
|
||||||
|
return this.checkAvailabilityService.check({ email, phone });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
|
export interface CheckAvailabilityQuery {
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckAvailabilityResult {
|
||||||
|
emailTaken: boolean;
|
||||||
|
phoneTaken: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CheckAvailabilityService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async check({
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
}: CheckAvailabilityQuery): Promise<CheckAvailabilityResult> {
|
||||||
|
if (!email && !phone) {
|
||||||
|
throw new BadRequestException("email or phone is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await this.userRepository.find({
|
||||||
|
where: [
|
||||||
|
...(email ? [{ email }] : []),
|
||||||
|
...(phone ? [{ phoneNumber: phone }] : []),
|
||||||
|
],
|
||||||
|
select: { id: true, email: true, phoneNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
emailTaken: email ? matches.some((user) => user.email === email) : false,
|
||||||
|
phoneTaken: phone
|
||||||
|
? matches.some((user) => user.phoneNumber === phone)
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { 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 {}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.jpg
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.png
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 KiB |
@@ -23,6 +23,7 @@ import {
|
|||||||
Users,
|
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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Box, Image, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { ChevronDown, Globe } from "lucide-react";
|
||||||
|
|
||||||
|
const EDR_IMAGE = "/assets/edr_image.png";
|
||||||
|
const EDR_LOGO = "/assets/logo.svg";
|
||||||
|
|
||||||
|
/** Muted deep-green brand wash for the left panel. */
|
||||||
|
const LEFT_PANEL_BG =
|
||||||
|
"linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)";
|
||||||
|
|
||||||
|
/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */
|
||||||
|
const IMAGE_FADE_MASK =
|
||||||
|
"linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)";
|
||||||
|
|
||||||
|
export interface AuthShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Headline shown in the top-left of the green panel. */
|
||||||
|
tagline?: string;
|
||||||
|
taglineBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeftPanel = ({
|
||||||
|
tagline,
|
||||||
|
taglineBody,
|
||||||
|
}: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||||
|
<Box
|
||||||
|
className="relative hidden shrink-0 overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.12)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]"
|
||||||
|
style={{ background: LEFT_PANEL_BG }}
|
||||||
|
>
|
||||||
|
{/* Top-left: logo, title, description — stacked, left aligned. */}
|
||||||
|
<Stack
|
||||||
|
gap="xl"
|
||||||
|
className="relative z-10 p-8 lg:p-10"
|
||||||
|
style={{ maxWidth: 520 }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={EDR_LOGO}
|
||||||
|
alt="EDR Freight"
|
||||||
|
h={40}
|
||||||
|
w="auto"
|
||||||
|
fit="contain"
|
||||||
|
style={{ filter: "brightness(0) invert(1)", alignSelf: "flex-start" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title
|
||||||
|
order={1}
|
||||||
|
c="white"
|
||||||
|
fz={38}
|
||||||
|
fw={800}
|
||||||
|
lh={1.08}
|
||||||
|
style={{ letterSpacing: "-0.02em" }}
|
||||||
|
>
|
||||||
|
{tagline ?? "Ethiopian Djibouti Railway"}
|
||||||
|
</Title>
|
||||||
|
<Text fz="md" lh={1.6} c="rgba(255,255,255,0.82)" maw={440}>
|
||||||
|
{taglineBody ??
|
||||||
|
"Manage bookings, track cargo, and run day-to-day logistics for the Ethio–Djibouti Railway from a single backoffice."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */}
|
||||||
|
<Image
|
||||||
|
src={EDR_IMAGE}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
fit="cover"
|
||||||
|
pos="absolute"
|
||||||
|
right={0}
|
||||||
|
bottom={0}
|
||||||
|
w="80%"
|
||||||
|
h="60%"
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: IMAGE_FADE_MASK,
|
||||||
|
maskImage: IMAGE_FADE_MASK,
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: 0.9,
|
||||||
|
maskComposite: "intersect",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RightPanelDecor = () => (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
|
||||||
|
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 h-full w-full text-gray-200/40"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id="auth-grid"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#auth-grid)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LanguageSelector = () => (
|
||||||
|
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
|
||||||
|
<Globe className="h-4 w-4 text-gray-500" />
|
||||||
|
<span>Eng</span>
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function AuthShell({
|
||||||
|
children,
|
||||||
|
tagline,
|
||||||
|
taglineBody,
|
||||||
|
}: AuthShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]">
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||||
|
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-white shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
|
||||||
|
<RightPanelDecor />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
|
||||||
|
<LanguageSelector />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
<div className="flex min-h-full justify-center">
|
||||||
|
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "react";
|
} 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>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
BIN
apps/edr-freight-web/portal/public/assets/edr_image.jpg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/edr-freight-web/portal/public/assets/edr_image.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 KiB |
@@ -1,40 +1,25 @@
|
|||||||
import type { ReactNode } from "react";
|
import 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 Ethio–Djibouti 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 & Conditions
|
Terms & 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 & Support
|
Help & 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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] : [];
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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't have an account?{" "}
|
Don'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user