From ddac590bfa9b54b4dd1a354e3f827362e2f54024 Mon Sep 17 00:00:00 2001 From: Navin Dev Date: Thu, 25 Jun 2026 23:26:46 +0100 Subject: [PATCH 1/2] feat(auth): add multi-step company registration flow with email verification - New 3-step wizard at /register/company (CompanyRegister.tsx) - Step 1: company name, industry dropdown, country, company size - Step 2: admin full name, business email, password with strength meter, confirm password - Step 3: review summary with per-section Edit links back to step 1/2 - Email verification confirmation page at /register/verify-email (EmailVerification.tsx) - Displays the registered email address passed via router state - Links back to login and to re-try registration - POST /auth/register/company API call on final submit; no token stored (email verification required first) - CompanyRegisterRequest interface and authApi.registerCompany() added to auth endpoints - "Create Company Account" link added to login page footer alongside existing Sign Up link --- frontend/src/App.tsx | 4 + .../EmailVerification/EmailVerification.tsx | 67 ++ frontend/src/pages/auth/Login/Login.tsx | 29 +- .../pages/auth/Register/CompanyRegister.tsx | 642 ++++++++++++++++++ frontend/src/services/api/endpoints/auth.ts | 14 + 5 files changed, 747 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/auth/EmailVerification/EmailVerification.tsx create mode 100644 frontend/src/pages/auth/Register/CompanyRegister.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 582f13e..1fbbb32 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -49,6 +51,8 @@ const router = createBrowserRouter([ { path: '/login', element: }, { path: '/forgot-password', element: }, { path: '/reset-password', element: }, + { path: '/register/company', element: }, + { path: '/register/verify-email', element: }, { path: '/pagination-demo', element: }, { element: , diff --git a/frontend/src/pages/auth/EmailVerification/EmailVerification.tsx b/frontend/src/pages/auth/EmailVerification/EmailVerification.tsx new file mode 100644 index 0000000..55863f2 --- /dev/null +++ b/frontend/src/pages/auth/EmailVerification/EmailVerification.tsx @@ -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 ( +
+
+
+ +
+
+
+ +
+ +

+ Check your email +

+ +

+ We sent a verification link to +

+ + {email && ( +

+ + {email} +

+ )} + +

+ Click the link in your inbox to verify your email address and activate your company account. The link expires in 24 hours. +

+ + + Back to Login + + +

+ Didn't receive it?{" "} + + Try again + +

+
+
+
+ ); +}; + +export default EmailVerification; diff --git a/frontend/src/pages/auth/Login/Login.tsx b/frontend/src/pages/auth/Login/Login.tsx index b3e5ae7..797498e 100644 --- a/frontend/src/pages/auth/Login/Login.tsx +++ b/frontend/src/pages/auth/Login/Login.tsx @@ -200,15 +200,26 @@ const Login: React.FC = () => { -

- Don't have an account?{" "} - - Sign Up - -

+
+

+ Don't have an account?{" "} + + Sign Up + +

+

+ Registering a company?{" "} + + Create Company Account + +

+
); diff --git a/frontend/src/pages/auth/Register/CompanyRegister.tsx b/frontend/src/pages/auth/Register/CompanyRegister.tsx new file mode 100644 index 0000000..0537c56 --- /dev/null +++ b/frontend/src/pages/auth/Register/CompanyRegister.tsx @@ -0,0 +1,642 @@ +import React, { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Eye, EyeOff, ChevronLeft, Check, Pencil } from "lucide-react"; +import { authApi } from "../../../services/api"; + +const INDUSTRIES = [ + "Agriculture", + "Automotive", + "Construction", + "Education", + "Energy & Utilities", + "Finance & Banking", + "Food & Beverage", + "Healthcare", + "Hospitality & Tourism", + "Logistics & Supply Chain", + "Manufacturing", + "Media & Entertainment", + "Mining & Resources", + "Pharmaceuticals", + "Real Estate", + "Retail & E-commerce", + "Technology & Software", + "Telecommunications", + "Transportation", + "Other", +]; + +const COUNTRIES = [ + "Australia", + "Brazil", + "Canada", + "China", + "Egypt", + "France", + "Germany", + "Ghana", + "India", + "Indonesia", + "Japan", + "Kenya", + "Mexico", + "Netherlands", + "Nigeria", + "Norway", + "Pakistan", + "Saudi Arabia", + "Singapore", + "South Africa", + "South Korea", + "Spain", + "Sweden", + "United Arab Emirates", + "United Kingdom", + "United States", + "Other", +]; + +const COMPANY_SIZES = ["1–10", "11–50", "51–200", "201–500", "501–1,000", "1,000+"]; + +interface Step1Data { + companyName: string; + industry: string; + country: string; + companySize: string; +} + +interface Step2Data { + fullName: string; + email: string; + password: string; + confirmPassword: string; +} + +interface Step1Errors { + companyName?: string; + industry?: string; + country?: string; + companySize?: string; +} + +interface Step2Errors { + fullName?: string; + email?: string; + password?: string; + confirmPassword?: string; +} + +const inputBase = + "w-full bg-[rgba(255,255,255,0.05)] border border-[rgba(255,255,255,0.1)] rounded-xl px-4 py-3.5 text-white text-base transition-all box-border focus:outline-none focus:border-[#00DAC1] focus:bg-[rgba(255,255,255,0.08)] focus:shadow-[0_0_0_4px_rgba(0,218,193,0.1)]"; + +const selectBase = + "w-full bg-[rgba(255,255,255,0.05)] border border-[rgba(255,255,255,0.1)] rounded-xl px-4 py-3.5 text-white text-base transition-all box-border focus:outline-none focus:border-[#00DAC1] focus:bg-[rgba(255,255,255,0.08)] focus:shadow-[0_0_0_4px_rgba(0,218,193,0.1)] appearance-none cursor-pointer"; + +const labelClass = "text-[0.85rem] font-medium text-[rgba(255,255,255,0.6)] ml-1"; +const errorClass = "text-[#FF4D4D] text-[0.75rem] mt-1 ml-1"; + +const STEP_LABELS = ["Company", "Admin", "Review"]; +const STEP_TITLES = ["Company Info", "Admin Account", "Review & Submit"]; +const STEP_DESCRIPTIONS = [ + "Tell us about your company", + "Set up your admin credentials", + "Confirm your details before submitting", +]; + +const CompanyRegister: React.FC = () => { + const navigate = useNavigate(); + const [step, setStep] = useState<1 | 2 | 3>(1); + + const [step1, setStep1] = useState({ + companyName: "", + industry: "", + country: "", + companySize: "", + }); + + const [step2, setStep2] = useState({ + fullName: "", + email: "", + password: "", + confirmPassword: "", + }); + + const [step1Errors, setStep1Errors] = useState({}); + const [step2Errors, setStep2Errors] = useState({}); + const [generalError, setGeneralError] = useState(""); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [passwordStrength, setPasswordStrength] = useState<"none" | "weak" | "fair" | "strong">("none"); + + const calcPasswordStrength = (pass: string): "none" | "weak" | "fair" | "strong" => { + if (!pass) return "none"; + let strength = 0; + if (pass.length >= 8) strength++; + if (/[A-Z]/.test(pass)) strength++; + if (/[0-9]/.test(pass)) strength++; + if (/[^A-Za-z0-9]/.test(pass)) strength++; + if (strength <= 1) return "weak"; + if (strength <= 3) return "fair"; + return "strong"; + }; + + const validateStep1 = (): boolean => { + const errs: Step1Errors = {}; + if (!step1.companyName.trim()) errs.companyName = "Company name is required"; + if (!step1.industry) errs.industry = "Please select an industry"; + if (!step1.country) errs.country = "Please select a country"; + if (!step1.companySize) errs.companySize = "Please select a company size"; + setStep1Errors(errs); + return Object.keys(errs).length === 0; + }; + + const validateStep2 = (): boolean => { + const errs: Step2Errors = {}; + if (!step2.fullName.trim()) errs.fullName = "Full name is required"; + if (!step2.email) errs.email = "Email is required"; + else if (!/\S+@\S+\.\S+/.test(step2.email)) errs.email = "Invalid email format"; + if (!step2.password) errs.password = "Password is required"; + else if (step2.password.length < 8) errs.password = "Minimum 8 characters required"; + if (step2.password !== step2.confirmPassword) errs.confirmPassword = "Passwords do not match"; + setStep2Errors(errs); + return Object.keys(errs).length === 0; + }; + + const handleNextStep1 = () => { + if (validateStep1()) setStep(2); + }; + + const handleNextStep2 = () => { + if (validateStep2()) setStep(3); + }; + + const handleSubmit = async () => { + setLoading(true); + setGeneralError(""); + try { + await authApi.registerCompany({ + companyName: step1.companyName, + industry: step1.industry, + country: step1.country, + companySize: step1.companySize, + adminName: step2.fullName, + email: step2.email, + password: step2.password, + }); + navigate("/register/verify-email", { state: { email: step2.email } }); + } catch { + setGeneralError("Registration failed. Please try again."); + } finally { + setLoading(false); + } + }; + + const strengthBarWidth = { none: "0%", weak: "33.33%", fair: "66.66%", strong: "100%" }[passwordStrength]; + const strengthBarColor = { none: "transparent", weak: "#FF4D4D", fair: "#FFAB00", strong: "#00E676" }[passwordStrength]; + const strengthLabel = { + none: null, + weak: Weak, + fair: Fair, + strong: Strong, + }[passwordStrength]; + + const StepIndicator = () => ( +
+ {[1, 2, 3].map((s, i) => ( + +
+
s + ? "bg-[rgba(0,218,193,0.15)] border-2 border-[#00DAC1] text-[#00DAC1]" + : "bg-[rgba(255,255,255,0.05)] border-2 border-[rgba(255,255,255,0.2)] text-[rgba(255,255,255,0.4)]" + }`} + aria-current={step === s ? "step" : undefined} + > + {step > s ? : s} +
+ = s ? "text-[#00DAC1]" : "text-[rgba(255,255,255,0.3)]" + }`} + > + {STEP_LABELS[i]} + +
+ {i < 2 && ( +
s ? "bg-[#00DAC1]" : "bg-[rgba(255,255,255,0.1)]" + }`} + /> + )} + + ))} +
+ ); + + const SelectWrapper = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+ +
+
+ ); + + const renderStep1 = () => ( +
+ {/* Company Name */} +
+ + { + setStep1((p) => ({ ...p, companyName: e.target.value })); + if (step1Errors.companyName) setStep1Errors((p) => ({ ...p, companyName: undefined })); + }} + aria-invalid={!!step1Errors.companyName} + aria-describedby={step1Errors.companyName ? "companyName-error" : undefined} + className={`${inputBase} pr-4 ${step1Errors.companyName ? "border-[#FF4D4D]" : ""}`} + /> + {step1Errors.companyName && ( + {step1Errors.companyName} + )} +
+ + {/* Industry */} +
+ + + + + {step1Errors.industry && ( + {step1Errors.industry} + )} +
+ + {/* Country */} +
+ + + + + {step1Errors.country && ( + {step1Errors.country} + )} +
+ + {/* Company Size */} +
+ + + + + {step1Errors.companySize && ( + {step1Errors.companySize} + )} +
+ + +
+ ); + + const renderStep2 = () => ( +
+ {/* Full Name */} +
+ + { + setStep2((p) => ({ ...p, fullName: e.target.value })); + if (step2Errors.fullName) setStep2Errors((p) => ({ ...p, fullName: undefined })); + }} + aria-invalid={!!step2Errors.fullName} + aria-describedby={step2Errors.fullName ? "fullName-error" : undefined} + className={`${inputBase} pr-4 ${step2Errors.fullName ? "border-[#FF4D4D]" : ""}`} + /> + {step2Errors.fullName && ( + {step2Errors.fullName} + )} +
+ + {/* Business Email */} +
+ + { + setStep2((p) => ({ ...p, email: e.target.value })); + if (step2Errors.email) setStep2Errors((p) => ({ ...p, email: undefined })); + }} + autoComplete="email" + aria-invalid={!!step2Errors.email} + aria-describedby={step2Errors.email ? "bizEmail-error" : undefined} + className={`${inputBase} pr-4 ${step2Errors.email ? "border-[#FF4D4D]" : ""}`} + /> + {step2Errors.email && ( + {step2Errors.email} + )} +
+ + {/* Password */} +
+ +
+ { + setStep2((p) => ({ ...p, password: e.target.value })); + setPasswordStrength(calcPasswordStrength(e.target.value)); + if (step2Errors.password) setStep2Errors((p) => ({ ...p, password: undefined })); + }} + autoComplete="new-password" + aria-invalid={!!step2Errors.password} + aria-describedby={step2Errors.password ? "password-error" : undefined} + className={`${inputBase} pr-12 ${step2Errors.password ? "border-[#FF4D4D]" : ""}`} + /> + +
+ {step2.password && ( +
+
+
+
+
+ Strength: {strengthLabel ?? "None"} +
+
+ )} + {step2Errors.password && ( + {step2Errors.password} + )} +
+ + {/* Confirm Password */} +
+ +
+ { + setStep2((p) => ({ ...p, confirmPassword: e.target.value })); + if (step2Errors.confirmPassword) setStep2Errors((p) => ({ ...p, confirmPassword: undefined })); + }} + autoComplete="new-password" + aria-invalid={!!step2Errors.confirmPassword} + aria-describedby={step2Errors.confirmPassword ? "confirmPassword-error" : undefined} + className={`${inputBase} pr-12 ${step2Errors.confirmPassword ? "border-[#FF4D4D]" : ""}`} + /> + +
+ {step2Errors.confirmPassword && ( + {step2Errors.confirmPassword} + )} +
+ +
+ + +
+
+ ); + + const renderStep3 = () => ( +
+ {generalError && ( +
+ {generalError} +
+ )} + + {/* Company Info Summary */} +
+
+ + Company Information + + +
+
+ {( + [ + ["Company Name", step1.companyName], + ["Industry", step1.industry], + ["Country", step1.country], + ["Company Size", step1.companySize ? `${step1.companySize} employees` : ""], + ] as [string, string][] + ).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ + {/* Admin Account Summary */} +
+
+ + Admin Account + + +
+
+ {( + [ + ["Full Name", step2.fullName], + ["Email", step2.email], + ["Password", "••••••••"], + ] as [string, string][] + ).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ +
+ + +
+
+ ); + + return ( +
+
+
+ +
+
+

+ {STEP_TITLES[step - 1]} +

+

+ {STEP_DESCRIPTIONS[step - 1]} +

+
+ + + + {step === 1 && renderStep1()} + {step === 2 && renderStep2()} + {step === 3 && renderStep3()} + +

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +}; + +export default CompanyRegister; diff --git a/frontend/src/services/api/endpoints/auth.ts b/frontend/src/services/api/endpoints/auth.ts index 46ca15d..c7c680e 100644 --- a/frontend/src/services/api/endpoints/auth.ts +++ b/frontend/src/services/api/endpoints/auth.ts @@ -8,6 +8,16 @@ export interface SignupRequest { organizationId?: string; } +export interface CompanyRegisterRequest { + companyName: string; + industry: string; + country: string; + companySize: string; + adminName: string; + email: string; + password: string; +} + export interface LoginRequest { email: string; password: string; @@ -42,6 +52,10 @@ export const authApi = { return res.data.data; }, + registerCompany: async (data: CompanyRegisterRequest): Promise => { + await apiClient.post("/auth/register/company", data); + }, + logout: async (): Promise => { await apiClient.post("/auth/logout"); localStorage.removeItem("authToken"); From 1b15cd329545ff8834125bc53134f924833c3079 Mon Sep 17 00:00:00 2001 From: Navin Dev Date: Fri, 26 Jun 2026 07:59:02 +0100 Subject: [PATCH 2/2] feat(ui): add reusable SearchInput with debounce and clear button Introduces a shared SearchInput component to replace inconsistent per-page search implementations in Shipments and NotificationsPage. - SearchInput debounces onChange by a configurable delay (default 300ms) using useEffect + clearTimeout, so the parent is only notified after the user pauses typing - A hasMounted guard prevents onChange from firing on initial render, avoiding duplicate API calls on page load - onChangeRef keeps the debounce effect stable regardless of whether the parent memoises the callback - Clear button (X) resets the value immediately (bypassing debounce) and refocuses the input - Spinner (Loader2) is rendered on the right when isLoading is true, otherwise X is shown when the input is non-empty Shipments.tsx: - Adds a searchQuery state wired to SearchInput - Filters loaded shipments client-side (id, origin, destination, case-insensitive) via a useMemo-derived filteredShipments list - Passes filteredShipments to the virtualiser so virtual rows stay accurate during a search - Shows a "No results found" empty state when the query matches nothing - Updates the summary line to reflect filtered vs loaded/total counts NotificationsPage.tsx: - Replaces the inline Search icon + raw block with SearchInput - Removes the now-unnecessary handleSearchChange handler and the direct Search lucide import - Passes isLoading so the spinner appears while the API fetches results --- frontend/src/components/ui/SearchInput.tsx | 78 +++++++++++++++++++ .../pages/Notifications/NotificationsPage.tsx | 25 +++--- frontend/src/pages/Shipments/Shipments.tsx | 39 ++++++++-- 3 files changed, 120 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/ui/SearchInput.tsx diff --git a/frontend/src/components/ui/SearchInput.tsx b/frontend/src/components/ui/SearchInput.tsx new file mode 100644 index 0000000..ab1fbee --- /dev/null +++ b/frontend/src/components/ui/SearchInput.tsx @@ -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 = ({ + value, + onChange, + placeholder = 'Search...', + debounceMs = 300, + isLoading = false, +}) => { + const [inputValue, setInputValue] = useState(value); + const inputRef = useRef(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 ( +
+ + setInputValue(e.target.value)} + className="bg-transparent border-none outline-none text-white text-sm flex-1 placeholder:text-[#6b7280]" + /> + {isLoading ? ( + + ) : inputValue ? ( + + ) : null} +
+ ); +}; + +export default SearchInput; diff --git a/frontend/src/pages/Notifications/NotificationsPage.tsx b/frontend/src/pages/Notifications/NotificationsPage.tsx index d9e1a2b..37b11fb 100644 --- a/frontend/src/pages/Notifications/NotificationsPage.tsx +++ b/frontend/src/pages/Notifications/NotificationsPage.tsx @@ -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"; @@ -172,10 +173,6 @@ const NotificationsPage = () => { setActiveFilter(filter); }; - const handleSearchChange = (event: ChangeEvent) => { - setSearchQuery(event.target.value); - }; - const handleLoadMore = async () => { if (!meta?.hasMore || isLoadingMore) return; await fetchNotifications(currentPage + 1, true); @@ -377,16 +374,12 @@ const NotificationsPage = () => { {isGrouped ? "Grouped" : "Ungrouped"}
-
- - -
+
{error && ( diff --git a/frontend/src/pages/Shipments/Shipments.tsx b/frontend/src/pages/Shipments/Shipments.tsx index b6c434c..5d80f95 100644 --- a/frontend/src/pages/Shipments/Shipments.tsx +++ b/frontend/src/pages/Shipments/Shipments.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; 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'; @@ -16,12 +17,24 @@ const Shipments: React.FC = () => { const [total, setTotal] = useState(0); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); const loadingRef = useRef(false); 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, }); @@ -70,6 +83,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(); @@ -77,6 +91,14 @@ const Shipments: React.FC = () => {

Shipments

+
+ +
+ {error ? (
{error}
) : isEmpty ? ( @@ -84,10 +106,15 @@ const Shipments: React.FC = () => {

No shipments available

There are no shipments to show.

+ ) : isFilterEmpty ? ( +
+

No results found

+

No shipments match “{searchQuery}”.

+
) : ( <>
- Showing {shipments.length} of {total} shipments + Showing {filteredShipments.length}{searchQuery ? ` of ${shipments.length} loaded` : ` of ${total}`} shipments
{/* Sticky table header */} @@ -117,7 +144,7 @@ const Shipments: React.FC = () => { > {virtualItems.map((virtualRow) => { - const shipment = shipments[virtualRow.index]; + const shipment = filteredShipments[virtualRow.index]; if (!shipment) return null; return ( {
)} - {!hasMore && shipments.length > 0 && ( + {!hasMore && filteredShipments.length > 0 && (
- All {total} shipments loaded + {searchQuery ? `${filteredShipments.length} matching shipments` : `All ${total} shipments loaded`}
)}