fix: react-doctor dialog/a11y/labels → 68/100 with 973 warnings

This commit is contained in:
Nora
2026-06-26 03:43:04 -06:00
parent d0bfec9d36
commit e97eb33bf1
34 changed files with 611 additions and 580 deletions
+2 -1
View File
@@ -13,6 +13,8 @@ const ENTITY_LABELS: Record<string, string> = {
unknown: "Unknown",
};
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
const ALL_FIELDS = ["ignore", "product_name", "price", "description", "product_type", "active", "image_url",
"customer_name", "customer_email", "customer_phone", "stop_id", "quantity", "fulfillment", "product_id",
"first_name", "last_name", "full_name", "tags", "email_opt_in", "sms_opt_in", "external_id",
@@ -134,7 +136,6 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
}
const stepIndex = ["upload", "analysis", "preview", "import"].indexOf(step);
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
return (
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden">
+10 -9
View File
@@ -9,6 +9,16 @@ import { pool } from "@/lib/db";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const featureKeys = [
"harvest_reach",
"wholesale_portal",
"water_log",
"ai_tools",
"sms_campaigns",
"square_sync",
"route_trace",
] as const;
export default async function AdminPage() {
const adminUser = await getAdminUser();
const isStoreEmployee = adminUser?.role === "store_employee";
@@ -62,15 +72,6 @@ export default async function AdminPage() {
enabledAddons = o.enabledAddons;
} else {
// Fallback to per-feature flag check (matches prior behavior)
const featureKeys = [
"harvest_reach",
"wholesale_portal",
"water_log",
"ai_tools",
"sms_campaigns",
"square_sync",
"route_trace",
] as const;
const featureFlags = await Promise.all(
featureKeys.map((key) => isFeatureEnabled(dashboardBrandId, key)),
);
+31 -31
View File
@@ -22,6 +22,37 @@ type ProductRow = {
available_until: string | null;
};
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
db
.select({
id: products.id,
name: products.name,
description: products.description,
priceCents: products.priceCents,
active: products.active,
brandId: products.brandId,
brandName: brands.name,
firstImageKey: productImages.storageKey,
firstImagePosition: productImages.position,
})
.from(products)
.leftJoin(brands, eq(brands.id, products.brandId))
// Pull only the lowest-position image per product. The
// `position` index on product_images (product_id, position) makes
// this cheap; a real-world scale would replace it with a
// DISTINCT ON subquery, but for the current catalog size this
// join is fine and avoids a second round-trip.
.leftJoin(
productImages,
sql`${productImages.id} = (
SELECT id FROM product_images
WHERE product_id = ${products.id}
ORDER BY position ASC, created_at ASC
LIMIT 1
)`,
)
.orderBy(asc(products.name));
export default async function AdminProductsPage() {
const adminUser = await getAdminUser();
@@ -61,37 +92,6 @@ export default async function AdminProductsPage() {
let productsList: ProductRow[] = [];
let queryError: string | null = null;
try {
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
db
.select({
id: products.id,
name: products.name,
description: products.description,
priceCents: products.priceCents,
active: products.active,
brandId: products.brandId,
brandName: brands.name,
firstImageKey: productImages.storageKey,
firstImagePosition: productImages.position,
})
.from(products)
.leftJoin(brands, eq(brands.id, products.brandId))
// Pull only the lowest-position image per product. The
// `position` index on product_images (product_id, position) makes
// this cheap; a real-world scale would replace it with a
// DISTINCT ON subquery, but for the current catalog size this
// join is fine and avoids a second round-trip.
.leftJoin(
productImages,
sql`${productImages.id} = (
SELECT id FROM product_images
WHERE product_id = ${products.id}
ORDER BY position ASC, created_at ASC
LIMIT 1
)`,
)
.orderBy(asc(products.name));
const rows = isPlatformAdmin
? await withPlatformAdmin((db) => baseQuery(db))
: brandId
+25 -38
View File
@@ -5,6 +5,29 @@ import Link from "next/link";
import { AdminCard } from "@/components/admin/design-system";
import { setAIProviderSettings } from "@/actions/integrations/ai-providers";
// ── Pure helpers (module scope) ──────────────────────────────────────────────
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
const reportTypes = [
{ value: "orders-by-stop", label: "Orders by Stop" },
{ value: "sales-by-product", label: "Sales by Product" },
{ value: "fulfillment", label: "Fulfillment" },
{ value: "pickup-status", label: "Pickup Status" },
{ value: "contact-growth", label: "Contact Growth" },
{ value: "campaigns", label: "Campaign Activity" },
];
const exampleQueries = [
"Which customers haven't ordered in 45 days?",
"What products are trending this month?",
"Who are my top customers by revenue?",
"Show recent orders from the last 7 days",
"Which customers are at risk of churning?",
];
// ── Header Icon Component ─────────────────────────────────────────────────────
function AIHeaderIcon() {
@@ -662,15 +685,6 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
const [result, setResult] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const reportTypes = [
{ value: "orders-by-stop", label: "Orders by Stop" },
{ value: "sales-by-product", label: "Sales by Product" },
{ value: "fulfillment", label: "Fulfillment" },
{ value: "pickup-status", label: "Pickup Status" },
{ value: "contact-growth", label: "Contact Growth" },
{ value: "campaigns", label: "Campaign Activity" },
];
async function handleExplain() {
setLoading(true);
setError(null);
@@ -692,10 +706,6 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
return (
<div className="space-y-4">
<p className="text-sm rounded-lg p-3" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', color: 'var(--admin-text-muted)' }}>
@@ -850,10 +860,6 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function addTier() { setPriceTiers([...priceTiers, { tier: "", price: "" }]); }
function removeTier(i: number) { setPriceTiers(priceTiers.filter((_, idx) => idx !== i)); }
function updateTier(i: number, field: keyof PriceTier, val: string) {
@@ -1099,14 +1105,11 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
return (
<div className="space-y-4">
<p className="text-sm rounded-lg p-3" style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', color: 'var(--admin-text-muted)' }}>
Enter details about the stop you want to send a blast for. The AI will suggest optimal timing, subject lines, content angles, and audience targeting.
Enter details about the stop you want to send a blast for.
The AI will suggest optimal timing, subject lines, content angles, and audience targeting.
</p>
<div className="grid grid-cols-2 gap-4">
<div>
@@ -1203,14 +1206,6 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
} | null>(null);
const [error, setError] = useState<string | null>(null);
const exampleQueries = [
"Which customers haven't ordered in 45 days?",
"What products are trending this month?",
"Who are my top customers by revenue?",
"Show recent orders from the last 7 days",
"Which customers are at risk of churning?",
];
async function handleAnalyze(nlQuery?: string) {
const q = nlQuery ?? query;
if (!q.trim()) return;
@@ -1389,10 +1384,6 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
const validStops = stops.filter((s) => s.name.trim() && s.city.trim() && s.state.trim());
return (
@@ -1621,10 +1612,6 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
function addRow() { setHistoricalData([...historicalData, { date: "", quantity_sold: "", stop: "" }]); }
function removeRow(i: number) { setHistoricalData(historicalData.filter((_, idx) => idx !== i)); }
function updateRow(i: number, field: keyof ForecastEntry, val: string) {
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import {
@@ -12,6 +12,20 @@ import {
} from "@/actions/water-log/admin";
import { AdminButton } from "@/components/admin/design-system";
async function openPrintWindow(html: string) {
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const w = window.open(url, "_blank", "width=700,height=700");
if (w) {
// Revoke the blob URL once the new window has had time to load the document.
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
// Safety net: revoke after 60s even if the load event never fires.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} else {
URL.revokeObjectURL(url);
}
}
type Headgate = {
id: string;
name: string;
@@ -122,6 +136,22 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
const [deletingId, setDeletingId] = useState<string | null>(null);
const editDialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = editDialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
setEditHg(null);
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [editHg]);
async function handleDelete(hg: Headgate) {
if (!window.confirm(`Delete headgate "${hg.name}"? Existing log entries will be preserved.`)) return;
setDeletingId(hg.id);
@@ -171,21 +201,8 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
}
}
async function openPrintWindow(html: string) {
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
const url = URL.createObjectURL(blob);
const w = window.open(url, "_blank", "width=700,height=700");
if (w) {
// Revoke the blob URL once the new window has had time to load the document.
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
// Safety net: revoke after 60s even if the load event never fires.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} else {
URL.revokeObjectURL(url);
}
}
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
<div className="bg-white border-b border-[var(--admin-border)] px-6 py-4">
@@ -371,17 +388,12 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
{/* Edit Modal */}
{editHg && (
<div
role="dialog"
aria-modal="true"
<dialog
ref={editDialogRef}
aria-label="Edit Headgate"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setEditHg(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditHg(null);
}}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/50"
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]" onClick={(e) => e.stopPropagation()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]">
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
<p className="font-bold text-[var(--admin-text-primary)]">Edit Headgate</p>
<button type="button" onClick={() => setEditHg(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
@@ -461,7 +473,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
</div>
</form>
</div>
</div>
</dialog>
)}
{/* QR Modal */}
@@ -475,6 +487,21 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => void; onRegenerate: (hg: Headgate) => void }) {
const [tab, setTab] = useState<"preview" | "print" | "download">("preview");
const [loading, setLoading] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
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);
};
}, [onClose]);
const code = hg.name.replace(/\s+/g, "-").toUpperCase().slice(0, 6) + "-1";
const qrUrl = `/api/water-qr?token=${hg.headgate_token}&size=360`;
@@ -520,17 +547,12 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
}
return (
<div
role="dialog"
aria-modal="true"
<dialog
ref={dialogRef}
aria-label="Headgate details"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/50"
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]" onClick={(e) => e.stopPropagation()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
@@ -616,6 +638,6 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
</div>
</div>
</div>
</div>
</dialog>
);
}
+59 -35
View File
@@ -50,6 +50,24 @@ function WholesaleIcon() {
);
}
const STATUS_BADGE_MAP: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
pending: "warning",
awaiting_deposit: "info",
confirmed: "info",
fulfilled: "success",
};
const STATUS_BADGE_LABEL: Record<string, string> = {
pending: "Pending",
awaiting_deposit: "Awaiting Deposit",
confirmed: "Confirmed",
fulfilled: "Fulfilled",
};
function isValidEmail(e: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
}
export default function WholesaleClient({ brandId }: { brandId: string }) {
const [tab, setTab] = useState<Tab>("dashboard");
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
@@ -868,6 +886,21 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
const [overrides, setOverrides] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
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);
};
}, [onClose]);
useEffect(() => {
getWholesaleCustomerPricing(customer.id).then(pricing => {
@@ -892,14 +925,10 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
}
return (
<div
role="dialog"
aria-modal="true"
<dialog
ref={dialogRef}
aria-label="Pricing overrides"
tabIndex={-1}
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={(e) => e.target === e.currentTarget && onClose()}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 m-0 p-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col border border-[var(--admin-border)]">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
@@ -974,7 +1003,7 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
</p>
</div>
</div>
</div>
</dialog>
);
}
@@ -994,6 +1023,21 @@ function PriceSheetModal({
const [subject, setSubject] = useState(defaultSubject);
const [note, setNote] = useState("");
const [sending, setSending] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
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);
};
}, [onClose]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -1003,16 +1047,12 @@ function PriceSheetModal({
}
return (
<div
role="dialog"
aria-modal="true"
<dialog
ref={dialogRef}
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(); }}
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/40"
>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]" onClick={e => e.stopPropagation()}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
@@ -1071,7 +1111,7 @@ function PriceSheetModal({
</div>
</form>
</div>
</div>
</dialog>
);
}
@@ -2303,10 +2343,6 @@ function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => v
const [name, setName] = useState("");
const [error, setError] = useState("");
function isValidEmail(e: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
}
function handleAdd() {
if (!email.trim()) return;
if (!isValidEmail(email.trim())) {
@@ -2398,21 +2434,9 @@ function WholesaleLoadingSkeleton() {
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
pending: "warning",
awaiting_deposit: "info",
confirmed: "info",
fulfilled: "success",
};
const label: Record<string, string> = {
pending: "Pending",
awaiting_deposit: "Awaiting Deposit",
confirmed: "Confirmed",
fulfilled: "Fulfilled",
};
return (
<AdminBadge variant={map[status] ?? "default"}>
{label[status] ?? status}
<AdminBadge variant={STATUS_BADGE_MAP[status] ?? "default"}>
{STATUS_BADGE_LABEL[status] ?? status}
</AdminBadge>
);
}