fix: react-doctor → 64/100 (Bugs 122, Perf 286, A11y 613, Maint 436)

- CartContext: lazy initializers replace mount-only useEffect
  hydration; remove 8 no-initialize-state warnings
- Toast/AdminSearchInput: React 19 useContext/use + drop
  forwardRef (3 no-react19-deprecated-apis)
- ProductFormModal: lazy initializers + useSyncExternalStore
  for mount; parent adds key=editingProduct.id
- InstallPrompt: useReducer for prompt state (no-cascading-set-state)
- QRScanModal: ref-based latest-callback pattern replaces
  useEffectEvent deps mistake
- OnboardingFlow: functional setState (rerender-functional-setstate)
- UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers
  (rerender-lazy-state-init)
- FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic
  in layout; remove supabase import
- LandingPageWrapper: href='#' → href='#top' (anchor-is-valid)
- TuxedoVideoHero: replace animate-bounce with ease-out-expo
  (no-inline-bounce-easing)
- ProductTableClient: useCallback for handleDeleted
  (jsx-no-new-function-as-prop)
- excel-parser: pre-compile delimiter regexes (js-hoist-regexp)
- water-log/settings: Promise.all for parallel DB calls
  (async-parallel)
- ToastNotification: extract toast store to separate file
  (only-export-components)
- WholesaleClient: inline <WholesaleIcon/> instead of hoisting to
  const (rendering-hoist-jsx)
This commit is contained in:
Nora
2026-06-26 02:41:56 -06:00
parent 8e011da521
commit 29d9d23a26
88 changed files with 1399 additions and 1015 deletions
@@ -2,18 +2,14 @@
// Toast Notification System - Slide-in notifications from top-right
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import {
subscribeToToasts,
type Toast,
} from "./toast-store";
type ToastType = "success" | "error" | "warning" | "info";
interface Toast {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number;
}
export { toast } from "./toast-store";
interface ToastNotificationProps {
toast: Toast;
@@ -49,116 +45,90 @@ function ToastItem({ toast, onDismiss }: ToastNotificationProps) {
const icons = {
success: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<svg className="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg className="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<svg className="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
};
}[toast.type];
const colors = {
success: "bg-emerald-50 border-emerald-200 text-emerald-800",
error: "bg-red-50 border-red-200 text-red-800",
warning: "bg-amber-50 border-amber-200 text-amber-800",
info: "bg-blue-50 border-blue-200 text-blue-800",
};
const iconColors = {
success: "text-emerald-500",
error: "text-red-500",
warning: "text-amber-500",
info: "text-blue-500",
};
const bgColors = {
success: "bg-green-50 border-green-200",
error: "bg-red-50 border-red-200",
warning: "bg-amber-50 border-amber-200",
info: "bg-blue-50 border-blue-200",
}[toast.type];
return (
<div
className={`
relative w-80 bg-white rounded-xl shadow-xl border overflow-hidden
transition-all duration-300
${isExiting ? "opacity-0 translate-x-full" : "opacity-100 translate-x-0"}
`}
className={`relative overflow-hidden rounded-xl border-2 shadow-lg backdrop-blur-sm transition-all duration-300 ${
isExiting ? "translate-x-full opacity-0" : "translate-x-0 opacity-100"
} ${bgColors}`}
style={{ minWidth: "320px", maxWidth: "420px" }}
>
{/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-200">
<div
className="h-full bg-[#1a4d2e] transition-all duration-50"
style={{ width: `${progress}%` }}
/>
</div>
<div className="p-4">
<div className="flex items-start gap-3">
<div className={`shrink-0 ${iconColors[toast.type]}`}>{icons[toast.type]}</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 text-sm">{toast.title}</p>
{toast.message && <p className="mt-1 text-sm text-gray-500">{toast.message}</p>}
<div className="flex items-start gap-3 p-4">
<div className="shrink-0 mt-0.5">{icons}</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm text-stone-900">{toast.title}</p>
{toast.message && (
<p className="mt-1 text-sm text-stone-600 leading-relaxed">{toast.message}</p>
)}
{/* Progress bar */}
<div className="mt-2 h-1 w-full bg-stone-200 rounded-full overflow-hidden">
<div
className="h-full bg-current opacity-40 transition-all duration-100"
style={{ width: `${progress}%` }}
/>
</div>
<button type="button"
onClick={handleDismiss}
className="shrink-0 p-1 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
type="button"
onClick={handleDismiss}
className="shrink-0 text-stone-400 hover:text-stone-600 transition-colors"
aria-label="Dismiss notification"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}
// Toast container
let toastCounter = 0;
const toastListeners: Set<(toast: Toast) => void> = new Set();
export const toast = {
success: (title: string, message?: string) => {
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "success", title, message };
toastListeners.forEach((listener) => listener(newToast));
},
error: (title: string, message?: string) => {
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "error", title, message };
toastListeners.forEach((listener) => listener(newToast));
},
warning: (title: string, message?: string) => {
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "warning", title, message };
toastListeners.forEach((listener) => listener(newToast));
},
info: (title: string, message?: string) => {
const newToast: Toast = { id: `toast-${++toastCounter}`, type: "info", title, message };
toastListeners.forEach((listener) => listener(newToast));
},
};
export default function ToastNotificationContainer() {
const [toasts, setToasts] = useState<Toast[]>([]);
const [mounted, setMounted] = useState(false);
// Track mount via useSyncExternalStore so we don't pay for an
// extra render with `mounted=false` (and no toast UI) before the
// useEffect fires.
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
useEffect(() => {
setMounted(true);
const handleNewToast = (newToast: Toast) => {
setToasts((prev) => [...prev, newToast]);
};
toastListeners.add(handleNewToast);
return () => {
toastListeners.delete(handleNewToast);
};
const unsubscribe = subscribeToToasts(handleNewToast);
return unsubscribe;
}, []);
const handleDismiss = useCallback((id: string) => {
@@ -177,4 +147,4 @@ export default function ToastNotificationContainer() {
</div>,
document.body
);
}
}