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
-82
View File
@@ -108,85 +108,3 @@ await getSession(); const adminUser = await getAdminUser();
} }
} }
export async function upsertSegment(params: {
id?: string;
brand_id: string;
name: string;
description?: string;
rules: AudienceRules;
}): Promise<UpsertSegmentResult> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
return { success: false, error: "Not authorized" };
}
try {
const segments = await loadSegments(params.brand_id);
const now = new Date().toISOString();
let saved: Segment;
if (params.id) {
const idx = segments.findIndex((s) => s.id === params.id);
if (idx === -1) {
return { success: false, error: "Segment not found" };
}
saved = {
...segments[idx],
name: params.name,
description: params.description ?? null,
rules: params.rules,
updated_at: now,
};
segments[idx] = saved;
} else {
saved = {
id: crypto.randomUUID(),
brand_id: params.brand_id,
name: params.name,
description: params.description ?? null,
rules: params.rules,
created_by: adminUser.id,
created_at: now,
updated_at: now,
};
segments.push(saved);
}
await saveSegments(params.brand_id, segments);
return { success: true, segment: saved };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to save segment",
};
}
}
export async function deleteSegment(
segmentId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, error: "Not authorized" };
}
try {
const segments = await loadSegments(brandId);
const filtered = segments.filter((s) => s.id !== segmentId);
if (filtered.length === segments.length) {
return { success: false, error: "Segment not found" };
}
await saveSegments(brandId, filtered);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to delete segment",
};
}
}
+2 -1
View File
@@ -13,6 +13,8 @@ const ENTITY_LABELS: Record<string, string> = {
unknown: "Unknown", 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", 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", "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", "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 stepIndex = ["upload", "analysis", "preview", "import"].indexOf(step);
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
return ( return (
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden"> <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 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() { export default async function AdminPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const isStoreEmployee = adminUser?.role === "store_employee"; const isStoreEmployee = adminUser?.role === "store_employee";
@@ -62,15 +72,6 @@ export default async function AdminPage() {
enabledAddons = o.enabledAddons; enabledAddons = o.enabledAddons;
} else { } else {
// Fallback to per-feature flag check (matches prior behavior) // 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( const featureFlags = await Promise.all(
featureKeys.map((key) => isFeatureEnabled(dashboardBrandId, key)), featureKeys.map((key) => isFeatureEnabled(dashboardBrandId, key)),
); );
+31 -31
View File
@@ -22,6 +22,37 @@ type ProductRow = {
available_until: string | null; 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() { export default async function AdminProductsPage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -61,37 +92,6 @@ export default async function AdminProductsPage() {
let productsList: ProductRow[] = []; let productsList: ProductRow[] = [];
let queryError: string | null = null; let queryError: string | null = null;
try { 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 const rows = isPlatformAdmin
? await withPlatformAdmin((db) => baseQuery(db)) ? await withPlatformAdmin((db) => baseQuery(db))
: brandId : brandId
+25 -38
View File
@@ -5,6 +5,29 @@ import Link from "next/link";
import { AdminCard } from "@/components/admin/design-system"; import { AdminCard } from "@/components/admin/design-system";
import { setAIProviderSettings } from "@/actions/integrations/ai-providers"; 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 ───────────────────────────────────────────────────── // ── Header Icon Component ─────────────────────────────────────────────────────
function AIHeaderIcon() { function AIHeaderIcon() {
@@ -662,15 +685,6 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
const [result, setResult] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null); const [result, setResult] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null);
const [error, setError] = useState<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() { async function handleExplain() {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -692,10 +706,6 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
} }
} }
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
return ( return (
<div className="space-y-4"> <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)' }}> <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 addTier() { setPriceTiers([...priceTiers, { tier: "", price: "" }]); }
function removeTier(i: number) { setPriceTiers(priceTiers.filter((_, idx) => idx !== i)); } function removeTier(i: number) { setPriceTiers(priceTiers.filter((_, idx) => idx !== i)); }
function updateTier(i: number, field: keyof PriceTier, val: string) { 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 ( return (
<div className="space-y-4"> <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)' }}> <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> </p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
@@ -1203,14 +1206,6 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
} | null>(null); } | null>(null);
const [error, setError] = useState<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) { async function handleAnalyze(nlQuery?: string) {
const q = nlQuery ?? query; const q = nlQuery ?? query;
if (!q.trim()) return; 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()); const validStops = stops.filter((s) => s.name.trim() && s.city.trim() && s.state.trim());
return ( 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 addRow() { setHistoricalData([...historicalData, { date: "", quantity_sold: "", stop: "" }]); }
function removeRow(i: number) { setHistoricalData(historicalData.filter((_, idx) => idx !== i)); } function removeRow(i: number) { setHistoricalData(historicalData.filter((_, idx) => idx !== i)); }
function updateRow(i: number, field: keyof ForecastEntry, val: string) { function updateRow(i: number, field: keyof ForecastEntry, val: string) {
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@@ -12,6 +12,20 @@ import {
} from "@/actions/water-log/admin"; } from "@/actions/water-log/admin";
import { AdminButton } from "@/components/admin/design-system"; 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 = { type Headgate = {
id: string; id: string;
name: string; name: string;
@@ -122,6 +136,22 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
const [deletingId, setDeletingId] = useState<string | null>(null); 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) { async function handleDelete(hg: Headgate) {
if (!window.confirm(`Delete headgate "${hg.name}"? Existing log entries will be preserved.`)) return; if (!window.confirm(`Delete headgate "${hg.name}"? Existing log entries will be preserved.`)) return;
setDeletingId(hg.id); 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 ( return (
<div className="min-h-screen bg-[var(--admin-bg)]"> <div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */} {/* Header */}
<div className="bg-white border-b border-[var(--admin-border)] px-6 py-4"> <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 */} {/* Edit Modal */}
{editHg && ( {editHg && (
<div <dialog
role="dialog" ref={editDialogRef}
aria-modal="true"
aria-label="Edit Headgate" aria-label="Edit Headgate"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 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"
onClick={() => setEditHg(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditHg(null);
}}
> >
<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)]"> <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> <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)]"> <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> </div>
</form> </form>
</div> </div>
</div> </dialog>
)} )}
{/* QR Modal */} {/* 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 }) { function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => void; onRegenerate: (hg: Headgate) => void }) {
const [tab, setTab] = useState<"preview" | "print" | "download">("preview"); const [tab, setTab] = useState<"preview" | "print" | "download">("preview");
const [loading, setLoading] = useState(false); 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 code = hg.name.replace(/\s+/g, "-").toUpperCase().slice(0, 6) + "-1";
const qrUrl = `/api/water-qr?token=${hg.headgate_token}&size=360`; 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 ( return (
<div <dialog
role="dialog" ref={dialogRef}
aria-modal="true"
aria-label="Headgate details" aria-label="Headgate details"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 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"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
> >
<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 */} {/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]"> <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>
</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 }) { export default function WholesaleClient({ brandId }: { brandId: string }) {
const [tab, setTab] = useState<Tab>("dashboard"); const [tab, setTab] = useState<Tab>("dashboard");
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null); 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 [overrides, setOverrides] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); 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(() => { useEffect(() => {
getWholesaleCustomerPricing(customer.id).then(pricing => { getWholesaleCustomerPricing(customer.id).then(pricing => {
@@ -892,14 +925,10 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
} }
return ( return (
<div <dialog
role="dialog" ref={dialogRef}
aria-modal="true"
aria-label="Pricing overrides" aria-label="Pricing overrides"
tabIndex={-1} 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"
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(); }}
> >
<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="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"> <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> </p>
</div> </div>
</div> </div>
</div> </dialog>
); );
} }
@@ -994,6 +1023,21 @@ function PriceSheetModal({
const [subject, setSubject] = useState(defaultSubject); const [subject, setSubject] = useState(defaultSubject);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [sending, setSending] = useState(false); 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) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -1003,16 +1047,12 @@ function PriceSheetModal({
} }
return ( return (
<div <dialog
role="dialog" ref={dialogRef}
aria-modal="true"
aria-label="Send price sheet" aria-label="Send price sheet"
tabIndex={-1} 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"
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="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 className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2> <h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Send Price Sheet</h2>
@@ -1071,7 +1111,7 @@ function PriceSheetModal({
</div> </div>
</form> </form>
</div> </div>
</div> </dialog>
); );
} }
@@ -2303,10 +2343,6 @@ function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => v
const [name, setName] = useState(""); const [name, setName] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
function isValidEmail(e: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
}
function handleAdd() { function handleAdd() {
if (!email.trim()) return; if (!email.trim()) return;
if (!isValidEmail(email.trim())) { if (!isValidEmail(email.trim())) {
@@ -2398,21 +2434,9 @@ function WholesaleLoadingSkeleton() {
} }
function StatusBadge({ status }: { status: string }) { 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 ( return (
<AdminBadge variant={map[status] ?? "default"}> <AdminBadge variant={STATUS_BADGE_MAP[status] ?? "default"}>
{label[status] ?? status} {STATUS_BADGE_LABEL[status] ?? status}
</AdminBadge> </AdminBadge>
); );
} }
+3 -2
View File
@@ -15,6 +15,8 @@ import { withDb, withPlatformAdmin } from "@/db/client";
import { orders } from "@/db/schema/orders"; import { orders } from "@/db/schema/orders";
import { customers } from "@/db/schema/customers"; import { customers } from "@/db/schema/customers";
const REPORT_HEADERS = ["id", "customer_name", "customer_email", "status", "total_cents", "placed_at"];
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) { if (!adminUser) {
@@ -74,8 +76,7 @@ export async function GET(req: NextRequest) {
); );
if (format === "csv") { if (format === "csv") {
const headers = ["id", "customer_name", "customer_email", "status", "total_cents", "placed_at"]; const csvRows = [REPORT_HEADERS.join(",")];
const csvRows = [headers.join(",")];
for (const r of rows) { for (const r of rows) {
csvRows.push( csvRows.push(
[ [
+13 -8
View File
@@ -29,6 +29,15 @@ function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
} }
const QUICKBOOKS_HEADERS = ["Employee Name", "Date", "Clock In", "Clock Out", "Hours", "Task / Service Item", "Overtime Hours", "Notes"];
const PAYROLL_HEADERS = ["Worker Name", "Role", "Days Worked", "Total Hours", "Regular Hours", "Overtime Hours", "Tasks"];
const SUMMARY_HEADERS = ["Brand", "Worker Name", "Pay Period Start", "Pay Period End", "Total Hours", "Daily OT Hours", "Weekly OT Hours", "Status"];
const DETAILED_HEADERS = [
"Worker Name", "Task", "Date", "Clock In", "Clock Out",
"Lunch (min)", "Total Minutes", "Hours", "Overtime Hours",
"Submitted Via", "Notes", "Brand",
];
function csvRow(values: (string | number | boolean | null)[]): string { function csvRow(values: (string | number | boolean | null)[]): string {
return values.map(v => `"${String(v ?? "").replace(/"/g, '""')}"`).join(","); return values.map(v => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",");
} }
@@ -128,7 +137,7 @@ export async function GET(req: NextRequest) {
if (exportType === "quickbooks") { if (exportType === "quickbooks") {
// QuickBooks Time import format // QuickBooks Time import format
// Columns: Employee Name, Date, Clock In, Clock Out, Hours, Task/Service Item, Overtime Hours, Notes // Columns: Employee Name, Date, Clock In, Clock Out, Hours, Task/Service Item, Overtime Hours, Notes
const headers = ["Employee Name", "Date", "Clock In", "Clock Out", "Hours", "Task / Service Item", "Overtime Hours", "Notes"]; const headers = QUICKBOOKS_HEADERS;
const rows = allLogs.map((log, i) => { const rows = allLogs.map((log, i) => {
const worker = allWorkers.flat().find(w => w.id === log.worker_id); const worker = allWorkers.flat().find(w => w.id === log.worker_id);
const settings = allSettings.find(s => s && s.brand_id === log.brandId); const settings = allSettings.find(s => s && s.brand_id === log.brandId);
@@ -174,7 +183,7 @@ export async function GET(req: NextRequest) {
entry.days.add(formatDate(log.clock_in)); entry.days.add(formatDate(log.clock_in));
entry.tasks.add(log.task_name); entry.tasks.add(log.task_name);
} }
const headers = ["Worker Name", "Role", "Days Worked", "Total Hours", "Regular Hours", "Overtime Hours", "Tasks"]; const headers = PAYROLL_HEADERS;
const rows = [...workerMap.values()].map(e => { const rows = [...workerMap.values()].map(e => {
const totalH = e.totalMin / 60; const totalH = e.totalMin / 60;
const otH = Math.max(0, totalH - 40); // weekly OT at 40h const otH = Math.max(0, totalH - 40); // weekly OT at 40h
@@ -195,7 +204,7 @@ export async function GET(req: NextRequest) {
else if (exportType === "summary") { else if (exportType === "summary") {
// Pay period summary — one row per worker per pay period // Pay period summary — one row per worker per pay period
const brandName = allSettings[0]?.brand_name ?? "Farm"; const brandName = allSettings[0]?.brand_name ?? "Farm";
const headers = ["Brand", "Worker Name", "Pay Period Start", "Pay Period End", "Total Hours", "Daily OT Hours", "Weekly OT Hours", "Status"]; const headers = SUMMARY_HEADERS;
const settingsByBrand = new Map<string, NonNullable<typeof allSettings[number]>>(); const settingsByBrand = new Map<string, NonNullable<typeof allSettings[number]>>();
for (const s of allSettings) { for (const s of allSettings) {
if (s && s.brand_id) settingsByBrand.set(s.brand_id, s); if (s && s.brand_id) settingsByBrand.set(s.brand_id, s);
@@ -240,11 +249,7 @@ export async function GET(req: NextRequest) {
} }
else { else {
// Detailed audit log // Detailed audit log
const headers = [ const headers = DETAILED_HEADERS;
"Worker Name", "Task", "Date", "Clock In", "Clock Out",
"Lunch (min)", "Total Minutes", "Hours", "Overtime Hours",
"Submitted Via", "Notes", "Brand",
];
const rows = allLogs.map(log => { const rows = allLogs.map(log => {
const settings = allSettings.find(s => s && s.brand_id === log.brandId); const settings = allSettings.find(s => s && s.brand_id === log.brandId);
const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 }; const { regularHours, overtimeHours } = settings ? computeOvertime(log, settings) : { regularHours: 0, overtimeHours: 0 };
+18 -15
View File
@@ -14,6 +14,23 @@ import type { WaterLogReportRow } from "@/lib/water-log-reporting";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const WATER_LOG_HEADERS = [
"When",
"Headgate",
"User",
"Measurement",
"Unit",
"Total Gallons",
"Method",
"Notes",
"Via",
"Photo URL",
];
function esc(s: string | null | undefined) {
return `"${(s ?? "").replace(/"/g, '""')}"`;
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) { if (!adminUser) {
@@ -43,21 +60,7 @@ export async function GET(request: NextRequest) {
})); }));
if (format === "csv") { if (format === "csv") {
const headers = [ const lines: string[] = [WATER_LOG_HEADERS.join(",")];
"When",
"Headgate",
"User",
"Measurement",
"Unit",
"Total Gallons",
"Method",
"Notes",
"Via",
"Photo URL",
];
const esc = (s: string | null | undefined) =>
`"${(s ?? "").replace(/"/g, '""')}"`;
const lines: string[] = [headers.join(",")];
for (const e of raw) { for (const e of raw) {
lines.push( lines.push(
[ [
+22 -6
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useCart } from "@/context/CartContext"; import { useCart } from "@/context/CartContext";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
@@ -88,6 +88,23 @@ export default function CartClient() {
window.location.href = "/checkout"; window.location.href = "/checkout";
}, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]); }, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]);
const stopPickerDialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!showStopPicker) return;
const dialog = stopPickerDialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
setShowStopPicker(false);
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [showStopPicker]);
return ( return (
<div className="min-h-screen relative"> <div className="min-h-screen relative">
{/* Background */} {/* Background */}
@@ -208,11 +225,10 @@ export default function CartClient() {
{/* Stop picker modal */} {/* Stop picker modal */}
{showStopPicker && ( {showStopPicker && (
<div <dialog
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 pointer-events-none" ref={stopPickerDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="stop-picker-title" aria-labelledby="stop-picker-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 m-0 max-w-none max-h-none w-full h-full backdrop:bg-black/60"
> >
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10"> <div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3> <h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
@@ -245,7 +261,7 @@ export default function CartClient() {
Cancel Cancel
</button> </button>
</div> </div>
</div> </dialog>
)} )}
{/* Cart items */} {/* Cart items */}
+5 -4
View File
@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
import { getTraceChain, getLotIdByNumber } from "@/actions/route-trace/lots"; import { getTraceChain, getLotIdByNumber } from "@/actions/route-trace/lots";
import ShareTraceButton from "@/components/route-trace/ShareTraceButton"; import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
const STATUS_ORDER = ["active", "in_transit", "at_shed", "packed", "delivered"];
export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) {
const { lotNumber } = await params; const { lotNumber } = await params;
@@ -77,8 +79,7 @@ export default async function TracePage({ params }: { params: Promise<{ lotNumbe
const { lot, events, orders } = result.chain; const { lot, events, orders } = result.chain;
const statusOrder = ["active", "in_transit", "at_shed", "packed", "delivered"]; const currentStepIndex = STATUS_ORDER.indexOf(lot.status);
const currentStepIndex = statusOrder.indexOf(lot.status);
const progressPercent = currentStepIndex >= 0 ? ((currentStepIndex + 1) / 5) * 100 : 0; const progressPercent = currentStepIndex >= 0 ? ((currentStepIndex + 1) / 5) * 100 : 0;
const totalQuantity = Number(lot.quantity_lbs ?? 0); const totalQuantity = Number(lot.quantity_lbs ?? 0);
@@ -230,12 +231,12 @@ export default async function TracePage({ params }: { params: Promise<{ lotNumbe
<div key={i} className="flex-1 flex flex-col items-center gap-1"> <div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-base transition-all ${ <div className={`h-9 w-9 rounded-full flex items-center justify-center text-base transition-all ${
done ? "bg-green-500 text-white shadow-sm" : done ? "bg-green-500 text-white shadow-sm" :
active ? `${STEP_COLORS[statusOrder[i]] ?? "bg-stone-400"} text-white shadow-sm ring-3 ring-white ring-offset-1` : active ? `${STEP_COLORS[STATUS_ORDER[i]] ?? "bg-stone-400"} text-white shadow-sm ring-3 ring-white ring-offset-1` :
"bg-stone-100 text-stone-300" "bg-stone-100 text-stone-300"
}`}> }`}>
{done ? <CheckIcon className="w-3.5 h-3.5 text-white" /> : icon} {done ? <CheckIcon className="w-3.5 h-3.5 text-white" /> : icon}
</div> </div>
<div className={`h-0.5 w-full rounded-full ${done ? "bg-green-400" : active ? (STEP_COLORS[statusOrder[i]] ?? "bg-stone-300") : "bg-stone-100"}`} /> <div className={`h-0.5 w-full rounded-full ${done ? "bg-green-400" : active ? (STEP_COLORS[STATUS_ORDER[i]] ?? "bg-stone-300") : "bg-stone-100"}`} />
</div> </div>
); );
})} })}
+10 -10
View File
@@ -13,6 +13,16 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider"; import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations"; import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
function scrollToStops() {
document.getElementById("stops")?.scrollIntoView({ behavior: "smooth" });
}
function scrollToStory() {
document.getElementById("story")?.scrollIntoView({ behavior: "smooth" });
}
function scrollToProducts() {
document.getElementById("products")?.scrollIntoView({ behavior: "smooth" });
}
import { getBrandSettingsPublic } from "@/actions/brand-settings"; import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { gsap } from "gsap"; import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger"; import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -495,16 +505,6 @@ export default function TuxedoPage() {
load(); load();
}, []); }, []);
function scrollToStops() {
document.getElementById("stops")?.scrollIntoView({ behavior: "smooth" });
}
function scrollToStory() {
document.getElementById("story")?.scrollIntoView({ behavior: "smooth" });
}
function scrollToProducts() {
document.getElementById("products")?.scrollIntoView({ behavior: "smooth" });
}
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────── // ─── GSAP SCROLL ANIMATIONS ──────────────────────────────────────────────
// Now explicitly scoped + resilient guards (no more "invalid scope" warnings) // Now explicitly scoped + resilient guards (no more "invalid scope" warnings)
useEffect(() => { useEffect(() => {
@@ -22,16 +22,17 @@ type Props = {
type Queue = "past_due" | "today" | "upcoming"; type Queue = "past_due" | "today" | "upcoming";
const STATUS_BADGE_MAP: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-700",
awaiting_deposit: "bg-purple-100 text-purple-700",
confirmed: "bg-blue-100 text-blue-700",
fulfilled: "bg-green-100 text-green-700",
cancelled: "bg-red-100 text-red-700",
};
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-700",
awaiting_deposit: "bg-purple-100 text-purple-700",
confirmed: "bg-blue-100 text-blue-700",
fulfilled: "bg-green-100 text-green-700",
cancelled: "bg-red-100 text-red-700",
};
return ( return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${map[status] ?? "bg-slate-100 text-slate-600"}`}> <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE_MAP[status] ?? "bg-slate-100 text-slate-600"}`}>
{status.replace(/_/g, " ")} {status.replace(/_/g, " ")}
</span> </span>
); );
+4 -4
View File
@@ -226,11 +226,11 @@ export default function AIProviderPanel({ brandId }: Props) {
</div> </div>
<div className="p-4 sm:p-6 space-y-4"> <div className="p-4 sm:p-6 space-y-4">
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5"> <label htmlFor="fld-3-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key API Key
</label> </label>
<div className="relative"> <div className="relative">
<input aria-label="Input" <input id="fld-3-api-key" aria-label="Input"
type={showKey ? "text" : "password"} type={showKey ? "text" : "password"}
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
@@ -276,9 +276,9 @@ export default function AIProviderPanel({ brandId }: Props) {
</div> </div>
<div className="p-4 sm:p-6 space-y-4"> <div className="p-4 sm:p-6 space-y-4">
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label> <label htmlFor="fld-4-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
<div className="relative"> <div className="relative">
<input aria-label="Sk ..." <input id="fld-4-api-key" aria-label="Sk ..."
type={showKey ? "text" : "password"} type={showKey ? "text" : "password"}
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
+17 -17
View File
@@ -4,6 +4,23 @@ import { useState, useCallback } from "react";
import { createLocation } from "@/actions/locations"; import { createLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal"; import GlassModal from "@/components/admin/GlassModal";
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
function handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
}
function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
}
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -82,23 +99,6 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
[brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose] [brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, reset, onClose]
); );
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
+4 -4
View File
@@ -11,6 +11,10 @@ import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { signOutAction } from "@/actions/auth-actions"; import { signOutAction } from "@/actions/auth-actions";
async function handleLogout() {
await signOutAction();
}
type AdminHeaderProps = { type AdminHeaderProps = {
userRole?: string | null; userRole?: string | null;
canManageUsers?: boolean; canManageUsers?: boolean;
@@ -67,10 +71,6 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin"; const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
const homeLabel = isStoreEmployee ? "Pickup" : "Admin"; const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
async function handleLogout() {
await signOutAction();
}
const roleLabel = userRole === "platform_admin" ? "Platform Admin" const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin" : userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee" : userRole === "store_employee" ? "Store Employee"
+4 -4
View File
@@ -29,6 +29,10 @@ import { signOutAction } from "@/actions/auth-actions";
import BrandSelector from "@/components/admin/BrandSelector"; import BrandSelector from "@/components/admin/BrandSelector";
import SideNavGroup from "@/components/admin/SideNavGroup"; import SideNavGroup from "@/components/admin/SideNavGroup";
async function handleLogout() {
await signOutAction();
}
// Sidebar tokens used (from src/styles/admin-design-system.css): // Sidebar tokens used (from src/styles/admin-design-system.css):
// --admin-sidebar-bg (#2A2520) // --admin-sidebar-bg (#2A2520)
// --admin-sidebar-text (#B8B4A8) // --admin-sidebar-text (#B8B4A8)
@@ -273,10 +277,6 @@ export default function AdminSidebar({
[router, mobileOpen, closeMobileMenu, visibleItems], [router, mobileOpen, closeMobileMenu, visibleItems],
); );
async function handleLogout() {
await signOutAction();
}
// Shared nav-link renderer. Used by both the desktop sidebar and the mobile // Shared nav-link renderer. Used by both the desktop sidebar and the mobile
// slide-in panel — they're the same list, just in a different container. // slide-in panel — they're the same list, just in a different container.
const renderNavLink = (item: NavItemDef, index: number) => { const renderNavLink = (item: NavItemDef, index: number) => {
+8 -8
View File
@@ -389,11 +389,11 @@ export default function AdvancedAIPanel({ brandId }: Props) {
<div className="p-4 sm:p-6 space-y-4"> <div className="p-4 sm:p-6 space-y-4">
{/* API Key */} {/* API Key */}
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5"> <label htmlFor="fld-1-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key <span className="text-red-500">*</span> API Key <span className="text-red-500">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<input aria-label="Input" <input id="fld-1-api-key" aria-label="Input"
type={showApiKey ? "text" : "password"} type={showApiKey ? "text" : "password"}
value={settings.apiKey} value={settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)} onChange={(e) => handleChange("apiKey", e.target.value)}
@@ -417,10 +417,10 @@ export default function AdvancedAIPanel({ brandId }: Props) {
{/* Organization ID (OpenAI only) */} {/* Organization ID (OpenAI only) */}
{currentProvider.hasOrgId && ( {currentProvider.hasOrgId && (
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5"> <label htmlFor="fld-2-organization-id" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Organization ID <span className="text-stone-400">(optional)</span> Organization ID <span className="text-stone-400">(optional)</span>
</label> </label>
<input aria-label="Org ..." <input id="fld-2-organization-id" aria-label="Org ..."
type="text" type="text"
value={settings.orgId} value={settings.orgId}
onChange={(e) => handleChange("orgId", e.target.value)} onChange={(e) => handleChange("orgId", e.target.value)}
@@ -445,11 +445,11 @@ export default function AdvancedAIPanel({ brandId }: Props) {
</div> </div>
<div className="p-4 sm:p-6 space-y-4"> <div className="p-4 sm:p-6 space-y-4">
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5"> <label htmlFor="fld-3-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key <span className="text-red-500">*</span> API Key <span className="text-red-500">*</span>
</label> </label>
<div className="relative"> <div className="relative">
<input aria-label="Sk ..." <input id="fld-3-api-key" aria-label="Sk ..."
type={showApiKey ? "text" : "password"} type={showApiKey ? "text" : "password"}
value={settings.apiKey} value={settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)} onChange={(e) => handleChange("apiKey", e.target.value)}
@@ -462,10 +462,10 @@ export default function AdvancedAIPanel({ brandId }: Props) {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5"> <label htmlFor="fld-4-base-url" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Base URL <span className="text-red-500">*</span> Base URL <span className="text-red-500">*</span>
</label> </label>
<input aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1" <input id="fld-4-base-url" aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
type="text" type="text"
value={settings.customEndpoint} value={settings.customEndpoint}
onChange={(e) => handleChange("customEndpoint", e.target.value)} onChange={(e) => handleChange("customEndpoint", e.target.value)}
@@ -198,9 +198,9 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
<div className="p-4 sm:p-6 space-y-4"> <div className="p-4 sm:p-6 space-y-4">
{/* API Key */} {/* API Key */}
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label> <label htmlFor="fld-1-api-key" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
<div className="relative"> <div className="relative">
<input aria-label="Re ..." <input id="fld-1-api-key" aria-label="Re ..."
type={showSecrets.resendKey ? "text" : "password"} type={showSecrets.resendKey ? "text" : "password"}
value={credentials.resend.api_key} value={credentials.resend.api_key}
onChange={(e) => setCredentials((p) => ({ onChange={(e) => setCredentials((p) => ({
@@ -307,9 +307,9 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
</div> </div>
{/* Auth Token */} {/* Auth Token */}
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label> <label htmlFor="fld-5-auth-token" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label>
<div className="relative"> <div className="relative">
<input aria-label="Your Twilio Auth Token" <input id="fld-5-auth-token" aria-label="Your Twilio Auth Token"
type={showSecrets.twilioToken ? "text" : "password"} type={showSecrets.twilioToken ? "text" : "password"}
value={credentials.twilio.auth_token} value={credentials.twilio.auth_token}
onChange={(e) => setCredentials((p) => ({ onChange={(e) => setCredentials((p) => ({
+2 -2
View File
@@ -132,9 +132,9 @@ export default function AdvancedShipping({ brandId }: { brandId: string }) {
{/* API Key / Password */} {/* API Key / Password */}
<div> <div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label> <label htmlFor="fld-3-api-key-password" className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label>
<div className="relative"> <div className="relative">
<input aria-label="Enter API Key Or Password" <input id="fld-3-api-key-password" aria-label="Enter API Key Or Password"
type={showApiKey ? "text" : "password"} type={showApiKey ? "text" : "password"}
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
+6 -6
View File
@@ -20,6 +20,12 @@ import {
type ConversionFunnel, type ConversionFunnel,
} from "@/actions/analytics"; } from "@/actions/analytics";
function getTrend(avgPrice: number) {
if (avgPrice > 10) return "up";
if (avgPrice < 5) return "down";
return "stable";
}
interface MetricCardProps { interface MetricCardProps {
title: string; title: string;
value: string | number; value: string | number;
@@ -147,12 +153,6 @@ function TopProductsTable({ products }: TopProductsTableProps) {
); );
} }
const getTrend = (avgPrice: number) => {
if (avgPrice > 10) return "up";
if (avgPrice < 5) return "down";
return "stable";
};
return ( return (
<div className="bg-white rounded-xl shadow-sm p-6"> <div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Top Products</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Top Products</h2>
+14 -14
View File
@@ -494,15 +494,15 @@ function BrandSettingsFormBody({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Primary Accent</label> <label htmlFor="fld-1-primary-accent-color" className="block text-sm font-medium text-zinc-300 mb-1">Primary Accent</label>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input aria-label="Color" <input id="fld-1-primary-accent-color" aria-label="Color"
type="color" type="color"
value={brandPrimaryColor} value={brandPrimaryColor}
onChange={(e) => setBrandPrimaryColor(e.target.value)} onChange={(e) => setBrandPrimaryColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer" className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/> />
<input aria-label="#16a34a" <input id="fld-1-primary-accent-color-text" aria-label="#16a34a"
type="text" type="text"
value={brandPrimaryColor} value={brandPrimaryColor}
onChange={(e) => setBrandPrimaryColor(e.target.value)} onChange={(e) => setBrandPrimaryColor(e.target.value)}
@@ -513,15 +513,15 @@ function BrandSettingsFormBody({
<p className="mt-1 text-xs text-zinc-500">Buttons, badges, links</p> <p className="mt-1 text-xs text-zinc-500">Buttons, badges, links</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Text Color</label> <label htmlFor="fld-2-text-color" className="block text-sm font-medium text-zinc-300 mb-1">Text Color</label>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input aria-label="Color" <input id="fld-2-text-color" aria-label="Color"
type="color" type="color"
value={brandTextColor} value={brandTextColor}
onChange={(e) => setBrandTextColor(e.target.value)} onChange={(e) => setBrandTextColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer" className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/> />
<input aria-label="#1c1917" <input id="fld-2-text-color-text" aria-label="#1c1917"
type="text" type="text"
value={brandTextColor} value={brandTextColor}
onChange={(e) => setBrandTextColor(e.target.value)} onChange={(e) => setBrandTextColor(e.target.value)}
@@ -532,15 +532,15 @@ function BrandSettingsFormBody({
<p className="mt-1 text-xs text-zinc-500">Headlines, body text</p> <p className="mt-1 text-xs text-zinc-500">Headlines, body text</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Background Color</label> <label htmlFor="fld-3-background-color" className="block text-sm font-medium text-zinc-300 mb-1">Background Color</label>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input aria-label="Color" <input id="fld-3-background-color" aria-label="Color"
type="color" type="color"
value={brandBgColor} value={brandBgColor}
onChange={(e) => setBrandBgColor(e.target.value)} onChange={(e) => setBrandBgColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer" className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/> />
<input aria-label="#fafaf9" <input id="fld-3-background-color-text" aria-label="#fafaf9"
type="text" type="text"
value={brandBgColor} value={brandBgColor}
onChange={(e) => setBrandBgColor(e.target.value)} onChange={(e) => setBrandBgColor(e.target.value)}
@@ -551,15 +551,15 @@ function BrandSettingsFormBody({
<p className="mt-1 text-xs text-zinc-500">Page background (light mode)</p> <p className="mt-1 text-xs text-zinc-500">Page background (light mode)</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Secondary Accent</label> <label htmlFor="fld-4-secondary-accent-color" className="block text-sm font-medium text-zinc-300 mb-1">Secondary Accent</label>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input aria-label="Color" <input id="fld-4-secondary-accent-color" aria-label="Color"
type="color" type="color"
value={brandSecondaryColor} value={brandSecondaryColor}
onChange={(e) => setBrandSecondaryColor(e.target.value)} onChange={(e) => setBrandSecondaryColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer" className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/> />
<input aria-label="#f5f5f4" <input id="fld-4-secondary-accent-color-text" aria-label="#f5f5f4"
type="text" type="text"
value={brandSecondaryColor} value={brandSecondaryColor}
onChange={(e) => setBrandSecondaryColor(e.target.value)} onChange={(e) => setBrandSecondaryColor(e.target.value)}
@@ -617,9 +617,9 @@ function BrandSettingsFormBody({
{collectSalesTax && ( {collectSalesTax && (
<div> <div>
<label className="block text-sm font-medium text-amber-200 mb-1"> <div className="block text-sm font-medium text-amber-200 mb-1">
Nexus States Nexus States
</label> </div>
<p className="mt-1 text-xs text-amber-300/60 mb-2"> <p className="mt-1 text-xs text-amber-300/60 mb-2">
States where you have tax nexus (physical presence). Stripe Tax will calculate tax for ship orders to these states. States where you have tax nexus (physical presence). Stripe Tax will calculate tax for ship orders to these states.
</p> </p>
+34 -36
View File
@@ -190,28 +190,24 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0); const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const router = useRouter(); const router = useRouter();
// Lock body scroll while open; focus the input on next frame. // Native <dialog> handles focus trapping, ESC, and the backdrop.
useEffect(() => { useEffect(() => {
document.body.style.overflow = "hidden"; const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const raf = requestAnimationFrame(() => inputRef.current?.focus()); const raf = requestAnimationFrame(() => inputRef.current?.focus());
const onCancel = (e: Event) => {
e.preventDefault();
onClose();
};
dialog.addEventListener("cancel", onCancel);
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
document.body.style.overflow = ""; dialog.removeEventListener("cancel", onCancel);
}; };
}, []);
// Global Escape while open (covers the case where focus is not on input).
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]); }, [onClose]);
// Filtering // Filtering
@@ -248,24 +244,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
}; };
return ( return (
<div <dialog
role="dialog" ref={dialogRef}
aria-modal="true"
aria-label="Command palette" aria-label="Command palette"
tabIndex={-1} className="m-0 p-0 w-full h-full max-w-none max-h-full bg-transparent"
onClick={onClose}
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
style={{ style={{
position: "fixed", backgroundColor: "transparent",
inset: 0,
zIndex: 60,
backgroundColor: "rgba(26, 24, 20, 0.4)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
paddingTop: "15vh",
animation: "cp-fade-in 180ms ease-out", animation: "cp-fade-in 180ms ease-out",
}} }}
> >
@@ -285,11 +269,24 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
`}</style> `}</style>
<div <div
onClick={(e) => e.stopPropagation()}
style={{ style={{
width: "100%", position: "fixed",
maxWidth: "36rem", inset: 0,
margin: "0 1rem", zIndex: 60,
backgroundColor: "rgba(26, 24, 20, 0.4)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
paddingTop: "15vh",
}}
>
<div
style={{
width: "100%",
maxWidth: "36rem",
margin: "0 1rem",
backgroundColor: "var(--admin-card-bg)", backgroundColor: "var(--admin-card-bg)",
border: "1px solid var(--admin-border)", border: "1px solid var(--admin-border)",
borderRadius: "var(--admin-radius-lg)", borderRadius: "var(--admin-radius-lg)",
@@ -458,6 +455,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
to navigate &nbsp;·&nbsp; to select &nbsp;·&nbsp; esc to close to navigate &nbsp;·&nbsp; to select &nbsp;·&nbsp; esc to close
</div> </div>
</div> </div>
</div> </div>
</dialog>
); );
} }
+6 -6
View File
@@ -6,6 +6,12 @@ import { AdminInput, AdminTextInput } from "@/components/admin/design-system";
type Role = "platform_admin" | "brand_admin" | "store_employee"; type Role = "platform_admin" | "brand_admin" | "store_employee";
const roleDescriptions: Record<Role, string> = {
platform_admin: "Full platform access",
brand_admin: "Brand-level admin",
store_employee: "Pickup and order operations only",
};
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -159,12 +165,6 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
} }
} }
const roleDescriptions: Record<Role, string> = {
platform_admin: "Full platform access",
brand_admin: "Brand-level admin",
store_employee: "Pickup and order operations only",
};
if (!isOpen) return null; if (!isOpen) return null;
// Success state — show temp password + email status, then "Done" closes. // Success state — show temp password + email status, then "Done" closes.
+20 -20
View File
@@ -54,6 +54,26 @@ const GROUP_FILTERS: { id: GroupFilter; label: string }[] = [
{ id: "tools", label: "Tools" }, { id: "tools", label: "Tools" },
]; ];
function formatCurrency(amount: number) {
return currencyFormatter.format(amount);
}
function getStatusBadge(status: string) {
const styles: Record<string, { bg: string; text: string }> = {
pending: { bg: "var(--admin-warning-soft)", text: "var(--admin-warning)" },
processing: { bg: "var(--admin-primary-soft)", text: "var(--admin-accent-text)" },
shipped: { bg: "var(--admin-primary-soft)", text: "var(--admin-primary)" },
};
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
}
const quickActions = [
{ label: "New Order", href: "/admin/orders?new=true", icon: Plus },
{ label: "Add Stop", href: "/admin/stops/new", icon: MapPin },
{ label: "Add Product", href: "/admin/products/new", icon: Package },
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
];
type Props = { type Props = {
brandId: string | null; brandId: string | null;
brandName: string; brandName: string;
@@ -93,19 +113,6 @@ export default function DashboardClient({
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0, products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
}; };
const formatCurrency = (amount: number) => {
return currencyFormatter.format(amount);
};
const getStatusBadge = (status: string) => {
const styles: Record<string, { bg: string; text: string }> = {
pending: { bg: "var(--admin-warning-soft)", text: "var(--admin-warning)" },
processing: { bg: "var(--admin-primary-soft)", text: "var(--admin-accent-text)" },
shipped: { bg: "var(--admin-primary-soft)", text: "var(--admin-primary)" },
};
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
};
// "What needs attention" feed — composes a flat list of items that need action // "What needs attention" feed — composes a flat list of items that need action
const attentionItems: AttentionItem[] = []; const attentionItems: AttentionItem[] = [];
if (stats.todayOrders === 0) { if (stats.todayOrders === 0) {
@@ -158,13 +165,6 @@ export default function DashboardClient({
}); });
} }
const quickActions = [
{ label: "New Order", href: "/admin/orders?new=true", icon: Plus },
{ label: "Add Stop", href: "/admin/stops/new", icon: MapPin },
{ label: "Add Product", href: "/admin/products/new", icon: Package },
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
];
const visibleSections: typeof sections = []; const visibleSections: typeof sections = [];
for (const s of sections) { for (const s of sections) {
if (s.title === "Water Log" && !isWaterLogVisible) continue; if (s.title === "Water Log" && !isWaterLogVisible) continue;
+16 -16
View File
@@ -4,6 +4,22 @@ import { useState, useCallback } from "react";
import { updateLocation } from "@/actions/locations"; import { updateLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal"; import GlassModal from "@/components/admin/GlassModal";
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
function handleFocus(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
}
function handleBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
}
export type LocationForEdit = { export type LocationForEdit = {
id: string; id: string;
brand_id: string; brand_id: string;
@@ -105,22 +121,6 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
[location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, onClose] [location, brandId, name, address, city, stateVal, zip, phone, contactName, contactEmail, notes, onSuccess, onClose]
); );
const inputStyle = {
background: "rgba(0, 0, 0, 0.02)",
border: "1px solid rgba(0, 0, 0, 0.06)",
outline: "none",
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(16, 185, 129, 0.04)";
e.target.style.border = "1px solid rgba(16, 185, 129, 0.5)";
e.target.style.boxShadow = "0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)";
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.target.style.background = "rgba(0, 0, 0, 0.02)";
e.target.style.border = "1px solid rgba(0, 0, 0, 0.06)";
e.target.style.boxShadow = "none";
};
if (!isOpen || !location) return null; if (!isOpen || !location) return null;
return ( return (
+28 -28
View File
@@ -8,6 +8,33 @@ import { uploadProductImage } from "@/actions/products/upload-image";
import { createProduct } from "@/actions/products/create-product"; import { createProduct } from "@/actions/products/create-product";
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system"; import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
type Props = { type Props = {
defaultBrandId?: string; defaultBrandId?: string;
brands?: { id: string; name: string }[]; brands?: { id: string; name: string }[];
@@ -73,33 +100,6 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
} }
} }
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -237,7 +237,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
</AdminInput> </AdminInput>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</label> <div className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p> <p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div <div
+8 -8
View File
@@ -385,7 +385,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
/> />
</div> </div>
<div> <div>
<label className="ha-field-label mb-1">Price</label> <label htmlFor="fld-3-price" className="ha-field-label mb-1">Price</label>
<div className="relative"> <div className="relative">
<span <span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
@@ -393,7 +393,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
> >
$ $
</span> </span>
<input aria-label="Number" <input id="fld-3-price" aria-label="Number"
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
@@ -434,7 +434,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="ha-field-label mb-1.5">Discount Amount</label> <label htmlFor="fld-4-discount-amount" className="ha-field-label mb-1.5">Discount Amount</label>
<div className="relative"> <div className="relative">
<span <span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm" className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
@@ -442,7 +442,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
> >
$ $
</span> </span>
<input aria-label="Number" <input id="fld-4-discount-amount" aria-label="Number"
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
@@ -501,11 +501,11 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Customer fields */} {/* Customer fields */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="ha-field-label mb-1.5"> <label htmlFor="fld-5-name" className="ha-field-label mb-1.5">
<span>Name</span> <span>Name</span>
<span className="ha-field-label-required">*</span> <span className="ha-field-label-required">*</span>
</label> </label>
<input aria-label="Text" <input id="fld-5-name" aria-label="Text"
type="text" type="text"
value={customer_name} value={customer_name}
onChange={(e) => { onChange={(e) => {
@@ -550,7 +550,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Status & pickup */} {/* Status & pickup */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="ha-field-label mb-2">Status</label> <div className="ha-field-label mb-2">Status</div>
<div className="ha-segment"> <div className="ha-segment">
{["pending", "confirmed", "cancelled"].map((s) => ( {["pending", "confirmed", "cancelled"].map((s) => (
<button <button
@@ -568,7 +568,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
</div> </div>
<div> <div>
<label className="ha-field-label mb-2">Pickup</label> <div className="ha-field-label mb-2">Pickup</div>
<button <button
type="button" type="button"
onClick={() => setPickup_complete((v) => !v)} onClick={() => setPickup_complete((v) => !v)}
+27 -27
View File
@@ -8,6 +8,33 @@ import { uploadProductImage, deleteProductImage } from "@/actions/products/uploa
import { updateProduct } from "@/actions/products/update-product"; import { updateProduct } from "@/actions/products/update-product";
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system"; import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
type Brand = { type Brand = {
id: string; id: string;
name: string; name: string;
@@ -154,33 +181,6 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
} }
} }
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
async function handleSave() { async function handleSave() {
if (!name.trim()) { if (!name.trim()) {
setError("Product name is required."); setError("Product name is required.");
+32 -31
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import Image from "next/image"; import Image from "next/image";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -135,24 +135,24 @@ export default function ProductFormModal({
); );
// Body scroll lock + escape key // Body scroll lock + escape key
// useEffectEvent so the latest onClose is always called even though // Native <dialog> handles both focus trapping and ESC; we just open
// it's no longer in the effect's dependency array. // it with showModal() and forward the cancel event to onClose so the
const onCloseEffect = useEffectEvent(() => { // parent's React state stays in sync with the native close behavior.
onClose(); const dialogRef = useRef<HTMLDialogElement>(null);
});
useEffect(() => { useEffect(() => {
if (!open) return; const dialog = dialogRef.current;
const original = document.body.style.overflow; if (!dialog) return;
document.body.style.overflow = "hidden"; if (!dialog.open) dialog.showModal();
const onKey = (e: KeyboardEvent) => { const onCancel = (e: Event) => {
if (e.key === "Escape") onCloseEffect(); e.preventDefault();
onClose();
}; };
window.addEventListener("keydown", onKey); dialog.addEventListener("cancel", onCancel);
return () => { return () => {
document.body.style.overflow = original; dialog.removeEventListener("cancel", onCancel);
window.removeEventListener("keydown", onKey);
}; };
}, [open]); }, [open, onClose]);
const handleFile = useCallback( const handleFile = useCallback(
async (file: File) => { async (file: File) => {
@@ -232,24 +232,24 @@ export default function ProductFormModal({
const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId; const lockedBrandName = brands.find((b) => b.id === lockedBrandId)?.name ?? lockedBrandId;
return createPortal( return createPortal(
<div <dialog
className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop" ref={dialogRef}
onClick={(e) => { aria-label={mode === "add" ? "Add product" : "Edit product"}
if (e.target === e.currentTarget) onClose(); className="m-0 p-0 w-full h-full max-w-none max-h-full bg-transparent"
}} style={{ backgroundColor: "transparent" }}
style={{
backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
}}
> >
<div <div
role="dialog" className="fixed inset-0 z-[80] flex items-center justify-center p-3 sm:p-6 atelier-backdrop"
aria-modal="true" style={{
aria-label={mode === "add" ? "Add product" : "Edit product"} backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 55%, transparent)",
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl flex flex-col overflow-hidden border border-[var(--admin-border)]" backdropFilter: "blur(8px)",
style={{ boxShadow: "0 30px 80px -20px color-mix(in srgb, var(--admin-text-primary) 45%, transparent)" }} WebkitBackdropFilter: "blur(8px)",
}}
> >
<div
className="atelier-enter atelier-canvas relative w-full max-w-5xl max-h-[calc(100vh-1.5rem)] sm:max-h-[calc(100vh-3rem)] rounded-2xl flex flex-col overflow-hidden border border-[var(--admin-border)]"
style={{ boxShadow: "0 30px 80px -20px color-mix(in srgb, var(--admin-text-primary) 45%, transparent)" }}
>
{/* grain overlay */} {/* grain overlay */}
<div className="atelier-grain" aria-hidden /> <div className="atelier-grain" aria-hidden />
@@ -719,7 +719,8 @@ export default function ProductFormModal({
</div> </div>
</div> </div>
</div> </div>
</div>, </div>
</dialog>,
document.body document.body
); );
} }
+47 -32
View File
@@ -37,6 +37,33 @@ const Icons = {
), ),
}; };
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = document.createElement("img");
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
export default function ProductsClient({ export default function ProductsClient({
products, products,
brandId, brandId,
@@ -75,33 +102,6 @@ export default function ProductsClient({
const activeCount = products.filter((p) => p.active).length; const activeCount = products.filter((p) => p.active).length;
const inactiveCount = products.filter((p) => !p.active).length; const inactiveCount = products.filter((p) => !p.active).length;
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = document.createElement("img");
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
const handleUploadImage = useCallback( const handleUploadImage = useCallback(
async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => { async (file: File): Promise<{ success: boolean; imageUrl?: string; error?: string }> => {
const validTypes = ["image/png", "image/jpeg", "image/webp"]; const validTypes = ["image/png", "image/jpeg", "image/webp"];
@@ -381,6 +381,7 @@ function TableView({
const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null); const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({}); const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const popupDialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@@ -389,6 +390,21 @@ function TableView({
init(); init();
}, []); }, []);
useEffect(() => {
if (!deleteConfirm) return;
const dialog = popupDialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
onDeleteCancel();
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [deleteConfirm, onDeleteCancel]);
// Track the previous deleteConfirm value so we can adjust menuPos // Track the previous deleteConfirm value so we can adjust menuPos
// inline during render when the prop changes — avoids a stale frame // inline during render when the prop changes — avoids a stale frame
// between the prop change and the effect running. Use a lazy // between the prop change and the effect running. Use a lazy
@@ -540,12 +556,11 @@ function TableView({
<> <>
<div <div
className="fixed inset-0 z-[60]" className="fixed inset-0 z-[60]"
onClick={onDeleteCancel}
/> />
<div <dialog
role="dialog" ref={popupDialogRef}
aria-label="Confirm delete" aria-label="Confirm delete"
className="fixed z-[70] w-72 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] shadow-xl p-4" className="fixed z-[70] m-0 p-4 max-w-none max-h-none w-72 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] shadow-xl"
style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }} style={{ top: menuPos.top, left: menuPos.left, transform: "translateX(-100%)" }}
> >
<p className="text-sm font-semibold text-[var(--admin-text-primary)]"> <p className="text-sm font-semibold text-[var(--admin-text-primary)]">
@@ -571,7 +586,7 @@ function TableView({
{deletingId === openProduct.id ? "..." : "Delete"} {deletingId === openProduct.id ? "..." : "Delete"}
</AdminButton> </AdminButton>
</div> </div>
</div> </dialog>
</>, </>,
document.body document.body
)} )}
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { enqueueAction } from "@/lib/offline/queue"; import { enqueueAction } from "@/lib/offline/queue";
import { syncPending } from "@/lib/offline/sync"; import { syncPending } from "@/lib/offline/sync";
import { dispatchClientAction } from "@/actions/offline-dispatcher"; import { dispatchClientAction } from "@/actions/offline-dispatcher";
@@ -59,6 +59,23 @@ export function StockAdjustButton({ productId, currentStock }: StockAdjustButton
} }
} }
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
setOpen(false);
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [open]);
return ( return (
<> <>
<button <button
@@ -78,18 +95,20 @@ export function StockAdjustButton({ productId, currentStock }: StockAdjustButton
± ±
</button> </button>
{open && ( {open && (
<div role="dialog" aria-label="Adjust stock" className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}> <dialog ref={dialogRef} aria-label="Adjust stock" className="m-0 p-0 max-w-none max-h-none w-full h-full bg-transparent" style={{ backgroundColor: "transparent" }}>
<div className="w-full max-w-[640px] rounded-t-3xl p-6" style={{ backgroundColor: "var(--color-bg)", paddingBottom: "calc(env(safe-area-inset-bottom, 0) + 24px)" }}> <div className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: "rgba(0,0,0,0.4)" }}>
<div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} /> <div className="w-full max-w-[640px] rounded-t-3xl p-6" style={{ backgroundColor: "var(--color-bg)", paddingBottom: "calc(env(safe-area-inset-bottom, 0) + 24px)" }}>
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2> <div className="w-12 h-1.5 rounded-full mx-auto mb-4" style={{ backgroundColor: "var(--color-surface-3)" }} />
<div className="mt-6 flex items-center justify-center gap-6"> <h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Adjust stock</h2>
<button type="button" onClick={() => adjust(-1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-surface-2)", fontSize: "32px", fontWeight: 700 }}></button> <div className="mt-6 flex items-center justify-center gap-6">
<div className="text-display font-display" style={{ fontWeight: 600, minWidth: "100px", textAlign: "center" }}>{stock}</div> <button type="button" onClick={() => adjust(-1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-surface-2)", fontSize: "32px", fontWeight: 700 }}></button>
<button type="button" onClick={() => adjust(1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-accent)", color: "white", fontSize: "32px", fontWeight: 700 }}>+</button> <div className="text-display font-display" style={{ fontWeight: 600, minWidth: "100px", textAlign: "center" }}>{stock}</div>
<button type="button" onClick={() => adjust(1)} disabled={pending} className="rounded-full" style={{ width: "64px", height: "64px", backgroundColor: "var(--color-accent)", color: "white", fontSize: "32px", fontWeight: 700 }}>+</button>
</div>
<button type="button" onClick={() => setOpen(false)} className="w-full mt-6 rounded-xl text-label font-semibold" style={{ minHeight: "56px", backgroundColor: "var(--color-surface-2)", letterSpacing: "0.02em" }}>Done</button>
</div> </div>
<button type="button" onClick={() => setOpen(false)} className="w-full mt-6 rounded-xl text-label font-semibold" style={{ minHeight: "56px", backgroundColor: "var(--color-surface-2)", letterSpacing: "0.02em" }}>Done</button>
</div> </div>
</div> </dialog>
)} )}
</> </>
); );
+36 -18
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
// One-color outline icons // One-color outline icons
@@ -234,6 +234,23 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
setOpen(false); setOpen(false);
} }
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current;
if (!dialog) return;
if (!dialog.open) dialog.showModal();
const onCancel = (e: Event) => {
e.preventDefault();
setOpen(false);
};
dialog.addEventListener("cancel", onCancel);
return () => {
dialog.removeEventListener("cancel", onCancel);
};
}, [open]);
const filteredLots = data?.lots.filter((lot) => { const filteredLots = data?.lots.filter((lot) => {
const matchesSearch = const matchesSearch =
!search || !search ||
@@ -255,26 +272,26 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
</button> </button>
{open && ( {open && (
<div <dialog
role="dialog" ref={dialogRef}
aria-modal="true"
aria-label="FSMA report" aria-label="FSMA report"
tabIndex={-1} className="m-0 p-0 max-w-none max-h-none w-full h-full bg-transparent"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30" style={{ backgroundColor: "transparent" }}
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
style={{ backdropFilter: "blur(4px)" }}
> >
<div <div
className="absolute inset-0" className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ style={{ backdropFilter: "blur(4px)" }}
background: "linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.2) 100%)", >
backdropFilter: "blur(60px) saturate(180%)", <div
}} className="absolute inset-0"
/> style={{
background: "linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.2) 100%)",
backdropFilter: "blur(60px) saturate(180%)",
}}
/>
{/* Modal card */} {/* Modal card */}
<div <div
className="relative w-full max-w-5xl rounded-2xl max-h-[90vh] flex flex-col" className="relative w-full max-w-5xl rounded-2xl max-h-[90vh] flex flex-col"
style={{ style={{
background: "rgba(255, 255, 255, 0.92)", background: "rgba(255, 255, 255, 0.92)",
@@ -612,7 +629,8 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</dialog>
)} )}
</> </>
); );