mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
update authentication pages with new layout and functionality
This commit is contained in:
@@ -12,9 +12,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Badge as MantineBadge } from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import "@/components/overview/overview.css";
|
||||
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
||||
import type {
|
||||
PaymentMethod,
|
||||
@@ -39,11 +42,16 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
||||
{ key: "success", label: "Success", statuses: "success" },
|
||||
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
||||
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
|
||||
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
|
||||
{
|
||||
key: "processing",
|
||||
label: "Processing",
|
||||
statuses: "processing,action-required",
|
||||
icon: Loader2,
|
||||
},
|
||||
{ key: "failed", label: "Failed", statuses: "failed,canceled", icon: XCircle },
|
||||
{ key: "refunded", label: "Refunded", statuses: "refunded", icon: RotateCcw },
|
||||
] as const;
|
||||
|
||||
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
||||
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
|
||||
|
||||
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
||||
|
||||
const tabCounts: Record<StatusTabKey, number | undefined> = {
|
||||
all:
|
||||
summary === undefined
|
||||
? undefined
|
||||
: (summary.success ?? 0) +
|
||||
(summary.processing ?? 0) +
|
||||
(summary.failed ?? 0) +
|
||||
(summary.refunded ?? 0),
|
||||
success: summary?.success,
|
||||
processing: summary?.processing,
|
||||
failed: summary?.failed,
|
||||
refunded: summary?.refunded,
|
||||
};
|
||||
|
||||
const columns: ColumnDef<PaymentRow>[] = [
|
||||
{
|
||||
id: "order",
|
||||
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
|
||||
setStatusTab((value as StatusTabKey) ?? "all");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
variant="pills"
|
||||
color="green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
{STATUS_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.key} value={t.key}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
{STATUS_TABS.map((t) => {
|
||||
const isActive = statusTab === t.key;
|
||||
const count = tabCounts[t.key];
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={t.key}
|
||||
value={t.key}
|
||||
leftSection={<Icon size={17} strokeWidth={1.85} />}
|
||||
rightSection={
|
||||
count !== undefined ? (
|
||||
<MantineBadge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "green" : "gray"}
|
||||
styles={
|
||||
isActive
|
||||
? {
|
||||
root: {
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
color: "#15805f",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{count}
|
||||
</MantineBadge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
|
||||
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
151
apps/edr-freight-web/portal/src/components/auth/AuthShell.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
export const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
export const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
const LeftPanelDecor = () => (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||
<svg
|
||||
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map((ring) => (
|
||||
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
|
||||
))}
|
||||
</svg>
|
||||
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const RightPanelDecor = () => (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" 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>
|
||||
);
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Tagline shown in the highlighted card over the left image panel. */
|
||||
tagline?: string;
|
||||
taglineBody?: string;
|
||||
}
|
||||
|
||||
const LeftPanel = ({ tagline, taglineBody }: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||
<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">
|
||||
{tagline ?? "Empower Your Freight Operations"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/85">
|
||||
{taglineBody ??
|
||||
"Sign in to manage shipments, 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>
|
||||
);
|
||||
|
||||
export default function AuthShell({ children, tagline, taglineBody }: AuthShellProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
|
||||
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
||||
>
|
||||
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
|
||||
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,51 @@
|
||||
import { Alert, Box, Button, Group, PasswordInput, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { ArrowRight, Mail, Phone } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
type LoginMethod = "email" | "phone";
|
||||
|
||||
const loginMethods: Array<{
|
||||
value: LoginMethod;
|
||||
label: string;
|
||||
icon: typeof Mail;
|
||||
placeholder: string;
|
||||
}> = [
|
||||
{ value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" },
|
||||
{ value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" },
|
||||
];
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useAuth();
|
||||
const [method, setMethod] = useState<LoginMethod>("email");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [countryCode, setCountryCode] = useState("+251");
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [countryCode] = useState("+251");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const currentMethod = loginMethods.find((item) => item.value === method)!;
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const loginId =
|
||||
method === "email"
|
||||
? identifier
|
||||
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
|
||||
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
|
||||
const result = await login({ email: loginId, password });
|
||||
if (result.success) {
|
||||
const from = (location.state as { from?: { pathname: string } } | null)
|
||||
?.from?.pathname;
|
||||
const from = (location.state as { from?: { pathname: string } } | null)?.from
|
||||
?.pathname;
|
||||
navigate(from ?? "/portal", { replace: true });
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
@@ -46,154 +58,105 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
left={{
|
||||
badge: "Welcome Back",
|
||||
title: "Sign in to your freight operations account",
|
||||
description:
|
||||
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
|
||||
features: [
|
||||
"Real-time shipment tracking",
|
||||
"Secure logistics management",
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box
|
||||
w={48}
|
||||
h={48}
|
||||
bg="edr-soft"
|
||||
className="flex items-center justify-center rounded-2xl"
|
||||
>
|
||||
<Mail size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
<AuthShell>
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Welcome back
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
Enter your credentials to access your portal
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Enter your credentials to access your freight portal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
value={method}
|
||||
onChange={(v) => setMethod(v as LoginMethod)}
|
||||
fullWidth
|
||||
radius="md"
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Mail size={15} />
|
||||
<Text size="sm">Email</Text>
|
||||
</Group>
|
||||
),
|
||||
value: "email",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Phone size={15} />
|
||||
<Text size="sm">Phone</Text>
|
||||
</Group>
|
||||
),
|
||||
value: "phone",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{method === "email" ? (
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="name@company.com"
|
||||
type="email"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
) : (
|
||||
<PhoneInput
|
||||
disabled={loading}
|
||||
countryCode={{
|
||||
value: countryCode,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCountryCode(e.target.value),
|
||||
}}
|
||||
phone={{
|
||||
value: phoneNumber,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setPhoneNumber(e.target.value),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Password
|
||||
</Text>
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="xs"
|
||||
c="edr-green.6"
|
||||
p={0}
|
||||
h="auto"
|
||||
fz={12}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">Sign in method</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={method}
|
||||
onChange={(event) => setMethod(event.target.value as LoginMethod)}
|
||||
disabled={loading}
|
||||
className={`${fieldClass} appearance-none pr-10`}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</Group>
|
||||
<PasswordInput
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
{loginMethods.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
{currentMethod.label} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder={currentMethod.placeholder}
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" variant="light" radius="md">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<a href="#" className="text-xs font-semibold text-primary hover:underline">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
Don't have an account?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/signup")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Alert, Box, Button, Group, PasswordInput, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, ArrowRight, UserPlus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
@@ -9,8 +8,9 @@ import { z } from "zod";
|
||||
import { userType } from "@/enums/userType";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthLayout from "@/components/auth/AuthLayout";
|
||||
import PhoneInput from "@/components/auth/PhoneInput";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
@@ -44,11 +44,16 @@ const userSchema = z
|
||||
|
||||
type FormData = z.infer<typeof userSchema>;
|
||||
|
||||
const errorText = (msg?: string) =>
|
||||
msg ? <p className="mt-1 text-xs text-red-600">{msg}</p> : null;
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signup } = useAuth();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -102,145 +107,182 @@ export default function SignupPage() {
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
left={{
|
||||
badge: "Smart Freight Operations",
|
||||
title: "Create your freight operations account",
|
||||
description:
|
||||
"Join EDR Freight to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows across Ethiopia and Djibouti.",
|
||||
features: [
|
||||
"Real-time shipment tracking",
|
||||
"Secure logistics management",
|
||||
"Enterprise-grade operations",
|
||||
"Multi-corridor freight monitoring",
|
||||
],
|
||||
stats: { label: "Active Corridors", value: "24+", footer: "Operational", progress: "w-[95%]" },
|
||||
}}
|
||||
<AuthShell
|
||||
tagline="Smart Freight Operations"
|
||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||
>
|
||||
<Stack gap="xs" mb="lg">
|
||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
||||
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
||||
Create Account
|
||||
</Text>
|
||||
<Text fz={15} c="edr-muted" mt={4}>
|
||||
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||
<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>
|
||||
|
||||
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" variant="light" radius="md" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
First name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="John"
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
{...register("firstName.en")}
|
||||
/>
|
||||
{errorText(errors.firstName?.en?.message)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Last name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
className={fieldClass}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
{errorText(errors.lastName?.en?.message)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="First Name"
|
||||
placeholder="John"
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
disabled={loading}
|
||||
error={errors.firstName?.en?.message}
|
||||
{...register("firstName.en")}
|
||||
className={fieldClass}
|
||||
{...register("email")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
placeholder="Doe"
|
||||
disabled={loading}
|
||||
error={errors.lastName?.en?.message}
|
||||
{...register("lastName.en")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{errorText(errors.email?.message)}
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
placeholder="john@example.com"
|
||||
type="email"
|
||||
disabled={loading}
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
disabled={loading}
|
||||
className={`${fieldClass} w-20 text-center`}
|
||||
{...register("countryCode")}
|
||||
/>
|
||||
<input
|
||||
placeholder="912345678"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} flex-1`}
|
||||
{...register("phone")}
|
||||
/>
|
||||
</div>
|
||||
{errorText(errors.countryCode?.message ?? errors.phone?.message)}
|
||||
</div>
|
||||
|
||||
<PhoneInput
|
||||
disabled={loading}
|
||||
countryCode={{ ...register("countryCode") }}
|
||||
phone={{ ...register("phone") }}
|
||||
countryCodeError={errors.countryCode}
|
||||
phoneError={errors.phone}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
/>
|
||||
{passwordValue.length > 0 && (
|
||||
<Stack gap={4} mt={8}>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Create a strong password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
{...register("password")}
|
||||
/>
|
||||
<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>
|
||||
{errorText(errors.password?.message)}
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<Group key={req.label} gap={6} align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={16}
|
||||
radius="xl"
|
||||
variant={met ? "filled" : "light"}
|
||||
color={met ? "edr-green" : "gray"}
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{met ? <Check size={10} /> : <X size={10} />}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
|
||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm Password"
|
||||
placeholder="Re-enter your password"
|
||||
disabled={loading}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Confirm password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder="Re-enter your password"
|
||||
disabled={loading}
|
||||
className={`${fieldClass} pr-11`}
|
||||
{...register("confirmPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((current) => !current)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label={showConfirm ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showConfirm ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errorText(errors.confirmPassword?.message)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
size="lg"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
||||
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
{loading ? "Creating account..." : "Create Account"}
|
||||
{!loading ? <ArrowRight className="h-4 w-4" /> : null}
|
||||
</button>
|
||||
|
||||
<Text size="sm" c="edr-muted" ta="center">
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Button
|
||||
variant="transparent"
|
||||
p={0}
|
||||
h="auto"
|
||||
c="edr-green.6"
|
||||
fw={600}
|
||||
fz="sm"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Text>
|
||||
</Stack>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user