fix: react-doctor modal/dialog/dropzone a11y — 18 files to role+tabIndex+onKeyDown or dialog
This commit is contained in:
@@ -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={{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 "{stop.city}, {stop.state}"?</p>
|
||||
<p className="mt-1.5 text-xs text-stone-500">This cannot be undone.</p>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || ""));
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user