fix: react-doctor modal/dialog/dropzone a11y — 18 files to role+tabIndex+onKeyDown or dialog

This commit is contained in:
Nora
2026-06-26 03:20:10 -06:00
parent e3c1295e62
commit d0bfec9d36
90 changed files with 747 additions and 408 deletions
+4 -2
View File
@@ -82,8 +82,10 @@ function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
return currencyFormatter.format(amount);
}
// Icon wrappers (lucide-react) — kept compact for inline use.
@@ -452,7 +454,7 @@ export default function AdminOrdersPanel({
{showStopDropdown && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} />
<button type="button" aria-label="Close dropdown" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => setShowStopDropdown(false)} />
<div
className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border shadow-lg"
style={{
+7 -6
View File
@@ -187,12 +187,13 @@ export default function AdminSidebar({
* future iteration hides Harvest Reach for some role, the entire group
* header goes away rather than rendering an empty section.
*/
const visibleGroups: NavGroup[] = NAV_GROUPS
.map((group) => ({
...group,
items: group.items.filter((item) => visibleItems.includes(item)),
}))
.filter((group) => group.items.length > 0);
const visibleGroups: NavGroup[] = [];
for (const group of NAV_GROUPS) {
const items = group.items.filter((item) => visibleItems.includes(item));
if (items.length > 0) {
visibleGroups.push({ ...group, items });
}
}
const isActive = useCallback(
(href: string) => {
+2 -2
View File
@@ -254,7 +254,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
{openMenu === stop.id && (
<>
<div className="fixed inset-0 z-10" onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} />
<button type="button" aria-label="Close menu" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-lg">
{stop.status === "draft" && (
<button type="button"
@@ -280,7 +280,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
{confirmDelete === stop.id && (
<>
<div className="fixed inset-0 z-30" onClick={() => setConfirmDelete(null)} />
<button type="button" aria-label="Cancel delete confirmation" className="fixed inset-0 z-30 cursor-default bg-transparent border-0 p-0" onClick={() => setConfirmDelete(null)} />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-lg p-4">
<p className="text-sm font-semibold text-stone-900">Delete &quot;{stop.city}, {stop.state}&quot;?</p>
<p className="mt-1.5 text-xs text-stone-500">This cannot be undone.</p>
+8 -1
View File
@@ -807,7 +807,7 @@ function NexusStateInput({
onChange={(e) => { if (e.target.value) addState(e.target.value); }}
options={[
{ value: "", label: "Add state" },
...US_STATES.filter((s) => !value.includes(s)).map((s) => ({ value: s, label: s })),
...US_STATES.flatMap((s) => (value.includes(s) ? [] : [{ value: s, label: s }])),
]}
/>
</AdminInput>
@@ -886,6 +886,13 @@ export function LogoUploadField({
/>
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
(e.currentTarget.querySelector('input[type="file"]') as HTMLInputElement | null)?.click();
}
}}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
+4 -3
View File
@@ -112,8 +112,10 @@ function NewCampaignModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
<button
type="button"
aria-label="Close modal"
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
onClick={onClose}
/>
@@ -154,7 +156,6 @@ function NewCampaignModal({
onChange={(e) => setName(e.target.value)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
placeholder="e.g. Weekly Pickup Reminder"
autoFocus
/>
</div>
+11 -6
View File
@@ -123,12 +123,15 @@ function scoreEntry(entry: PaletteEntry, query: string): number {
}
function filterEntries(query: string): PaletteEntry[] {
const scored = PALETTE_ENTRIES
.map((e) => ({ entry: e, score: scoreEntry(e, query) }))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_RESULTS);
return scored.map((r) => r.entry);
const scored: { entry: PaletteEntry; score: number }[] = [];
for (const e of PALETTE_ENTRIES) {
const score = scoreEntry(e, query);
if (score > 0) {
scored.push({ entry: e, score });
}
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, MAX_RESULTS).map((r) => r.entry);
}
// ---------------------------------------------------------------------------
@@ -249,7 +252,9 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
role="dialog"
aria-modal="true"
aria-label="Command palette"
tabIndex={-1}
onClick={onClose}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
style={{
position: "fixed",
inset: 0,
+8 -3
View File
@@ -228,6 +228,8 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
const seenEmails = new Set<string>();
const seenPhones = new Set<string>();
const entries: ContactImportEntry[] = [];
const headerIndex = new Map<string, number>();
headers.forEach((h, i) => headerIndex.set(h, i));
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
@@ -235,8 +237,8 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
const ignored: Record<string, string> = {};
for (const mapping of effective) {
const colIdx = headers.indexOf(mapping.csvColumn);
if (colIdx === -1) continue;
const colIdx = headerIndex.get(mapping.csvColumn);
if (colIdx === undefined) continue;
const raw = row[colIdx] ?? "";
if (mapping.field === null) {
@@ -266,7 +268,10 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
break;
case "tags":
entry.tags = raw
? raw.split(/[;:,]/).map((t) => t.trim()).filter(Boolean)
? raw.split(/[;:,]/).flatMap((t) => {
const trimmed = t.trim();
return trimmed ? [trimmed] : [];
})
: undefined;
break;
case "email_opt_in":
+10 -5
View File
@@ -7,6 +7,8 @@ import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge, KPIStat, EmptySta
import { ShoppingCart, MapPin, Package, DollarSign, Plus, Send, AlertCircle, Calendar, Inbox, Sparkles, BarChart3, BrainCircuit, Upload, Clock, Droplets, Route, Truck, Receipt, Settings as SettingsIcon, Store, type LucideIcon } from "lucide-react";
import type { DashboardStats } from "@/actions/dashboard";
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
// Lazy-load the upgrade modal — only needed when starter tier + brandId present
const UpgradePlanModal = dynamic(
() => import("@/components/admin/UpgradePlanModal"),
@@ -92,7 +94,7 @@ export default function DashboardClient({
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
return currencyFormatter.format(amount);
};
const getStatusBadge = (status: string) => {
@@ -163,10 +165,13 @@ export default function DashboardClient({
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
];
const visibleSections = sections
.filter((s) => !(s.title === "Water Log" && !isWaterLogVisible))
.filter((s) => !(s.title === "Route Trace" && !enabledAddons["route_trace"]))
.filter((s) => groupFilter === "all" || s.group === groupFilter);
const visibleSections: typeof sections = [];
for (const s of sections) {
if (s.title === "Water Log" && !isWaterLogVisible) continue;
if (s.title === "Route Trace" && !enabledAddons["route_trace"]) continue;
if (groupFilter !== "all" && s.group !== groupFilter) continue;
visibleSections.push(s);
}
return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
+59 -50
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
type Props = {
title: string;
@@ -13,72 +13,81 @@ type Props = {
};
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
const dialogRef = useRef<HTMLDialogElement>(null);
// Native <dialog> handles focus trapping, ESC, and the backdrop.
// showModal() opens it in modal mode; we listen to "cancel" so the
// parent React state stays in sync with native close behavior.
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const isElegant = variant === "elegant";
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
onClick={handleBackdropClick}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
<div
className={`relative w-full ${maxWidth} max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)] rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)] flex flex-col ${isElegant ? "border border-stone-200/60" : ""}`}
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-600 to-amber-500 rounded-t-2xl" />
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
<div className="flex items-center gap-3 min-w-0">
{titleIcon && (
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
{titleIcon}
<div className={`flex items-center justify-between px-6 sm:px-8 py-5 shrink-0 ${isElegant ? "border-b border-stone-100" : ""}`}>
<div className="flex items-center gap-3 min-w-0">
{titleIcon && (
<div className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg shrink-0 bg-amber-50">
{titleIcon}
</div>
)}
<div className="min-w-0">
{isElegant ? (
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
{title}
</h2>
) : (
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
{title}
</h2>
)}
{subtitle && (
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
)}
</div>
)}
<div className="min-w-0">
{isElegant ? (
<h2 className="text-sm font-normal tracking-wide text-stone-500 uppercase" style={{ fontFamily: "Georgia, serif" }}>
{title}
</h2>
) : (
<h2 className="text-lg sm:text-xl font-semibold text-stone-800 truncate" style={{ letterSpacing: "-0.02em" }}>
{title}
</h2>
)}
{subtitle && (
<p className={`mt-0.5 text-sm text-stone-400 hidden sm:block ${isElegant ? "font-normal" : ""}`}>{subtitle}</p>
)}
</div>
<button type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
>
<svg aria-hidden="true" className="h-4 w-4 sm:h-5 sm:w-5" 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>
<button type="button"
onClick={onClose}
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
>
<svg className="h-4 w-4 sm:h-5 sm:w-5" 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 className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
{children}
<div className="relative px-6 sm:px-8 py-6 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
</div>
</dialog>
);
}
+97 -93
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
type Props = {
title: string;
@@ -25,113 +25,117 @@ export default function GlassModal({
compact = false,
eyebrow,
}: Props) {
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
const dialogRef = useRef<HTMLDialogElement>(null);
// Escape key
// Native <dialog> handles focus trapping, ESC, and the backdrop.
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
onClick={handleBackdropClick}
style={{
backgroundColor: "rgba(60, 56, 37, 0.45)",
backdropFilter: "blur(2px)",
WebkitBackdropFilter: "blur(2px)",
}}
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className={`relative w-full ${maxWidth} ${
compact
? "max-h-[min(640px,calc(100vh-2rem))]"
: "max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)]"
} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.18),0_8px_16px_-4px_rgba(0,0,0,0.05)] flex flex-col overflow-hidden`}
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
style={{
backgroundColor: "rgba(60, 56, 37, 0.45)",
backdropFilter: "blur(2px)",
WebkitBackdropFilter: "blur(2px)",
}}
>
{/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
{!compact && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
)}
{/* Header */}
<div
className={`flex items-center justify-between shrink-0 ${
compact ? "px-5 pt-4 pb-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6"
}`}
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
className={`relative w-full ${maxWidth} ${
compact
? "max-h-[min(640px,calc(100vh-2rem))]"
: "max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)] md:max-h-[calc(100vh-4rem)]"
} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.18),0_8px_16px_-4px_rgba(0,0,0,0.05)] flex flex-col overflow-hidden`}
>
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
{titleIcon && !compact && (
<div
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
style={{ backgroundColor: "var(--admin-accent-light)" }}
>
{titleIcon}
</div>
)}
<div className="min-w-0">
{compact && eyebrow && (
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
)}
<h2
className={`${
compact
? "ha-display text-lg truncate"
: "text-lg sm:text-xl font-semibold truncate"
} text-[var(--admin-text-primary)]`}
style={compact ? undefined : { letterSpacing: "-0.02em" }}
>
{title}
</h2>
{subtitle && !compact && (
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
{subtitle}
</p>
)}
</div>
</div>
<button type="button"
onClick={onClose}
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
compact
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
: "h-8 w-8 sm:h-9 sm:w-9"
{/* Accent bar — only for non-compact (compact forms have their own internal accent) */}
{!compact && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
)}
{/* Header */}
<div
className={`flex items-center justify-between shrink-0 ${
compact ? "px-5 pt-4 pb-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6"
}`}
style={
compact
? undefined
: { backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }
}
aria-label="Close modal"
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
>
<svg className="h-4 w-4 sm:h-5 sm:w-5" 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 className="flex items-center gap-2 sm:gap-3 min-w-0">
{titleIcon && !compact && (
<div
className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-xl shrink-0"
style={{ backgroundColor: "var(--admin-accent-light)" }}
>
{titleIcon}
</div>
)}
<div className="min-w-0">
{compact && eyebrow && (
<p className="ha-eyebrow mb-0.5 truncate">{eyebrow}</p>
)}
<h2
className={`${
compact
? "ha-display text-lg truncate"
: "text-lg sm:text-xl font-semibold truncate"
} text-[var(--admin-text-primary)]`}
style={compact ? undefined : { letterSpacing: "-0.02em" }}
>
{title}
</h2>
{subtitle && !compact && (
<p className="mt-0.5 text-xs sm:text-sm text-[var(--admin-text-muted)] hidden sm:block">
{subtitle}
</p>
)}
</div>
</div>
<button type="button"
onClick={onClose}
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
compact
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
: "h-8 w-8 sm:h-9 sm:h-9"
}`}
style={
compact
? undefined
: { backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }
}
aria-label="Close modal"
>
<svg aria-hidden="true" className="h-4 w-4 sm:h-5 sm:w-5" 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>
{/* Content */}
<div
className={`relative overflow-y-auto flex-1 ${
compact ? "px-5 pb-5 pt-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8"
}`}
>
{children}
{/* Content */}
<div
className={`relative overflow-y-auto flex-1 ${
compact ? "px-5 pb-5 pt-3" : "px-4 sm:px-6 md:px-8 py-4 sm:py-6 md:py-8"
}`}
>
{children}
</div>
</div>
</div>
</div>
</dialog>
);
}
@@ -236,7 +236,12 @@ export default function AnalyticsDashboard({ analytics }: Props) {
const campaignCount = filteredAnalytics.length;
// Best performing campaign
const bestCampaign = [...filteredAnalytics].sort((a, b) => Number(b.open_rate) - Number(a.open_rate))[0];
let bestCampaign: (typeof filteredAnalytics)[number] | undefined;
for (const a of filteredAnalytics) {
if (!bestCampaign || Number(a.open_rate) > Number(bestCampaign.open_rate)) {
bestCampaign = a;
}
}
const emptyStateIcon = Icons.chart("w-10 h-10");
@@ -391,7 +391,10 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
onChange({
params: {
...filter.params,
zip_codes: e.target.value.split(",").map((z) => z.trim()).filter(Boolean),
zip_codes: e.target.value.split(",").flatMap((z) => {
const trimmed = z.trim();
return trimmed ? [trimmed] : [];
}),
},
})
}
@@ -446,7 +449,10 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
onChange({
params: {
...filter.params,
tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean),
tags: e.target.value.split(",").flatMap((t) => {
const trimmed = t.trim();
return trimmed ? [trimmed] : [];
}),
},
})
}
@@ -41,8 +41,10 @@ export default function SegmentEditModal({ initialName = "", initialDescription
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
<button
type="button"
aria-label="Close modal"
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
onClick={onClose}
/>
@@ -82,7 +84,6 @@ export default function SegmentEditModal({ initialName = "", initialDescription
onKeyDown={(e) => e.key === "Enter" && handleSave()}
placeholder="e.g. Fort Pierce Regulars"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)] outline-none"
autoFocus
/>
</div>
<div>
+5 -1
View File
@@ -97,6 +97,10 @@ const INTEGRATIONS: Integration[] = [
},
];
const INTEGRATIONS_WITHOUT_OPENAI: Integration[] = INTEGRATIONS.filter(
(i) => i.id !== "openai"
);
const iconColors: Record<string, string> = {
AI: "bg-violet-100 text-violet-700",
Email: "bg-amber-100 text-amber-700",
@@ -318,7 +322,7 @@ export default function IntegrationsInner({ brandId, brands }: Props) {
{/* Payment & email integrations grid */}
<div className="space-y-3 mb-4">
{INTEGRATIONS.filter(i => i.id !== "openai").map((integration) => (
{INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
+1 -4
View File
@@ -78,10 +78,7 @@ export function MoreSheet({ open, onClose }: MoreSheetProps) {
return (
<dialog
ref={dialogRef}
onClick={(e) => {
// Click on the backdrop (the dialog element itself) closes; clicks on content don't
if (e.target === dialogRef.current) onClose();
}}
aria-label="More navigation"
className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40"
style={{
backgroundColor: "var(--color-bg)",
+7
View File
@@ -241,6 +241,13 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
fileInputRef.current?.click();
}
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
+3 -1
View File
@@ -50,8 +50,10 @@ type EditableItem = {
removed: boolean;
};
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
return currencyFormatter.format(amount);
}
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
+3 -1
View File
@@ -30,8 +30,10 @@ type OrderPaymentSectionProps = {
existingRefunds: Refund[];
};
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
return currencyFormatter.format(amount);
}
export default function OrderPaymentSection({
+7
View File
@@ -321,6 +321,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
fileInputRef.current?.click();
}
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
@@ -311,6 +311,13 @@ export default function ProductFormModal({
<div className="atelier-section-num mb-4">01 · Media</div>
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (!uploading) fileInputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
setIsDrag(true);
+8 -4
View File
@@ -186,8 +186,10 @@ export default function ProductTableBody({
{openMenu === product.id && (
<>
<div
className="fixed inset-0 z-10"
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-[var(--admin-card-bg)] shadow-lg ring-1 ring-[var(--admin-border)] overflow-hidden">
@@ -203,8 +205,10 @@ export default function ProductTableBody({
{confirmDelete === product.id && (
<>
<div
className="fixed inset-0 z-30 backdrop-blur-sm"
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 backdrop-blur-sm border-0 p-0 cursor-default"
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
+8 -4
View File
@@ -232,8 +232,10 @@ function ProductRowBase({
{openMenu === product.id && (
<>
<div
className="fixed inset-0 z-10"
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
@@ -249,8 +251,10 @@ function ProductRowBase({
{confirmDelete === product.id && (
<>
<div
className="fixed inset-0 z-30"
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
@@ -163,6 +163,13 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
{/* Drop zone */}
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
fileInputRef.current?.click();
}
}}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
@@ -73,8 +73,10 @@ function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
return currencyFormatter.format(amount);
}
function statusLabel(status: string) {
@@ -526,8 +528,8 @@ function OrderCard({
/>
)}
{SHIPPING_STATUSES.filter(
(s) => s.value !== order.shipping_status && s.value !== "label_created"
{SHIPPING_STATUSES.flatMap((s) =>
s.value !== order.shipping_status && s.value !== "label_created" ? [s] : []
).map((s) => {
const isDanger = s.value === "returned";
return (
+11 -9
View File
@@ -144,7 +144,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
}, [stops, search, statusFilter]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
return filtered.toSorted((a, b) => {
let comparison = 0;
switch (sortField) {
case "date":
@@ -845,10 +845,11 @@ function StopRow({
{openMenu === stop.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={(e) => {
e.stopPropagation();
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => {
setOpenMenu(null);
setConfirmDelete(null);
}}
@@ -899,10 +900,11 @@ function StopRow({
{confirmDelete === stop.id && (
<>
<div
className="fixed inset-0 z-30"
onClick={(e) => {
e.stopPropagation();
<button
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
onClick={() => {
setConfirmDelete(null);
setOpenMenu(null);
}}
+2 -2
View File
@@ -152,7 +152,7 @@ export default function StopsCalendarClient({ stops }: Props) {
// Earliest/latest stop dates — used to enable/disable nav
const dateRange = useMemo(() => {
if (stops.length === 0) return null;
const dates = stops.map((s) => s.date).filter(Boolean).sort();
const dates = stops.flatMap((s) => (s.date ? [s.date] : [])).sort();
return { min: dates[0], max: dates[dates.length - 1] };
}, [stops]);
@@ -591,7 +591,7 @@ function DayRouteDrawer({
onClose: () => void;
onPublish: (stop: StopForView) => void;
}) {
const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
const sorted = useMemo(() => stops.toSorted((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
const counts = useMemo(() => {
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
const draft = sorted.filter((s) => s.status === "draft").length;
+4 -3
View File
@@ -130,8 +130,10 @@ function NewTemplateModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
<button
type="button"
aria-label="Close modal"
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
onClick={onClose}
/>
@@ -172,7 +174,6 @@ function NewTemplateModal({
onChange={(e) => setName(e.target.value)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
placeholder="e.g. Pickup Reminder"
autoFocus
/>
</div>
@@ -155,10 +155,15 @@ export default function UpgradePlanModal({
return (
<div
role="dialog"
aria-modal="true"
aria-label="Upgrade plan"
tabIndex={-1}
className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
}`}
onClick={handleBackdropClick}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Glass backdrop */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" />
+10 -2
View File
@@ -429,7 +429,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
</button>
{actionMenuId === user.id && (
<>
<div className="fixed inset-0 z-40" onClick={() => setActionMenuId(null)} />
<button type="button" aria-label="Close menu" className="fixed inset-0 z-40 cursor-default bg-transparent border-0 p-0" onClick={() => setActionMenuId(null)} />
<div className="absolute right-0 z-50 mt-1 w-48 rounded-xl bg-white shadow-xl ring-1 ring-stone-200 border border-stone-100 py-1 text-xs overflow-visible">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-stone-400 border-b border-stone-100">
Actions
@@ -508,7 +508,15 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
{/* Slide-in panel */}
{panelOpen && (
<div className="fixed inset-0 z-50 flex justify-end bg-black/20" onClick={closePanel}>
<div
role="dialog"
aria-modal="true"
aria-label={editing.isNew ? "Create user" : "Edit user"}
className="fixed inset-0 z-50 flex justify-end bg-black/20"
onClick={closePanel}
onKeyDown={(e) => { if (e.key === "Escape") closePanel(); }}
tabIndex={-1}
>
<div
className="relative flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-xl ring-1 ring-stone-200"
onClick={(e) => e.stopPropagation()}
+7 -12
View File
@@ -273,11 +273,12 @@ export default function WaterLogAdminPanel({
async function handlePreviewReport() {
const all = await getWaterEntries(brandId, 1000);
const todayRows: WaterLogReportRow[] = all
.filter(
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today,
)
.map(shapeWaterLogEntry);
const todayRows: WaterLogReportRow[] = [];
for (const e of all) {
if ((e.logged_date ?? e.logged_at.slice(0, 10)) === today) {
todayRows.push(shapeWaterLogEntry(e));
}
}
const report = formatDailyWaterReport(todayRows, {
recipientName: recipientName || undefined,
previewMode: true,
@@ -489,7 +490,6 @@ export default function WaterLogAdminPanel({
onChange={(v) => setNewHg((s) => ({ ...s, name: v }))}
placeholder="e.g. North Field Gate 1"
required
autoFocus
/>
<Select
label="Unit"
@@ -657,7 +657,6 @@ export default function WaterLogAdminPanel({
onChange={(v) => setNewUser((s) => ({ ...s, name: v }))}
placeholder="Full name"
required
autoFocus
/>
<Select
label="Role"
@@ -1116,7 +1115,6 @@ function Input({
onChange,
placeholder,
required,
autoFocus,
type = "text",
}: {
label: string;
@@ -1124,7 +1122,6 @@ function Input({
onChange: (v: string) => void;
placeholder?: string;
required?: boolean;
autoFocus?: boolean;
type?: string;
}) {
return (
@@ -1138,7 +1135,6 @@ function Input({
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
autoFocus={autoFocus}
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
/>
</label>
@@ -1199,8 +1195,7 @@ function FilterChip({ label, children }: { label: string; children: React.ReactN
function Avatar({ name, role }: { name: string; role: string }) {
const initials = name
.split(/\s+/)
.map((n) => n[0])
.filter(Boolean)
.flatMap((n) => (n[0] ? [n[0]] : []))
.slice(0, 2)
.join("")
.toUpperCase();
@@ -48,7 +48,7 @@ export default function AdminActionMenu({ actions, triggerLabel, className = ""
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<button type="button" aria-label="Close menu" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-[var(--admin-shadow-lg)] overflow-hidden">
{actions.map((action, i) => (
<button type="button"
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
type Props = {
title: string;
@@ -11,58 +11,69 @@ type Props = {
};
export default function AdminModal({ title, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
const dialogRef = useRef<HTMLDialogElement>(null);
// Native <dialog> handles focus trapping, ESC, and the backdrop.
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
<dialog
ref={dialogRef}
aria-label={title}
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
style={{ backgroundColor: "transparent" }}
>
<div
className={`relative w-full ${maxWidth} rounded-2xl`}
style={{
backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)",
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
}}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
style={{ background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)" }}
/>
<div
className={`relative w-full ${maxWidth} rounded-2xl`}
style={{
backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)",
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
}}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
style={{ background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)" }}
/>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5" style={{ borderBottom: "1px solid var(--admin-border-light)" }}>
<div>
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
{/* Header */}
<div className="flex items-center justify-between px-6 py-5" style={{ borderBottom: "1px solid var(--admin-border-light)" }}>
<div>
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
</div>
<button type="button"
onClick={onClose}
aria-label="Close"
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
>
<svg aria-hidden="true" className="h-5 w-5 text-[var(--admin-text-muted)]" 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>
<button type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
>
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" 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>
{/* Content */}
<div className="p-6">{children}</div>
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>
</div>
</dialog>
);
}
@@ -75,7 +75,7 @@ export function OrdersFilterButton() {
</button>
<dialog
ref={dialogRef}
onClick={(e) => { if (e.target === dialogRef.current) closeDialog(); }}
aria-label="Filter orders"
className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40"
style={{ backgroundColor: "var(--color-bg)", color: "var(--color-text)" }}
>
+1 -1
View File
@@ -12,7 +12,7 @@ type Props = {
// dedicated /admin/stops page.
export default function StopsList({ stops }: Props) {
const sorted = useMemo(() => {
return [...stops].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
return stops.toSorted((a, b) => (a.date || "").localeCompare(b.date || ""));
}, [stops]);
if (sorted.length === 0) {
+13 -5
View File
@@ -55,13 +55,21 @@ function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
for (const g of map.values()) {
const venues = new Set(g.stops.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean));
const venues = new Set(
g.stops.flatMap((s) => {
const trimmed = (s.location || "").trim().toLowerCase();
return trimmed ? [trimmed] : [];
})
);
g.venueCount = Math.max(1, venues.size);
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
const future = g.stops
.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive")
.map((s) => s.date)
.sort();
const future: string[] = [];
for (const s of g.stops) {
if (s.date >= todayStr && getStopStatus(s) !== "inactive") {
future.push(s.date);
}
}
future.sort();
g.nextDate = future[0] ?? null;
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
}
+9 -1
View File
@@ -23,7 +23,15 @@ export default function CartRestoredToast() {
return (
<div
className="fixed bottom-6 right-6 z-50 animate-slide-up"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setVisible(false);
dismissRestoredToast();
}
}}
className="fixed bottom-6 right-6 z-50 animate-slide-up cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 rounded-2xl"
onClick={() => { setVisible(false); dismissRestoredToast(); }}
>
<div className="flex items-center gap-3 rounded-2xl border border-green-200 bg-green-50 px-5 py-3.5 shadow-lg cursor-pointer hover:bg-green-100 transition-colors">
+8 -1
View File
@@ -78,7 +78,14 @@ export default function ChangelogFeed({ entries = DEFAULT_ENTRIES }: ChangelogFe
return (
<div
key={entry.id}
className={`bg-white rounded-xl p-5 border transition-all hover:shadow-md ${
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
markAsRead(entry.id);
}
}}
className={`bg-white rounded-xl p-5 border transition-all hover:shadow-md cursor-pointer ${
isRead ? "border-gray-200 opacity-70" : "border-emerald-200 shadow-sm"
}`}
onClick={() => markAsRead(entry.id)}
+3
View File
@@ -562,6 +562,9 @@ export default function HeroSection() {
{/* ─── SCROLL INDICATOR ────────────────────────────────────────────────── */}
<div
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") scrollToContent(); }}
className="scroll-indicator absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-4 cursor-pointer"
onClick={scrollToContent}
>
@@ -176,6 +176,14 @@ export default function NotificationCenter({ brandId, userId }: NotificationCent
{notifications.map((notification) => (
<div
key={notification.id}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (!notification.read) markAsRead(notification.id);
if (notification.link) window.location.href = notification.link;
}
}}
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
!notification.read ? "bg-emerald-50/50" : ""
}`}
@@ -287,6 +287,11 @@ export function InteractiveDemo({ onClose }: { onClose: () => void }) {
{demoSteps.map((step, i) => (
<div
key={step.title}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setDemoStep(i);
}}
className={`p-4 rounded-lg border-2 cursor-pointer transition-colors ${
i === demoStep ? "border-primary bg-primary/5" : "border-gray-200"
}`}
@@ -256,8 +256,13 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
{open && (
<div
role="dialog"
aria-modal="true"
aria-label="FSMA report"
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30"
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
style={{ backdropFilter: "blur(4px)" }}
>
<div
@@ -94,8 +94,10 @@ export default function LotCreateModal({ isOpen, onClose, brandId }: Props) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
<button
type="button"
aria-label="Close modal"
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
onClick={onClose}
/>
@@ -758,7 +758,9 @@ export default function LotDetailPanel({
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50 px-3 py-3 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 transition-all"
>
<option value="">Select status...</option>
{STATUS_FLOW.filter((s) => s !== lot.status).map((s) => {
{STATUS_FLOW.flatMap((s) =>
s !== lot.status ? [s] : []
).map((s) => {
const c = EVENT_CONFIG[s];
return (
<option key={s} value={s}>
@@ -288,7 +288,6 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
value={manualInput}
onChange={(e) => setManualInput(e.target.value.toUpperCase())}
placeholder="e.g. TC-20260520-001"
autoFocus
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400"
/>
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
@@ -88,7 +88,6 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
onChange={(e) => setCropType(e.target.value)}
placeholder="e.g. Sweet Corn"
required
autoFocus
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
@@ -643,7 +643,7 @@ export default function RouteTraceDashboard({
className="rounded-lg border border-[var(--admin-border)] bg-white text-xs text-[var(--admin-text-primary)] px-2 py-1.5 sm:py-2 focus:outline-none focus:border-[var(--admin-accent)] flex-shrink-0"
>
<option value="">All Crops</option>
{[...new Set(haulingLots.map((l) => l.crop_type))].sort().map((c) => (
{[...new Set(haulingLots.map((l) => l.crop_type))].toSorted().map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
@@ -84,7 +84,15 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
const qrPreviewSize = qrSize * scale * 0.55; // scaled down for preview
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label="Print sticker"
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={onClose}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<div>
+1 -1
View File
@@ -212,7 +212,7 @@ function SheetContent({
<div className="relative px-6 sm:px-8 pt-2 pb-5 border-b border-stone-900/10">
<div className="flex items-center gap-3">
<motion.div
initial={{ scale: 0, rotate: -45 }}
initial={{ scale: 0.95, opacity: 0 , rotate: -45 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 18, delay: 0.05 }}
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#1A4D2E] text-white shadow-[0_4px_14px_rgba(26,77,46,0.4)]"
@@ -126,7 +126,7 @@ export default function StorefrontHeader({
</svg>
{cartCount > 0 && (
<motion.span
initial={{ scale: 0 }}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1 }}
className={`absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full ${accent.cartBadge} text-[10px] font-bold text-white shadow-sm`}
>
@@ -464,7 +464,6 @@ function WaterFieldInner() {
placeholder={t.pinPlaceholder}
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
style={{ letterSpacing: "0.3em" }}
autoFocus
/>
{error && (
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
+9 -1
View File
@@ -40,7 +40,15 @@ export default function DepositModal({ order, onClose, onFulfilled }: Props) {
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 px-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label="Record deposit"
tabIndex={-1}
className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 px-4"
onClick={onClose}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl" onClick={e => e.stopPropagation()}>
<h3 className="font-semibold text-slate-900 mb-4">Record Deposit</h3>
<p className="text-sm text-slate-500 mb-4">
@@ -29,7 +29,15 @@ export default function OrderDetailsModal({ order, onClose, onFulfill, onRecordD
const hasPhone = Boolean(order.customer_phone);
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label="Order details"
tabIndex={-1}
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
onClick={onClose}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="sticky top-0 bg-white px-6 py-4 border-b border-slate-200 flex items-center justify-between rounded-t-2xl">
@@ -248,7 +248,9 @@ export default function CustomersTab({
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{registrations.filter(r => r.account_status === "pending_approval").map(r => (
{registrations.flatMap(r =>
r.account_status === "pending_approval" ? [r] : []
).map(r => (
<tr key={r.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{r.company_name ?? "—"}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{r.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{r.email}</span></td>
@@ -28,7 +28,15 @@ export default function PriceSheetModal({
}
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label="Send price sheet"
tabIndex={-1}
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
onClick={onClose}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div>