Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Signup from './pages/auth/Signup/Signup';
import Login from './pages/auth/Login/Login';
import ForgotPassword from './pages/auth/ForgotPassword/ForgotPasswordPage';
import ResetPasswordPage from './pages/auth/ResetPassword/ResetPasswordPage';
import CompanyRegister from './pages/auth/Register/CompanyRegister';
import EmailVerification from './pages/auth/EmailVerification/EmailVerification';
import DashboardLayout from './components/layout/DashboardLayout';
import ProtectedRoute from './components/auth/ProtectedRoute/ProtectedRoute';
import RoleGuard from './components/auth/RoleGuard';
Expand Down Expand Up @@ -49,6 +51,8 @@ const router = createBrowserRouter([
{ path: '/login', element: <Login /> },
{ path: '/forgot-password', element: <ForgotPassword /> },
{ path: '/reset-password', element: <ResetPasswordPage /> },
{ path: '/register/company', element: <CompanyRegister /> },
{ path: '/register/verify-email', element: <EmailVerification /> },
{ path: '/pagination-demo', element: <PaginationDemo /> },
{
element: <ProtectedRoute />,
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/components/ui/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Loader2, Search, X } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';

interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
debounceMs?: number;
isLoading?: boolean;
}

const SearchInput: React.FC<SearchInputProps> = ({
value,
onChange,
placeholder = 'Search...',
debounceMs = 300,
isLoading = false,
}) => {
const [inputValue, setInputValue] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
const onChangeRef = useRef(onChange);
const hasMounted = useRef(false);

useEffect(() => {
onChangeRef.current = onChange;
});

// Sync external value changes (e.g., programmatic clear from parent)
useEffect(() => {
setInputValue(value);
}, [value]);

// Debounce onChange — skip on initial mount to avoid double-firing on load
useEffect(() => {
if (!hasMounted.current) {
hasMounted.current = true;
return;
}
const timer = setTimeout(() => {
onChangeRef.current(inputValue);
}, debounceMs);
return () => clearTimeout(timer);
}, [inputValue, debounceMs]);

const handleClear = () => {
setInputValue('');
onChangeRef.current('');
inputRef.current?.focus();
};

return (
<div className="flex items-center gap-3 bg-[#1f2937] border border-[#374151] rounded-lg px-4 py-2.5 flex-1 max-w-[400px]">
<Search size={18} className="text-[#6b7280] shrink-0" />
<input
ref={inputRef}
type="text"
placeholder={placeholder}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className="bg-transparent border-none outline-none text-white text-sm flex-1 placeholder:text-[#6b7280]"
/>
{isLoading ? (
<Loader2 size={16} className="text-[#6b7280] shrink-0 animate-spin" />
) : inputValue ? (
<button
type="button"
onClick={handleClear}
aria-label="Clear search"
className="text-[#6b7280] hover:text-white transition-colors shrink-0"
>
<X size={16} />
</button>
) : null}
</div>
);
};

export default SearchInput;
25 changes: 9 additions & 16 deletions frontend/src/pages/Notifications/NotificationsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {
Bell, Settings, UserCircle, Search, Check,
Bell, Settings, UserCircle, Check,
Truck, FileText, AlertTriangle, Server, Receipt, DollarSign, Trash2,
} from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import SearchInput from "../../components/ui/SearchInput";
import { useNavigate, useSearchParams } from "react-router-dom";
import { notificationsApi, Notification as NotificationType } from "../../services/api/endpoints/notifications";
import { useRealtimeEvents } from "../../hooks/useRealtimeEvents";
Expand Down Expand Up @@ -172,10 +173,6 @@ const NotificationsPage = () => {
setActiveFilter(filter);
};

const handleSearchChange = (event: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(event.target.value);
};

const handleLoadMore = async () => {
if (!meta?.hasMore || isLoadingMore) return;
await fetchNotifications(currentPage + 1, true);
Expand Down Expand Up @@ -377,16 +374,12 @@ const NotificationsPage = () => {
{isGrouped ? "Grouped" : "Ungrouped"}
</button>
</div>
<div className="flex items-center gap-3 bg-[#1f2937] border border-[#374151] rounded-lg px-4 py-2.5 flex-1 max-w-[400px]">
<Search size={18} className="text-[#6b7280] shrink-0" />
<input
type="text"
placeholder="Search by ID, contract, or keyword..."
value={searchQuery}
onChange={handleSearchChange}
className="bg-transparent border-none outline-none text-white text-sm flex-1 placeholder:text-[#6b7280]"
/>
</div>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search by ID, contract, or keyword..."
isLoading={isLoading}
/>
</div>

{error && (
Expand Down
38 changes: 32 additions & 6 deletions frontend/src/pages/Shipments/Shipments.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Download, Loader2 } from 'lucide-react';
import { shipmentApi, type Shipment } from '../../api/shipmentApi';
import SearchInput from '../../components/ui/SearchInput';
import StatusBadge from '../../components/ui/StatusBadge/StatusBadge';
import { safeFormatDate } from '../../utils/safeFormat';
import { useVirtualShipments } from './hooks/useVirtualShipments';
Expand Down Expand Up @@ -49,8 +50,19 @@ const Shipments: React.FC = () => {

const hasMore = shipments.length < total;

const filteredShipments = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return shipments;
return shipments.filter(
(s) =>
s.id.toLowerCase().includes(q) ||
s.origin.toLowerCase().includes(q) ||
s.destination.toLowerCase().includes(q),
);
}, [shipments, searchQuery]);

const { parentRef, virtualizer, handleScroll, scrollToIndex } = useVirtualShipments({
shipments,
shipments: filteredShipments,
onLoadMore: () => setCurrentPage((p) => p + 1),
hasMore,
});
Expand Down Expand Up @@ -107,6 +119,7 @@ const Shipments: React.FC = () => {
};

const isEmpty = !isLoading && !error && shipments.length === 0;
const isFilterEmpty = !isLoading && !error && shipments.length > 0 && filteredShipments.length === 0;
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();

Expand All @@ -130,17 +143,30 @@ const Shipments: React.FC = () => {
</button>
</div>

<div className="shipments-search">
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search by ID, origin, or destination..."
/>
</div>

{error ? (
<div className="shipments-error">{error}</div>
) : isEmpty ? (
<div className="shipments-empty">
<h3>No shipments available</h3>
<p>There are no shipments to show.</p>
</div>
) : isFilterEmpty ? (
<div className="shipments-empty">
<h3>No results found</h3>
<p>No shipments match &ldquo;{searchQuery}&rdquo;.</p>
</div>
) : (
<>
<div className="shipments-summary">
Showing {shipments.length} of {total} shipments
Showing {filteredShipments.length}{searchQuery ? ` of ${shipments.length} loaded` : ` of ${total}`} shipments
</div>

{/* Sticky table header */}
Expand Down Expand Up @@ -170,7 +196,7 @@ const Shipments: React.FC = () => {
>
<tbody style={{ display: 'block', height: `${totalSize}px`, position: 'relative' }}>
{virtualItems.map((virtualRow) => {
const shipment = shipments[virtualRow.index];
const shipment = filteredShipments[virtualRow.index];
if (!shipment) return null;
return (
<tr
Expand Down Expand Up @@ -216,9 +242,9 @@ const Shipments: React.FC = () => {
</div>
)}

{!hasMore && shipments.length > 0 && (
{!hasMore && filteredShipments.length > 0 && (
<div className="shipments-summary" style={{ marginTop: '0.5rem' }}>
All {total} shipments loaded
{searchQuery ? `${filteredShipments.length} matching shipments` : `All ${total} shipments loaded`}
</div>
)}
</>
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/pages/auth/EmailVerification/EmailVerification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { CheckCircle2, ArrowLeft, Mail } from "lucide-react";

const EmailVerification: React.FC = () => {
const location = useLocation();
const email = location.state?.email as string | undefined;

return (
<div className="min-h-screen flex items-center justify-center bg-[#050505] text-white relative overflow-hidden font-sans">
<div
className="absolute w-[500px] h-[500px] top-[-250px] right-[-100px] z-0 pointer-events-none"
style={{ background: "radial-gradient(rgba(0,218,193,0.4), rgba(0,218,193,0))" }}
/>
<div
className="absolute w-[600px] h-[600px] bottom-[-300px] left-[-200px] z-0 pointer-events-none"
style={{ background: "conic-gradient(from 180deg at 50% 50%, #16abff33 0deg, #0885ff33 55deg, #54d6ff33 120deg, #0071ff33 160deg, transparent 360deg)" }}
/>

<div className="bg-[rgba(20,20,20,0.7)] backdrop-blur-[20px] border border-[rgba(255,255,255,0.1)] rounded-3xl p-10 w-full max-w-[480px] z-10 shadow-[0_8px_32px_0_rgba(0,0,0,0.8)] sm:p-8 sm:rounded-none sm:min-h-screen sm:flex sm:flex-col sm:justify-center">
<div className="flex flex-col items-center text-center">
<div className="mb-6 bg-[rgba(0,218,193,0.1)] p-5 rounded-full inline-flex items-center justify-center">
<CheckCircle2 size={64} className="text-[#00DAC1]" />
</div>

<h2 className="text-[2rem] font-bold mb-2 bg-[linear-gradient(135deg,#fff_0%,#00DAC1_100%)] bg-clip-text [-webkit-background-clip:text] [-webkit-text-fill-color:transparent]">
Check your email
</h2>

<p className="text-[rgba(255,255,255,0.6)] text-[0.95rem] mb-2">
We sent a verification link to
</p>

{email && (
<p className="text-white font-semibold text-[0.95rem] mb-4 flex items-center gap-2">
<Mail size={16} className="text-[#00DAC1]" />
{email}
</p>
)}

<p className="text-[rgba(255,255,255,0.5)] text-[0.875rem] mb-8 leading-relaxed">
Click the link in your inbox to verify your email address and activate your company account. The link expires in 24 hours.
</p>

<Link
to="/login"
className="w-full bg-[linear-gradient(135deg,#00DAC1_0%,#008B7B_100%)] text-black border-none rounded-xl py-4 text-base font-bold no-underline transition-all flex items-center justify-center gap-2 hover:-translate-y-0.5 hover:shadow-[0_4px_15px_rgba(0,218,193,0.4)]"
>
<ArrowLeft size={20} /> Back to Login
</Link>

<p className="text-[rgba(255,255,255,0.4)] text-[0.8rem] mt-6">
Didn't receive it?{" "}
<Link
to="/register/company"
className="text-[#00DAC1] no-underline font-semibold hover:underline"
>
Try again
</Link>
</p>
</div>
</div>
</div>
);
};

export default EmailVerification;
29 changes: 20 additions & 9 deletions frontend/src/pages/auth/Login/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,15 +200,26 @@ const Login: React.FC = () => {
</button>
</form>

<p className="text-center text-[0.9rem] text-[rgba(255,255,255,0.6)] mt-6">
Don't have an account?{" "}
<Link
to="/signup"
className="text-[#00DAC1] no-underline font-semibold hover:underline"
>
Sign Up
</Link>
</p>
<div className="flex flex-col gap-2 mt-6 text-center">
<p className="text-[0.9rem] text-[rgba(255,255,255,0.6)]">
Don't have an account?{" "}
<Link
to="/signup"
className="text-[#00DAC1] no-underline font-semibold hover:underline"
>
Sign Up
</Link>
</p>
<p className="text-[0.9rem] text-[rgba(255,255,255,0.6)]">
Registering a company?{" "}
<Link
to="/register/company"
className="text-[#00DAC1] no-underline font-semibold hover:underline"
>
Create Company Account
</Link>
</p>
</div>
</div>
</div>
);
Expand Down
Loading
Loading