mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
update authentication pages with new layout and functionality
This commit is contained in:
@@ -12,9 +12,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { Badge as MantineBadge } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
|
LayoutGrid,
|
||||||
Loader2,
|
Loader2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
|
import "@/components/overview/overview.css";
|
||||||
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments";
|
||||||
import type {
|
import type {
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
@@ -39,11 +42,16 @@ import {
|
|||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
{ key: "all", label: "All", statuses: undefined as string | undefined },
|
{ key: "all", label: "All", statuses: undefined as string | undefined, icon: LayoutGrid },
|
||||||
{ key: "success", label: "Success", statuses: "success" },
|
{ key: "success", label: "Success", statuses: "success", icon: CheckCircle2 },
|
||||||
{ key: "processing", label: "Processing", statuses: "processing,action-required" },
|
{
|
||||||
{ key: "failed", label: "Failed", statuses: "failed,canceled" },
|
key: "processing",
|
||||||
{ key: "refunded", label: "Refunded", statuses: "refunded" },
|
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;
|
] as const;
|
||||||
|
|
||||||
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
type StatusTabKey = (typeof STATUS_TABS)[number]["key"];
|
||||||
@@ -166,6 +174,20 @@ export default function PaymentsPage() {
|
|||||||
|
|
||||||
const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0));
|
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>[] = [
|
const columns: ColumnDef<PaymentRow>[] = [
|
||||||
{
|
{
|
||||||
id: "order",
|
id: "order",
|
||||||
@@ -274,13 +296,48 @@ export default function PaymentsPage() {
|
|||||||
setStatusTab((value as StatusTabKey) ?? "all");
|
setStatusTab((value as StatusTabKey) ?? "all");
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
}}
|
}}
|
||||||
|
variant="pills"
|
||||||
|
color="green"
|
||||||
|
keepMounted={false}
|
||||||
|
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
{STATUS_TABS.map((t) => (
|
{STATUS_TABS.map((t) => {
|
||||||
<Tabs.Tab key={t.key} value={t.key}>
|
const isActive = statusTab === t.key;
|
||||||
{t.label}
|
const count = tabCounts[t.key];
|
||||||
</Tabs.Tab>
|
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.List>
|
||||||
</Tabs>
|
</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 { type FormEvent, useState } from "react";
|
||||||
import { ArrowRight, Mail, Phone } from "lucide-react";
|
import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
|
||||||
|
const EDR_LOGO = "/assets/logo.svg";
|
||||||
|
|
||||||
type LoginMethod = "email" | "phone";
|
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() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [method, setMethod] = useState<LoginMethod>("email");
|
const [method, setMethod] = useState<LoginMethod>("email");
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [countryCode, setCountryCode] = useState("+251");
|
const [countryCode] = useState("+251");
|
||||||
const [phoneNumber, setPhoneNumber] = 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);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const currentMethod = loginMethods.find((item) => item.value === method)!;
|
||||||
e.preventDefault();
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const loginId =
|
const loginId =
|
||||||
method === "email"
|
method === "email"
|
||||||
? identifier
|
? identifier
|
||||||
: `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`;
|
: `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`;
|
||||||
const result = await login({ email: loginId, password });
|
const result = await login({ email: loginId, password });
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const from = (location.state as { from?: { pathname: string } } | null)
|
const from = (location.state as { from?: { pathname: string } } | null)?.from
|
||||||
?.from?.pathname;
|
?.pathname;
|
||||||
navigate(from ?? "/portal", { replace: true });
|
navigate(from ?? "/portal", { replace: true });
|
||||||
} else {
|
} else {
|
||||||
setError(result.error.message);
|
setError(result.error.message);
|
||||||
@@ -46,154 +58,105 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthShell>
|
||||||
left={{
|
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
|
||||||
badge: "Welcome Back",
|
<div className="mb-4 flex justify-center sm:mb-6">
|
||||||
title: "Sign in to your freight operations account",
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||||
description:
|
</div>
|
||||||
"Access your dashboard to manage shipments, monitor railway operations, track consignments, and streamline logistics workflows.",
|
|
||||||
features: [
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
"Real-time shipment tracking",
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
"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">
|
|
||||||
Welcome back
|
Welcome back
|
||||||
</Text>
|
</h1>
|
||||||
<Text fz={15} c="edr-muted" mt={4}>
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
Enter your credentials to access your portal
|
Enter your credentials to access your freight portal.
|
||||||
</Text>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<div className="flex w-full flex-col gap-4">
|
||||||
<Stack gap="md">
|
<div className="space-y-1.5">
|
||||||
<SegmentedControl
|
<label className="text-sm font-medium text-gray-800">Sign in method</label>
|
||||||
value={method}
|
<div className="relative">
|
||||||
onChange={(v) => setMethod(v as LoginMethod)}
|
<select
|
||||||
fullWidth
|
value={method}
|
||||||
radius="md"
|
onChange={(event) => setMethod(event.target.value as LoginMethod)}
|
||||||
data={[
|
disabled={loading}
|
||||||
{
|
className={`${fieldClass} appearance-none pr-10`}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
Forgot password?
|
{loginMethods.map((item) => (
|
||||||
</Button>
|
<option key={item.value} value={item.value}>
|
||||||
</Group>
|
{item.label}
|
||||||
<PasswordInput
|
</option>
|
||||||
placeholder="••••••••"
|
))}
|
||||||
value={password}
|
</select>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
required
|
</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}
|
disabled={loading}
|
||||||
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
{error && (
|
<div className="space-y-1.5">
|
||||||
<Alert color="red" variant="light" radius="md">
|
<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}
|
{error}
|
||||||
</Alert>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
||||||
type="submit"
|
{loading ? "Signing in..." : "Sign In"}
|
||||||
disabled={loading}
|
</button>
|
||||||
loading={loading}
|
|
||||||
size="lg"
|
|
||||||
color="edr-green"
|
|
||||||
fullWidth
|
|
||||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Text size="sm" c="edr-muted" ta="center">
|
<p className="text-center text-sm text-gray-500">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Button
|
<button
|
||||||
variant="transparent"
|
type="button"
|
||||||
p={0}
|
|
||||||
h="auto"
|
|
||||||
c="edr-green.6"
|
|
||||||
fw={600}
|
|
||||||
fz="sm"
|
|
||||||
onClick={() => navigate("/signup")}
|
onClick={() => navigate("/signup")}
|
||||||
|
className="font-semibold text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Create an account
|
Create an account
|
||||||
</Button>
|
</button>
|
||||||
</Text>
|
</p>
|
||||||
</Stack>
|
</div>
|
||||||
</form>
|
</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 { 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 { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -9,8 +8,9 @@ import { z } from "zod";
|
|||||||
import { userType } from "@/enums/userType";
|
import { userType } from "@/enums/userType";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import type { SignupPayload } from "@/types/auth";
|
import type { SignupPayload } from "@/types/auth";
|
||||||
import AuthLayout from "@/components/auth/AuthLayout";
|
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
|
||||||
|
const EDR_LOGO = "/assets/logo.svg";
|
||||||
|
|
||||||
const passwordRequirements = [
|
const passwordRequirements = [
|
||||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||||
@@ -44,11 +44,16 @@ const userSchema = z
|
|||||||
|
|
||||||
type FormData = z.infer<typeof userSchema>;
|
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() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { signup } = useAuth();
|
const { signup } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -102,145 +107,182 @@ export default function SignupPage() {
|
|||||||
const passwordValue = watch("password") ?? "";
|
const passwordValue = watch("password") ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthShell
|
||||||
left={{
|
tagline="Smart Freight Operations"
|
||||||
badge: "Smart Freight Operations",
|
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||||
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%]" },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack gap="xs" mb="lg">
|
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Box w={48} h={48} bg="edr-soft" className="flex items-center justify-center rounded-2xl">
|
<div className="mb-4 flex justify-center sm:mb-6">
|
||||||
<UserPlus size={22} color="var(--mantine-color-edr-green-6)" />
|
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
|
||||||
</Box>
|
</div>
|
||||||
<Box>
|
|
||||||
<Text fz={24} fw={800} c="edr-text" className="tracking-tight">
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
Create Account
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
</Text>
|
Create account
|
||||||
<Text fz={15} c="edr-muted" mt={4}>
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
Register to access EDR Freight services.
|
Register to access EDR Freight services.
|
||||||
</Text>
|
</p>
|
||||||
</Box>
|
</div>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{error && (
|
<div className="flex w-full flex-col gap-4">
|
||||||
<Alert color="red" variant="light" radius="md" mb="md">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{error}
|
<div className="space-y-1.5">
|
||||||
</Alert>
|
<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)}>
|
<div className="space-y-1.5">
|
||||||
<Stack gap="md">
|
<label className="text-sm font-medium text-gray-800">
|
||||||
<SimpleGrid cols={2} spacing="md">
|
Email <span className="text-red-500">*</span>
|
||||||
<TextInput
|
</label>
|
||||||
label="First Name"
|
<input
|
||||||
placeholder="John"
|
type="email"
|
||||||
|
placeholder="john@example.com"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
error={errors.firstName?.en?.message}
|
className={fieldClass}
|
||||||
{...register("firstName.en")}
|
{...register("email")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
{errorText(errors.email?.message)}
|
||||||
label="Last Name"
|
</div>
|
||||||
placeholder="Doe"
|
|
||||||
disabled={loading}
|
|
||||||
error={errors.lastName?.en?.message}
|
|
||||||
{...register("lastName.en")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<TextInput
|
<div className="space-y-1.5">
|
||||||
label="Email Address"
|
<label className="text-sm font-medium text-gray-800">
|
||||||
placeholder="john@example.com"
|
Phone <span className="text-red-500">*</span>
|
||||||
type="email"
|
</label>
|
||||||
disabled={loading}
|
<div className="flex items-start gap-2">
|
||||||
error={errors.email?.message}
|
<input
|
||||||
{...register("email")}
|
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
|
<div className="space-y-1.5">
|
||||||
disabled={loading}
|
<label className="text-sm font-medium text-gray-800">
|
||||||
countryCode={{ ...register("countryCode") }}
|
Password <span className="text-red-500">*</span>
|
||||||
phone={{ ...register("phone") }}
|
</label>
|
||||||
countryCodeError={errors.countryCode}
|
<div className="relative">
|
||||||
phoneError={errors.phone}
|
<input
|
||||||
/>
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Create a strong password"
|
||||||
<Box>
|
disabled={loading}
|
||||||
<PasswordInput
|
className={`${fieldClass} pr-11`}
|
||||||
label="Password"
|
{...register("password")}
|
||||||
placeholder="Create a strong password"
|
/>
|
||||||
disabled={loading}
|
<button
|
||||||
error={errors.password?.message}
|
type="button"
|
||||||
{...register("password")}
|
onClick={() => setShowPassword((current) => !current)}
|
||||||
/>
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||||
{passwordValue.length > 0 && (
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
<Stack gap={4} mt={8}>
|
>
|
||||||
|
{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) => {
|
{passwordRequirements.map((req) => {
|
||||||
const met = req.test(passwordValue);
|
const met = req.test(passwordValue);
|
||||||
return (
|
return (
|
||||||
<Group key={req.label} gap={6} align="center" wrap="nowrap">
|
<div key={req.label} className="flex items-center gap-2">
|
||||||
<ThemeIcon
|
<span
|
||||||
size={16}
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||||
radius="xl"
|
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||||
variant={met ? "filled" : "light"}
|
}`}
|
||||||
color={met ? "edr-green" : "gray"}
|
|
||||||
>
|
>
|
||||||
{met ? <Check size={10} /> : <X size={10} />}
|
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||||
</ThemeIcon>
|
</span>
|
||||||
<Text size="xs" c={met ? "edr-green.7" : "edr-muted"}>
|
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||||
{req.label}
|
{req.label}
|
||||||
</Text>
|
</span>
|
||||||
</Group>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<PasswordInput
|
<div className="space-y-1.5">
|
||||||
label="Confirm Password"
|
<label className="text-sm font-medium text-gray-800">
|
||||||
placeholder="Re-enter your password"
|
Confirm password <span className="text-red-500">*</span>
|
||||||
disabled={loading}
|
</label>
|
||||||
error={errors.confirmPassword?.message}
|
<div className="relative">
|
||||||
{...register("confirmPassword")}
|
<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"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
loading={loading}
|
className={`${primaryButtonClass} flex items-center justify-center gap-2`}
|
||||||
size="lg"
|
|
||||||
color="edr-green"
|
|
||||||
fullWidth
|
|
||||||
rightSection={!loading ? <ArrowRight size={18} /> : undefined}
|
|
||||||
>
|
>
|
||||||
Create Account
|
{loading ? "Creating account..." : "Create Account"}
|
||||||
</Button>
|
{!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?{" "}
|
Already have an account?{" "}
|
||||||
<Button
|
<button
|
||||||
variant="transparent"
|
type="button"
|
||||||
p={0}
|
|
||||||
h="auto"
|
|
||||||
c="edr-green.6"
|
|
||||||
fw={600}
|
|
||||||
fz="sm"
|
|
||||||
onClick={() => navigate("/login")}
|
onClick={() => navigate("/login")}
|
||||||
|
className="font-semibold text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</button>
|
||||||
</Text>
|
</p>
|
||||||
</Stack>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AuthLayout>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user