diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts new file mode 100644 index 000000000..13084d1c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts @@ -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 }); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.service.ts b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts new file mode 100644 index 000000000..c9ce84b72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts @@ -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, + ) {} + + async check({ + email, + phone, + }: CheckAvailabilityQuery): Promise { + 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, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index a689ba24e..16fbeffda 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; + +import { CheckAvailabilityController } from './check-availability.controller'; +import { CheckAvailabilityService } from './check-availability.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - controllers: [FreightMeController], - providers: [FreightMeService], + imports: [TypeOrmModule.forFeature([User])], + controllers: [FreightMeController, CheckAvailabilityController], + providers: [FreightMeService, CheckAvailabilityService], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 02f77b2e0..62be578bb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1183,9 +1183,11 @@ export class CompaniesService { const { businessInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( - "No business license found for this TIN. Please check the number and try again.", + "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - return this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const tinTaken = await this.companiesRepo.existsByTin(tin); + return { ...registrationData, tinTaken }; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index e5b686d11..a56ea5ad8 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -17,10 +17,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index 200b69fee..ef7eb2a21 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerName!: string; managerEmail?: string; managerPhone!: string; + tinTaken?: boolean; constructor(data: CompanyRegistrationData) { this.licenceNumber = data.licenceNumber; @@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerName = data.managerName; this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; + this.tinTaken = data.tinTaken; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 316038dc9..9fd8f28ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -34,10 +34,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg b/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg new file mode 100644 index 000000000..b89941eea Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg differ diff --git a/apps/edr-freight-web/backoffice/public/assets/edr_image.png b/apps/edr-freight-web/backoffice/public/assets/edr_image.png new file mode 100644 index 000000000..1654c747c Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/edr_image.png differ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 92784cd38..f7b26af00 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -23,6 +23,7 @@ import { Users, Wallet, } from "lucide-react"; +import { useEffect } from "react"; import { Navigate, Outlet, @@ -135,17 +136,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "User Management", + label: "Staff", href: "/um", icon: , }, { - label: "Booking requests", + label: "Bookings", href: "/dashboard/booking-requests", icon: , }, { - label: "Contract requests", + label: "Contracts", href: "/dashboard/contract-requests", icon: , permission: FREIGHT_PERMS.contracts.view, @@ -174,7 +175,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Operations", items: [ { - label: "Document Clearance", + label: "Clearance", href: "/dashboard/contracts/clearance", icon: , permission: [ @@ -312,7 +313,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Port & Terminal", items: [ { - label: "Import Operations", + label: "Imports", href: "/dashboard/import-warehouse", icon: , children: [ @@ -344,7 +345,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - label: "Export Operations", + label: "Exports", href: "/dashboard/export-warehouse", icon: , children: [ @@ -516,6 +517,38 @@ const filterSidebarByPermission = ( .filter((section) => section.items.length > 0); }; +const APP_TITLE = "EDR Freight Backoffice"; + +/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */ +const flattenSidebarItems = ( + sections: SidebarSection[], +): { href: string; label: string }[] => + sections.flatMap((section) => + section.items.flatMap((item) => [ + ...(item.href ? [{ href: item.href, label: item.label }] : []), + ...(item.children ?? []) + .filter((child): child is SidebarItem & { href: string } => + Boolean(child.href), + ) + .map((child) => ({ href: child.href, label: child.label })), + ]), + ); + +/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */ +const findActiveSidebarLabel = ( + pathname: string, + sections: SidebarSection[], +): string | undefined => { + const path = pathname.toLowerCase(); + const candidates = flattenSidebarItems(sections) + .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .sort((a, b) => b.href.length - a.href.length); + + return candidates.find( + ({ href }) => path === href || path.startsWith(`${href}/`), + )?.label; +}; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -541,6 +574,14 @@ const DashboardShell = () => { : null : null; + useEffect(() => { + const activeLabel = findActiveSidebarLabel( + location.pathname, + sidebarSections, + ); + document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE; + }, [location.pathname, sidebarSections]); + if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { return ; } diff --git a/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx new file mode 100644 index 000000000..c4fd7f39e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx @@ -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) => ( + + {/* Top-left: logo, title, description — stacked, left aligned. */} + + EDR Freight + + + + {tagline ?? "Ethiopian Djibouti Railway"} + + + {taglineBody ?? + "Manage bookings, track cargo, and run day-to-day logistics for the Ethio–Djibouti Railway from a single backoffice."} + + + + + {/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */} + + +); + +const RightPanelDecor = () => ( +
+
+
+ + + + + + + + +
+); + +const LanguageSelector = () => ( +
+ + Eng + +
+); + +export default function AuthShell({ + children, + tagline, + taglineBody, +}: AuthShellProps) { + return ( +
+
+ + +
+ + +
+ +
+ +
+
+
+ {children} +
+
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 59efe49a7..391a8157d 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -18,6 +18,7 @@ import { } from "react"; import type { SidebarItem, SidebarSection } from "./types"; +import { Link } from "react-router-dom"; export interface FreightSidebarProps { sections: SidebarSection[]; @@ -35,15 +36,15 @@ const BRAND_LOGO = "/assets/logo.svg"; const navClassNames = (active: boolean) => active ? { - root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", - label: "text-edr-primary-dark! font-medium! text-sm!", - section: "text-edr-primary-dark!", - } + root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", + label: "text-edr-primary-dark! font-medium! text-sm!", + section: "text-edr-primary-dark!", + } : { - root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", - label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", - section: "text-edr-text!", - }; + root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", + label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", + section: "text-edr-text!", + }; const itemKey = (parentKey: string, item: SidebarItem, index: number) => `${parentKey}/${item.href ?? item.label}/${index}`; @@ -65,7 +66,9 @@ const FreightSidebar = ({ const isHrefActive = useCallback( (href: string) => { const normalized = href.toLowerCase(); - return activePath === normalized || activePath.startsWith(`${normalized}/`); + return ( + activePath === normalized || activePath.startsWith(`${normalized}/`) + ); }, [activePath], ); @@ -109,9 +112,7 @@ const FreightSidebar = ({ if (hasChildren) { const isLink = !!item.href; - const active = - (isLink ? isHrefActive(item.href!) : false) || - branchActive(item.children!); + const active = isLink ? isHrefActive(item.href!) : false; const isOpen = openMap[key] ?? false; return ( @@ -124,7 +125,7 @@ const FreightSidebar = ({ active={active} opened={isOpen} classNames={navClassNames(active)} - onClick={ () => toggle(key)} + onClick={() => toggle(key)} rightSection={ } @@ -161,8 +164,9 @@ const FreightSidebar = ({ label={item.label} leftSection={item.icon} active={active} + component={Link} classNames={navClassNames(active)} - onClick={() => onNavigate?.(item.href!)} + to={item.href!} /> ); }, @@ -178,7 +182,7 @@ const FreightSidebar = ({ tt="uppercase" px="sm" mb={6} - className={ "text-edr-muted!" } + className={"text-edr-muted!"} style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }} > {section.title} @@ -232,14 +236,24 @@ const FreightSidebar = ({ {onClose && ( - + )} {/* Nav */} - + {renderedSections} diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 32b94f325..781ffba74 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -27,7 +27,6 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, - refetchOnWindowFocus: false, staleTime: 30_000, }, }, diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 89e8557c3..849049bc2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -1,162 +1,40 @@ import { type FormEvent, useState } from "react"; import { - Eye, - EyeOff, - ArrowUpRight, - Globe, - ChevronDown, -} from "lucide-react"; + Alert, + Box, + Button, + Center, + Group, + Image, + PasswordInput, + PinInput, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { AlertCircle, ArrowLeft } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; /** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ const normaliseIdentifier = (raw: string): string => { const v = raw.trim(); const digits = v.replace(/\D/g, ""); if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { - const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + const local = digits.startsWith("251") + ? digits.slice(3) + : digits.replace(/^0/, ""); return `+251${local}`; } return v.toLowerCase(); }; -const LOGIN_IMAGE = "/assets/login.png"; const EDR_LOGO = "/assets/logo.svg"; -const fieldClass = - "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10"; - -const primaryButtonClass = - "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; - -const LeftPanelDecor = () => ( -
- - {[0, 1, 2, 3, 4, 5].map((ring) => ( - - ))} - -
-
-); - -const RightPanelDecor = () => ( -
-
-
- - - - - - - - -
-); - -const LeftPanel = () => ( -
- Ethio Djibouti Railway -
- - - - -
-
-
-
- - Empower Your Freight Operations - -
-

- Sign in to manage bookings, track cargo, and run logistics operations - on the Ethio Djibouti Railway freight platform. -

-
-
-
-); - -const LanguageSelector = () => ( -
- - Eng - -
-); - -const FormFooter = () => ( - -); - const LoginPage = () => { const navigate = useNavigate(); const { login, verifyMfa } = useAuth(); @@ -165,7 +43,6 @@ const LoginPage = () => { const [otp, setOtp] = useState(""); const [needsMfa, setNeedsMfa] = useState(false); const [submitting, setSubmitting] = useState(false); - const [showPassword, setShowPassword] = useState(false); const [normalizedIdentifier, setNormalizedIdentifier] = useState(""); const [error, setError] = useState(null); @@ -179,15 +56,14 @@ const LoginPage = () => { setNormalizedIdentifier(normalized); const result = await login({ email: normalized, password }); - console.log(result); if (result.mfaRequired) { setNeedsMfa(true); return; } // navigate("/dashboard/overview", { replace: true }); - } catch { - setError("Unable to sign in with those credentials."); + } catch (err) { + setError(extractApiError(err).message); } finally { setSubmitting(false); } @@ -201,194 +77,132 @@ const LoginPage = () => { try { await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() }); navigate("/dashboard/overview", { replace: true }); - } catch { - setError("Unable to verify the one-time code."); + } catch (err) { + setError(extractApiError(err).message); } finally { setSubmitting(false); } }; const loginForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

- Get Started -

-

+ + + Welcome back! + + Log in to access the freight backoffice & explore all logistics resources. -

-
+ + -
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
- -
- setPassword(event.target.value)} - placeholder="Enter your password" - className={`${fieldClass} pr-11`} - /> - -
-
+ setPassword(event.target.value)} + /> {error ? ( -
+ }> {error} -
+ ) : null} - - -

- Need an account?{" "} - - Contact your admin - -

-
- + + +
); const mfaForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

+ + Multi-factor verification - </h1> - <p className="text-sm leading-relaxed text-gray-500"> + + We sent a verification code to{" "} - + {normalizedIdentifier} - + . Enter it below to complete sign in. -

-

+ + -
-
- - + + + Verification code + + setOtp(event.target.value)} - placeholder="Enter the code" - className={fieldClass} + placeholder="0" + disabled={submitting} + styles={{ input: { textAlign: "center" } }} + onChange={setOtp} /> -
+ {error ? ( -
+ }> {error} -
+ ) : null} -
- - -
-
- + Verify + + + +
); - return ( - <> - - - - -
-
- - -
- - -
- -
- -
-
-
- {!needsMfa ? loginForm : mfaForm} -
-
-
- - -
-
-
- - ); + return {!needsMfa ? loginForm : mfaForm}; }; export default LoginPage; diff --git a/apps/edr-freight-web/portal/public/assets/edr_image.jpg b/apps/edr-freight-web/portal/public/assets/edr_image.jpg new file mode 100644 index 000000000..b89941eea Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/edr_image.jpg differ diff --git a/apps/edr-freight-web/portal/public/assets/edr_image.png b/apps/edr-freight-web/portal/public/assets/edr_image.png new file mode 100644 index 000000000..1654c747c Binary files /dev/null and b/apps/edr-freight-web/portal/public/assets/edr_image.png differ diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx index 5a4adf587..439f41d19 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx @@ -1,40 +1,25 @@ import type { ReactNode } from "react"; -import { ArrowUpRight, ChevronDown, Globe } from "lucide-react"; +import { Box, Image, Stack, Text, Title } from "@mantine/core"; +import { ChevronDown, Globe } from "lucide-react"; +import { Link } from "react-router-dom"; -const LOGIN_IMAGE = "/assets/login.png"; +const EDR_IMAGE = "/assets/edr_image.png"; const EDR_LOGO = "/assets/logo.svg"; +/** Muted deep-green brand wash for the left panel. */ +const LEFT_PANEL_BG = + "linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)"; + +/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */ +const IMAGE_FADE_MASK = + "linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)"; + export const fieldClass = "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10"; export const primaryButtonClass = "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; -const LeftPanelDecor = () => ( -
- - {[0, 1, 2, 3, 4, 5].map((ring) => ( - - ))} - -
-
-); - const RightPanelDecor = () => (
( export interface AuthShellProps { children: ReactNode; - /** Tagline shown in the highlighted card over the left image panel. */ + /** Headline shown in the top-left of the green panel. */ tagline?: string; taglineBody?: string; } @@ -72,45 +57,63 @@ const LeftPanel = ({ tagline, taglineBody, }: Pick) => ( -
- Ethio Djibouti Railway -
- - -
- + {/* Top-left: logo, title, description — stacked, left aligned. */} + + EDR Freight - - Support - - -
-
-
-
-
- - {tagline ?? "Empower Your Freight Operations"} - -
-

+ + + {tagline ?? "Ethiopian Djibouti Railway"} + + {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."} + + + + + {/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */} + + ); const LanguageSelector = () => ( @@ -125,24 +128,24 @@ const FormFooter = () => ( ); @@ -169,8 +172,8 @@ export default function AuthShell({
-
-
+
+
{children}
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 98efc2613..6c38bba46 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -7,9 +7,11 @@ import { Text, TextInput, } from "@mantine/core"; +import { useEffect, useRef } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, CheckCircle2, Download } from "lucide-react"; +import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; +import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; interface ETradeInfoProps { @@ -22,6 +24,8 @@ interface ETradeInfoProps { onDataLoaded: (data: CompanyRegistrationData) => void; } +const isValidTin = (tin: string) => tin.length === 10; + export default function ETradeInfo({ tin, register, @@ -30,53 +34,100 @@ export default function ETradeInfo({ }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; - const hasData = mutation.data; + const tinTaken = mutation.data?.tinTaken; + const hasData = + mutation.data && !mutation.data.tinTaken ? mutation.data : null; const handleFetch = async () => { - if (!tin || tin.length !== 10 || !tin.startsWith("00")) return; + if (!isValidTin(tin)) return; const result = await mutation.mutateAsync(tin); - if (result) { + if (result && !result.tinTaken) { onDataLoaded(result); } }; - const errorMessage = + // Auto-fetch as soon as the TIN reaches its full 10-digit length — only + // once per distinct value, so retyping the same TIN doesn't refetch. + const lastFetchedTin = useRef(null); + useEffect(() => { + if (isValidTin(tin) && lastFetchedTin.current !== tin) { + lastFetchedTin.current = tin; + handleFetch(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tin]); + + const apiError = mutation.isError && mutation.error - ? (mutation.error as any).message || - "Failed to fetch company information. Please try again." + ? extractApiError(mutation.error) + : null; + // A 400 here means eTrade simply has no record for this TIN — not a + // failure. Soft-pedal it as an FYI, not a red error, so filling in + // manually doesn't feel like something went wrong. + const notFound = apiError?.statusCode === 400; + const errorMessage = + apiError && !notFound + ? apiError.message || + "We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below." : null; return ( TIN Number (10 digits) *} + label={ + <> + TIN Number (10 digits){" "} + * + + } placeholder="0012345678" maxLength={10} error={error} {...register} /> - + {errorMessage && ( + + )} + {notFound && ( + } color="gray"> + We couldn't find a matching business record for this TIN — no + problem, just fill in the details below. + + )} + {errorMessage && ( } color="red" - title="Failed to fetch data" + title="Couldn't fetch eTrade data" > - {errorMessage} You can still fill in the details manually below. + {errorMessage} + + )} + + {tinTaken && ( + } + 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. )} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index 451222841..fa1061622 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -70,6 +70,8 @@ interface RoleLicenseStepProps { /** Newly-selected files per profile id (not yet uploaded). */ value: Record; onChange: (value: Record) => void; + /** "Business license is required" style error, keyed by profile id. */ + errors?: Record; } /** @@ -82,6 +84,7 @@ export default function RoleLicenseStep({ profiles, value, onChange, + errors, }: RoleLicenseStepProps) { const setFiles = (profileId: string, files: File[]) => { onChange({ ...value, [profileId]: files }); @@ -123,6 +126,11 @@ export default function RoleLicenseStep({ file={buildLicenseSetting(profile.id, label)} value={{ [LICENSE_FILE_KEY]: selected }} uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined} + errors={ + errors?.[profile.id] + ? { [LICENSE_FILE_KEY]: errors[profile.id] } + : undefined + } onChange={(v) => { const next = v[LICENSE_FILE_KEY]; const files = Array.isArray(next) ? next : next ? [next] : []; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index ee8abe106..6a2c80160 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -14,6 +14,7 @@ export const URL_CONSTANTS = { SET_PASSWORD: "/api/auth/set-password", ME: "/api/auth/me", GENERATE_VERIFICATION_CODE: "/users/generate-verification-code", + CHECK_AVAILABILITY: "/api/auth/check-availability", }, OTP: { diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 85af9bfdd..cc9f81a29 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; +import { getMinFiles } from "@/types/fileUploadSettings"; import { api } from "@/services/api"; import RoleLicenseStep, { type RoleLicenseProfile, @@ -279,22 +280,39 @@ export default function CompanyProfileForm({ }); }; - /** Fill the General Manager from the eTrade business owner. */ - const useOwnerAsManager = () => { - if (!etradeOwner) return; - setValue("generalManagerName", etradeOwner.name); - setValue("generalManagerEmail", user.email); - setValue("generalManagerPhone", etradeOwner.phone ?? "", { - shouldValidate: true, - }); - }; - // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears // them and re-enables editing. + const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false); const [poaSameAsContact, setPoaSameAsContact] = useState(false); + // General Manager source: the eTrade-registered business owner when a TIN + // lookup found one, otherwise the registering user's own account details. + const gmSourceName = etradeOwner?.name ?? user.name?.en ?? ""; + const gmSourcePhone = etradeOwner + ? etradeOwner.phone + : toEthiopianE164(user.phoneNumber); + + useEffect(() => { + if (!gmSameAsOwner) return; + setValue("generalManagerName", gmSourceName, { shouldValidate: true }); + setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true }); + setValue("generalManagerPhone", gmSourcePhone ?? "", { + shouldValidate: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]); + + const toggleGmSameAsOwner = (checked: boolean) => { + setGmSameAsOwner(checked); + if (!checked) { + setValue("generalManagerName", ""); + setValue("generalManagerEmail", ""); + setValue("generalManagerPhone", ""); + } + }; + const gmName = watch("generalManagerName"); const gmEmail = watch("generalManagerEmail"); const gmPhone = watch("generalManagerPhone"); @@ -341,6 +359,72 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); + // Hard verification for the documents step: required company-level + // documents and a business license per operational profile must both be + // present before the user can continue. + const [documentFieldErrors, setDocumentFieldErrors] = useState< + Record + >({}); + const [licenseFieldErrors, setLicenseFieldErrors] = useState< + Record + >({}); + + const validateRequiredDocuments = (): Record => { + const errs: Record = {}; + 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 => { + const errs: Record = {}; + 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, + ) => { + 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) => { + onLicenseChange?.(next); + setLicenseFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const id of Object.keys(updated)) { + if ((next[id]?.length ?? 0) > 0) delete updated[id]; + } + return updated; + }); + }; + // The registration/license details come straight from the eTrade lookup and // are not user-editable — shown as a read-only confirmation once a TIN lookup // (or rehydration) has filled them in. The address fields below are separate: @@ -385,18 +469,21 @@ export default function CompanyProfileForm({ } }; - // Every role needs at least one license file (existing or newly selected). - const licenseComplete = (roleProfiles ?? []).every( - (p) => - (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, - ); - const nextStep = async () => { userNavigatedRef.current = true; - // The documents step auto-uploads whatever the user selected as they - // continue (partial uploads are allowed — required-doc completeness is - // re-checked on resume). A failed upload holds them on the step. + // The documents step hard-blocks on required company documents and a + // business license per operational profile before it auto-uploads and + // submits — no partial-completion path forward. if (step === "documents") { + const docErrors = validateRequiredDocuments(); + const licenseErrors = validateLicenses(); + if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) { + setDocumentFieldErrors(docErrors); + setLicenseFieldErrors(licenseErrors); + setSaveError("Please upload all required documents before continuing."); + return; + } + if (onUploadDocuments) { setSaving(true); try { @@ -410,12 +497,6 @@ export default function CompanyProfileForm({ } } - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } setSaveError(null); handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; @@ -450,8 +531,6 @@ export default function CompanyProfileForm({ onDataLoaded={handleETradeDataLoaded} /> - - - + - - - General Manager - - {etradeOwner && ( - - )} - + + General Manager + + )} { })} + onChange={handleLicenseFilesChange} + errors={licenseFieldErrors} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index bdd10871b..885888a05 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,9 +1,11 @@ import { type FormEvent, useState } from "react"; -import { Eye, EyeOff } from "lucide-react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; -import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -24,7 +26,6 @@ export default function LoginPage() { const { login } = useAuth(); const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -41,8 +42,8 @@ export default function LoginPage() { } else { setError(result.error.message); } - } catch { - setError("An unexpected error occurred"); + } catch (err) { + setError(extractApiError(err).message); } finally { setLoading(false); } @@ -64,60 +65,45 @@ export default function LoginPage() {

-
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" + + setIdentifier(event.target.value)} + /> + +
+
+ Password + + Forgot password? + +
+ setPassword(event.target.value)} />
-
-
- - - Forgot password? - -
-
- setPassword(event.target.value)} - placeholder="Enter your password" - disabled={loading} - className={`${fieldClass} pr-11`} - /> - -
-
- {error ? ( -
+ }> {error} -
+ ) : null} - +

Don't have an account?{" "} @@ -129,7 +115,7 @@ export default function LoginPage() { Create an account

-
+ ); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 50320f699..35ff521ed 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -41,7 +41,10 @@ const passwordRequirements = [ { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, + { + label: "One special character", + test: (v: string) => /[^A-Za-z0-9]/.test(v), + }, ] as const; const userSchema = z @@ -52,8 +55,14 @@ const userSchema = z .min(1, "Phone number is required") .refine(isValidPhone, "Enter a valid phone number"), userType: z.string(), - firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), - lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), + firstName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), + lastName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), password: z .string() .min(8, "Password must be at least 8 characters") @@ -132,12 +141,30 @@ export default function SignupPage() { const passwordValue = watch("password") ?? ""; - // Step 1 — form is valid: send a fresh code to the chosen channel, then - // move to the OTP challenge. + // Step 1 — form is valid: make sure the email/phone aren't already + // registered, then send a fresh code to the chosen channel and move to + // the OTP challenge. const requestOtp = async (data: FormData) => { setError(null); setSending(true); try { + const availability = await api.auth.checkAvailability.call({ + email: data.email, + phone: data.phone, + }); + if (availability.emailTaken && availability.phoneTaken) { + setError("An account with this email and phone number already exists."); + return; + } + if (availability.emailTaken) { + setError("An account with this email already exists."); + return; + } + if (availability.phoneTaken) { + setError("An account with this phone number already exists."); + return; + } + await api.auth.sendOTP.call( channel === "email" ? { email: data.email } : { phone: data.phone }, ); @@ -221,12 +248,11 @@ export default function SignupPage() { taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti." >
-
- EDR Freight -
- {stage === "form" ? ( -
+

Create account @@ -236,7 +262,7 @@ export default function SignupPage() {

- + { const met = req.test(passwordValue); return ( -
+
- {met ? : } + {met ? ( + + ) : ( + + )} - + {req.label}
@@ -346,7 +382,11 @@ export default function SignupPage() { /> {error ? ( - }> + } + > {error} ) : null} @@ -396,7 +436,11 @@ export default function SignupPage() {
{otpError ? ( - }> + } + > {otpError} ) : null} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 81a2488a9..932f6fc46 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -66,6 +66,8 @@ import type { import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, + CheckAvailabilityPayload, + CheckAvailabilityResponse, GenerateVerificationCodePayload, LoginPayload, LoginResponse, @@ -107,6 +109,11 @@ export const api = { "setPassword", authService.setPassword, ), + checkAvailability: endpoint( + "auth", + "checkAvailability", + authService.checkAvailability, + ), sendOTP: endpoint( "auth", "sendOTP", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index e1de1889a..58b81c5ba 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -1,14 +1,16 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import type { - AuthUser, - GenerateVerificationCodePayload, - LoginPayload, - LoginResponse, - OtpPayload, - OtpResponse, - SetPasswordPayload, - SignupPayload, - SignupResponse, + AuthUser, + CheckAvailabilityPayload, + CheckAvailabilityResponse, + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, } from "@/types/auth"; import { client } from "@/utils/api"; import { ApiResponse } from "@edr/types"; @@ -23,7 +25,7 @@ export const authService = { }, createUser: async (body: SignupPayload) => { - const res = await client.post> ( + const res = await client.post>( URL_CONSTANTS.USERS.SIGN_UP, body, ); @@ -31,9 +33,7 @@ export const authService = { }, getMyInfo: async () => { - const res = await client.get( - URL_CONSTANTS.USERS.ME, - ); + const res = await client.get(URL_CONSTANTS.USERS.ME); return res.data; }, @@ -53,6 +53,14 @@ export const authService = { return res.data.data; }, + checkAvailability: async (params: CheckAvailabilityPayload) => { + const res = await client.get>( + URL_CONSTANTS.USERS.CHECK_AVAILABILITY, + { params }, + ); + return res.data; + }, + sendOTP: async (body: OtpPayload) => { const res = await client.post>( URL_CONSTANTS.OTP.SEND, diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 7b58f573b..04357a9ca 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,16 @@ export interface OtpResponse { message: string; } +export interface CheckAvailabilityPayload { + email?: string; + phone?: string; +} + +export interface CheckAvailabilityResponse { + emailTaken: boolean; + phoneTaken: boolean; +} + export interface SetPasswordPayload { newPassword: string; confirmPassword: string; diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts index 9067569fb..c125b75c5 100644 --- a/packages/types/src/freight/etrade.ts +++ b/packages/types/src/freight/etrade.ts @@ -76,4 +76,6 @@ export interface CompanyRegistrationData { managerName: string; managerEmail?: string; managerPhone: string; + /** True when this TIN is already registered to an existing company. */ + tinTaken?: boolean; } diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx index 0da3e2f37..1e1eaf223 100644 --- a/packages/ui-common/src/components/SmartFileInput/index.tsx +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -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 ( +
+
+
+ +
+ +
+

+ {fileObj.name} +

+
+ + {formatBytes(fileObj.size)} + + + Ready to upload + +
+
+
+ + + + {/* Hidden input to represent file details in traditional form submissions */} + +
+ ); +} + export function SmartFileInput({ file, value, @@ -407,228 +477,352 @@ export function SmartFileInput({

)} - {/* Selected Files List */} - {currentFiles.length > 0 && ( -
- {currentFiles.map((fileObj, idx) => ( -
-
-
- -
- -
-

- {fileObj.name} -

-
- - {formatBytes(fileObj.size)} - - - Ready - -
-
-
- - - - {/* Hidden inputs to represent file details in traditional form submissions */} - -
- ))} -
- )} - - {/* Dropzone area */} - {!reachedLimit && - (variant === "minimal" ? ( -
- + {/* + Multiple-file fields (default variant) render as ONE integrated + drag-and-drop surface. Uploaded files live INSIDE the dropzone as + lightweight rows — part of the surface, not separate cards — with + the "add more" prompt on the same surface below them. A full-cover + transparent input makes clicking anywhere (outside a file row) + open the picker; the prompt is pointer-transparent so clicks fall + through to it, while file rows and their controls sit above it. + */} + {variant === "default" && field.isMultiple ? ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative flex flex-col gap-2.5 rounded-xl border-2 border-dashed p-4 transition-all", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : fieldError + ? "border-destructive/70" + : "border-border bg-card/40 hover:border-primary/40", + disabled && "pointer-events-none opacity-50", + )} + > + {/* Click anywhere on the surface (except a file row) to browse */} + {!reachedLimit && ( { - if (fileInputRefs.current) { - fileInputRefs.current[field.fileKey] = el; - } - }} - multiple={field.isMultiple} + multiple accept={acceptString} disabled={disabled} onChange={(e) => handleFileSelect(e, field)} - className="hidden" - /> - - Accepts:{" "} - {field.allowedExtensions.join(", ").toUpperCase() || - "All"} - - {existingForField.length > 0 && ( -
- {existingForField.map((f, idx) => ( - - ))} -
- )} -
- ) : isUploaded ? ( - // Uploaded state: a solid success panel that still doubles as a - // replace target (click anywhere or drag a new file onto it). -
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", - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - aria-label={`Replace ${field.fileLabel}`} + className="absolute inset-0 z-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" + aria-label={`Add files to ${field.fileLabel}`} /> + )} -
- {isDragOver ? ( - - ) : ( - - )} -
- -
-

- {isDragOver ? "Drop to replace" : "Document uploaded"} -

- {existingForField.length > 0 ? ( -
- {existingForField.map((f, idx) => ( + {(existingForField.length > 0 || currentFiles.length > 0) && ( +
+ {/* Already-saved (server) files — view/download only */} + {existingForField.map((f, idx) => ( +
+ +
- ))} +
+ + Saved +
- ) : ( -

- {isDragOver - ? "Release to replace the document on file." - : "Saved to your application. Drag a new file here or click to replace it."} -

+ ))} + + {/* Just-added (in-memory) files */} + {currentFiles.map((fileObj, idx) => ( +
+ +
+

+ {fileObj.name} +

+

+ {formatBytes(fileObj.size)} +

+
+ + Ready + + + +
+ ))} +
+ )} + + {reachedLimit ? ( +
+ + Maximum of {maxFiles} files reached +
+ ) : ( +
0 || currentFiles.length > 0 + ? "py-1" + : "py-6", )} -
- - - - Replace - -
- ) : ( -
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", - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - /> - -
- +
0 || currentFiles.length > 0 + ? "p-1.5" + : "p-3", )} - /> + > + 0 || + currentFiles.length > 0 + ? "h-4 w-4" + : "h-6 w-6", + isDragOver && "animate-bounce text-primary", + )} + /> +
+

+ {isDragOver + ? "Drop your files here" + : existingForField.length > 0 || + currentFiles.length > 0 + ? "Add more files, or " + : "Drag & drop your files here, or "} + {!isDragOver && ( + browse + )} +

+

+ {field.allowedExtensions.join(", ").toUpperCase() || + "All formats"} + {" • "} + {currentFiles.length}/{maxFiles} added +

+ )} +
+ ) : ( + <> + {/* Selected Files List */} + {currentFiles.length > 0 && ( +
+ {currentFiles.map((fileObj, idx) => ( + removeFile(field.fileKey, idx)} + /> + ))} +
+ )} -

- Drag & drop your file here, or{" "} - - browse - -

+ {/* Dropzone area */} + {!reachedLimit && + (variant === "minimal" ? ( +
+ + { + if (fileInputRefs.current) { + fileInputRefs.current[field.fileKey] = el; + } + }} + multiple={field.isMultiple} + accept={acceptString} + disabled={disabled} + onChange={(e) => handleFileSelect(e, field)} + className="hidden" + /> + + Accepts:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} + + {existingForField.length > 0 && ( +
+ {existingForField.map((f, idx) => ( + + ))} +
+ )} +
+ ) : isUploaded ? ( + // Uploaded state: a solid success panel that still doubles as a + // replace target (click anywhere or drag a new file onto it). +
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", + )} + > + 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}`} + /> -

- Supported formats:{" "} - {field.allowedExtensions.join(", ").toUpperCase() || - "All"} -

-
- ))} +
+ {isDragOver ? ( + + ) : ( + + )} +
+ +
+

+ {isDragOver + ? "Drop to replace" + : "Document uploaded"} +

+ {existingForField.length > 0 ? ( +
+ {existingForField.map((f, idx) => ( + + ))} +
+ ) : ( +

+ {isDragOver + ? "Release to replace the document on file." + : "Saved to your application. Drag a new file here or click to replace it."} +

+ )} +
+ + + + Replace + +
+ ) : ( +
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", + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + /> + +
+ +
+ +

+ Drag & drop your file here, or{" "} + + browse + +

+ +

+ Supported formats:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} +

+
+ ))} + + )} {/* Validation Error Message */} {fieldError && (