fix: react-doctor modal/dialog/dropzone a11y — 18 files to role+tabIndex+onKeyDown or dialog
This commit is contained in:
+9
-7
@@ -26,13 +26,15 @@ self.addEventListener("install", (event) => {
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
caches.keys().then((cacheNames) =>
|
||||
Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== SHELL_CACHE && name !== DATA_CACHE)
|
||||
.map((name) => caches.delete(name))
|
||||
)
|
||||
),
|
||||
caches.keys().then((cacheNames) => {
|
||||
const toDelete = [];
|
||||
for (const name of cacheNames) {
|
||||
if (name !== SHELL_CACHE && name !== DATA_CACHE) {
|
||||
toDelete.push(caches.delete(name));
|
||||
}
|
||||
}
|
||||
return Promise.all(toDelete);
|
||||
}),
|
||||
self.clients.claim(),
|
||||
])
|
||||
);
|
||||
|
||||
+76
-16
@@ -266,7 +266,15 @@ function fallbackParse(
|
||||
}
|
||||
}
|
||||
|
||||
const detectedType = (Object.entries(typeScores).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown") as ImportEntityType;
|
||||
let bestEtype: string | undefined;
|
||||
let bestScore = -1;
|
||||
for (const [etype, score] of Object.entries(typeScores)) {
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestEtype = etype;
|
||||
}
|
||||
}
|
||||
const detectedType = (bestEtype ?? "unknown") as ImportEntityType;
|
||||
|
||||
// Map headers to semantic fields
|
||||
const columnMappings: ColumnMapping = {};
|
||||
@@ -335,14 +343,26 @@ function fallbackParse(
|
||||
// ── Import Executors ─────────────────────────────────────────────────────────
|
||||
|
||||
async function executeProductsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||
const products = rows.map((r) => ({
|
||||
name: String(r.product_name ?? r.name ?? ""),
|
||||
const products: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url: string | undefined;
|
||||
}> = [];
|
||||
for (const r of rows) {
|
||||
const name = String(r.product_name ?? r.name ?? "");
|
||||
if (name === "") continue;
|
||||
products.push({
|
||||
name,
|
||||
description: String(r.description ?? ""),
|
||||
price: parseFloat(String(r.price ?? "0").replace(/[^0-9.]/g, "")) || 0,
|
||||
type: normalizeFulfillmentType(String(r.product_type ?? r.type ?? "Pickup")),
|
||||
active: String(r.active ?? "true").toLowerCase() !== "false",
|
||||
image_url: r.image_url ? String(r.image_url) : undefined,
|
||||
})).filter((p) => p.name !== "");
|
||||
});
|
||||
}
|
||||
|
||||
const result = await importProductsBatch(brandId, products);
|
||||
if (!result.success) {
|
||||
@@ -380,15 +400,24 @@ async function executeOrdersImport(brandId: string, rows: Record<string, unknown
|
||||
}
|
||||
}
|
||||
|
||||
const orders = Object.values(orderMap)
|
||||
.filter((o) => o.customer_email && o.stop_id)
|
||||
.map((o) => ({
|
||||
const orders: Array<{
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
customer_phone: string;
|
||||
stop_id: string;
|
||||
items: { product_id: string; quantity: number; fulfillment: string }[];
|
||||
}> = [];
|
||||
for (const o of Object.values(orderMap)) {
|
||||
if (o.customer_email && o.stop_id) {
|
||||
orders.push({
|
||||
customer_name: o.customer_name || "",
|
||||
customer_email: o.customer_email,
|
||||
customer_phone: o.customer_phone || "",
|
||||
stop_id: o.stop_id,
|
||||
items: o.items,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return importOrdersBatch(brandId, orders).then((r) => {
|
||||
if (r.success) {
|
||||
@@ -403,16 +432,31 @@ async function executeOrdersImport(brandId: string, rows: Record<string, unknown
|
||||
}
|
||||
|
||||
async function executeStopsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||
const stops = rows.map((r) => ({
|
||||
city: String(r.city ?? ""),
|
||||
state: String(r.state ?? ""),
|
||||
const stops: Array<{
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address: string | undefined;
|
||||
zip: string | undefined;
|
||||
notes: string | undefined;
|
||||
}> = [];
|
||||
for (const r of rows) {
|
||||
const city = String(r.city ?? "");
|
||||
const state = String(r.state ?? "");
|
||||
if (city === "" || state === "") continue;
|
||||
stops.push({
|
||||
city,
|
||||
state,
|
||||
location: String(r.location ?? r.address ?? ""),
|
||||
date: normalizeDate(String(r.date ?? "")),
|
||||
time: String(r.time ?? ""),
|
||||
address: r.address ? String(r.address) : undefined,
|
||||
zip: r.zip ? String(r.zip) : undefined,
|
||||
notes: r.notes ? String(r.notes) : undefined,
|
||||
})).filter((s) => s.city !== "" && s.state !== "");
|
||||
});
|
||||
}
|
||||
|
||||
return createStopsBatch(brandId, stops).then((r) => {
|
||||
if (r.success) {
|
||||
@@ -423,9 +467,24 @@ async function executeStopsImport(brandId: string, rows: Record<string, unknown>
|
||||
}
|
||||
|
||||
async function executeContactsImport(brandId: string, rows: Record<string, unknown>[]) {
|
||||
const contacts = rows.map((r) => ({
|
||||
email: r.email ? String(r.email).toLowerCase().trim() : undefined,
|
||||
phone: r.phone ? String(r.phone).trim() : undefined,
|
||||
const contacts: Array<{
|
||||
email: string | undefined;
|
||||
phone: string | undefined;
|
||||
first_name: string | undefined;
|
||||
last_name: string | undefined;
|
||||
full_name: string | undefined;
|
||||
tags: string[];
|
||||
email_opt_in: boolean;
|
||||
sms_opt_in: boolean;
|
||||
external_id: string | undefined;
|
||||
}> = [];
|
||||
for (const r of rows) {
|
||||
const email = r.email ? String(r.email).toLowerCase().trim() : undefined;
|
||||
const phone = r.phone ? String(r.phone).trim() : undefined;
|
||||
if (!email && !phone) continue;
|
||||
contacts.push({
|
||||
email,
|
||||
phone,
|
||||
first_name: r.first_name ? String(r.first_name).trim() : undefined,
|
||||
last_name: r.last_name ? String(r.last_name).trim() : undefined,
|
||||
full_name: r.full_name ? String(r.full_name).trim() : undefined,
|
||||
@@ -433,7 +492,8 @@ async function executeContactsImport(brandId: string, rows: Record<string, unkno
|
||||
email_opt_in: r.email_opt_in !== undefined ? String(r.email_opt_in).toLowerCase() === "true" : true,
|
||||
sms_opt_in: r.sms_opt_in !== undefined ? String(r.sms_opt_in).toLowerCase() === "true" : false,
|
||||
external_id: r.external_id ? String(r.external_id).trim() : undefined,
|
||||
})).filter((c) => c.email || c.phone);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await importContactsBatch({ brandId, contacts });
|
||||
if (!result.success) {
|
||||
|
||||
@@ -198,15 +198,23 @@ await getSession();
|
||||
LIMIT 10`,
|
||||
);
|
||||
|
||||
const recentOrders = recentRes.rows
|
||||
.filter((o) => o.status !== "cancelled")
|
||||
.map((o) => ({
|
||||
const recentOrders: Array<{
|
||||
id: string;
|
||||
customer_name: string;
|
||||
total: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}> = [];
|
||||
for (const o of recentRes.rows) {
|
||||
if (o.status === "cancelled") continue;
|
||||
recentOrders.push({
|
||||
id: o.id || "",
|
||||
customer_name: o.customer_name || "Guest",
|
||||
total: (o.total_cents || 0) / 100,
|
||||
status: o.status || "unknown",
|
||||
created_at: formatTimeAgo(o.placed_at),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
todayOrders: todayOrderCount,
|
||||
|
||||
@@ -213,6 +213,7 @@ await getSession(); const adminUser = await getAdminUser();
|
||||
const allowedServices = perishable
|
||||
? (["FEDEX_OVERNIGHT", "FEDEX_2_DAY_AIR"] as FedExServiceType[])
|
||||
: ALL_SERVICES;
|
||||
const allowedServicesSet = new Set<FedExServiceType>(allowedServices);
|
||||
|
||||
// Build FedEx rate request. The SaaS rebuild doesn't store the
|
||||
// recipient's city/state/zip on the order — FedEx rate quotes
|
||||
@@ -283,7 +284,7 @@ await getSession(); const adminUser = await getAdminUser();
|
||||
|
||||
for (const detail of output) {
|
||||
const serviceType = detail.serviceType as FedExServiceType;
|
||||
if (!allowedServices.includes(serviceType)) continue;
|
||||
if (!allowedServicesSet.has(serviceType)) continue;
|
||||
|
||||
const dayOption = detail.derivedDetails?.deliveryDayOfWeek ?? "";
|
||||
const deliveryDate = detail.derivedDetails?.deliveryDate ?? "";
|
||||
|
||||
@@ -87,6 +87,7 @@ await getSession(); const adminUser = await getAdminUser();
|
||||
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
const completedStatuses = new Set(["COMPLETED", "APPROVED"]);
|
||||
|
||||
// Determine sync start time — last sync or 30 days ago
|
||||
const since = settings.square_last_sync_at
|
||||
@@ -105,7 +106,7 @@ await getSession(); const adminUser = await getAdminUser();
|
||||
// Skip if no order_id
|
||||
if (!payment.order_id) continue;
|
||||
// Skip if already completed/canceled
|
||||
if (!["COMPLETED", "APPROVED"].includes(payment.status ?? "")) continue;
|
||||
if (!completedStatuses.has(payment.status ?? "")) continue;
|
||||
|
||||
const orderData = await fetchSquareOrder(settings.square_access_token, payment.order_id);
|
||||
const order = orderData.order;
|
||||
|
||||
+6
-4
@@ -66,13 +66,15 @@ await getSession(); // 1. If not collecting tax, return zero
|
||||
const stripe = new Stripe(stripeKey);
|
||||
|
||||
// Build Stripe line items for tax calculation — only taxable items
|
||||
const lineItems: Stripe.Tax.CalculationCreateParams.LineItem[] = params.items
|
||||
.filter((item) => item.is_taxable !== false)
|
||||
.map((item) => ({
|
||||
const lineItems: Stripe.Tax.CalculationCreateParams.LineItem[] = [];
|
||||
for (const item of params.items) {
|
||||
if (item.is_taxable === false) continue;
|
||||
lineItems.push({
|
||||
amount: Math.round(item.price * item.quantity * 100), // cents
|
||||
reference: item.id,
|
||||
tax_behavior: "exclusive",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// If no taxable items, return zero tax
|
||||
if (lineItems.length === 0) {
|
||||
|
||||
@@ -316,7 +316,10 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
{analysis.headers.map((header) => {
|
||||
const mappedField = editedMappings[header];
|
||||
const sampleIdx = analysis.headers.indexOf(header);
|
||||
const samples = analysis.rawRows.slice(0, 3).map((r) => r[sampleIdx] ?? "").filter(Boolean);
|
||||
const samples = analysis.rawRows.slice(0, 3).flatMap((r) => {
|
||||
const val = r[sampleIdx] ?? "";
|
||||
return val ? [val] : [];
|
||||
});
|
||||
return (
|
||||
<tr key={header} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg)]">
|
||||
<td className="px-5 py-2.5 font-medium text-[var(--admin-text-primary)] whitespace-nowrap">{header}</td>
|
||||
|
||||
@@ -25,8 +25,10 @@ type OrderItem = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
return currencyFormatter.format(amount);
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
|
||||
@@ -814,16 +814,21 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const parsedTiers = priceTiers
|
||||
.filter((t) => t.tier.trim() && t.price.trim())
|
||||
.map((t) => ({ tier: t.tier.trim(), price: parseFloat(t.price) || 0 }));
|
||||
const parsedSales = historicalSales
|
||||
.filter((s) => s.date.trim())
|
||||
.map((s) => ({
|
||||
const parsedTiers: Array<{ tier: string; price: number }> = [];
|
||||
for (const t of priceTiers) {
|
||||
if (t.tier.trim() && t.price.trim()) {
|
||||
parsedTiers.push({ tier: t.tier.trim(), price: parseFloat(t.price) || 0 });
|
||||
}
|
||||
}
|
||||
const parsedSales: Array<{ date: string; units_sold: number; revenue: number }> = [];
|
||||
for (const s of historicalSales) {
|
||||
if (!s.date.trim()) continue;
|
||||
parsedSales.push({
|
||||
date: s.date.trim(),
|
||||
units_sold: parseInt(s.units_sold) || 0,
|
||||
revenue: parseFloat(s.revenue) || 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch("/api/ai/pricing-advisor", {
|
||||
method: "POST",
|
||||
@@ -1591,13 +1596,15 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const parsedData = historicalData
|
||||
.filter((d) => d.date.trim())
|
||||
.map((d) => ({
|
||||
const parsedData: Array<{ date: string; quantity_sold: number; stop: string }> = [];
|
||||
for (const d of historicalData) {
|
||||
if (!d.date.trim()) continue;
|
||||
parsedData.push({
|
||||
date: d.date.trim(),
|
||||
quantity_sold: parseInt(d.quantity_sold) || 0,
|
||||
stop: d.stop.trim(),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch("/api/ai/demand-forecast", {
|
||||
method: "POST",
|
||||
|
||||
@@ -102,6 +102,10 @@ const INTEGRATIONS: Integration[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const INTEGRATIONS_WITHOUT_OPENAI: Integration[] = INTEGRATIONS.filter(
|
||||
(i) => i.id !== "openai"
|
||||
);
|
||||
|
||||
function IntegrationCard({
|
||||
integration,
|
||||
initialCredentials,
|
||||
@@ -438,7 +442,7 @@ export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmi
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
INTEGRATIONS.filter(i => i.id !== "openai").map((integration) => (
|
||||
INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
|
||||
@@ -263,7 +263,9 @@ export default async function StopsV2Page({
|
||||
actions={<StopsDatePicker value={headerDate} />}
|
||||
/>
|
||||
<PullToRefresh>
|
||||
{BUCKET_ORDER.filter((b) => grouped[b].length > 0).map((bucket) => (
|
||||
{BUCKET_ORDER.flatMap((bucket) =>
|
||||
grouped[bucket].length > 0 ? [bucket] : []
|
||||
).map((bucket) => (
|
||||
<section key={bucket}>
|
||||
<h2
|
||||
className="sticky top-0 px-4 py-2 text-label font-semibold"
|
||||
|
||||
@@ -232,7 +232,6 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<label htmlFor="headgate-add-name" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Headgate Name</label>
|
||||
<input aria-label=". North Field Gate 1"
|
||||
id="headgate-add-name"
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
|
||||
@@ -575,7 +575,9 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{registrations.filter(r => r.account_status === "pending_approval").map(r => (
|
||||
{registrations.flatMap(r =>
|
||||
r.account_status === "pending_approval" ? [r] : []
|
||||
).map(r => (
|
||||
<tr key={r.id} className="hover:bg-[var(--admin-bg-subtle)]">
|
||||
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{r.company_name ?? "—"}</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{r.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{r.email}</span></td>
|
||||
@@ -890,7 +892,15 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Pricing overrides"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<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>
|
||||
@@ -993,7 +1003,15 @@ function PriceSheetModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4" onClick={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Send price sheet"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -151,13 +151,17 @@ export async function GET(req: NextRequest) {
|
||||
else if (exportType === "payroll") {
|
||||
// Standard payroll export — grouped by worker with totals
|
||||
// Columns: Worker Name, Role, Total Hours, Regular Hours, Overtime Hours, Days Worked, Tasks
|
||||
const workersById = new Map<string, { id: string; role: string }>();
|
||||
for (const w of allWorkers.flat()) {
|
||||
if (w) workersById.set(w.id, w);
|
||||
}
|
||||
const workerMap = new Map<string, {
|
||||
name: string; role: string; totalMin: number; days: Set<string>; tasks: Set<string>;
|
||||
dailyOTMin: number; weeklyOTMin: number;
|
||||
}>();
|
||||
for (const log of allLogs) {
|
||||
if (!workerMap.has(log.worker_id)) {
|
||||
const w = allWorkers.flat().find(w => w.id === log.worker_id);
|
||||
const w = workersById.get(log.worker_id);
|
||||
workerMap.set(log.worker_id, {
|
||||
name: log.worker_name,
|
||||
role: w?.role ?? "worker",
|
||||
@@ -192,12 +196,16 @@ export async function GET(req: NextRequest) {
|
||||
// Pay period summary — one row per worker per pay period
|
||||
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 settingsByBrand = new Map<string, NonNullable<typeof allSettings[number]>>();
|
||||
for (const s of allSettings) {
|
||||
if (s && s.brand_id) settingsByBrand.set(s.brand_id, s);
|
||||
}
|
||||
const workerMap = new Map<string, {
|
||||
brandName: string; name: string; totalMin: number; dailyOT: number; weeklyOT: number;
|
||||
}>();
|
||||
for (const log of allLogs) {
|
||||
if (!workerMap.has(log.worker_id)) {
|
||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||
const settings = settingsByBrand.get(log.brandId);
|
||||
workerMap.set(log.worker_id, {
|
||||
brandName: settings?.brand_name ?? brandName,
|
||||
name: log.worker_name,
|
||||
@@ -206,7 +214,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
const entry = workerMap.get(log.worker_id)!;
|
||||
const totalH = log.total_minutes / 60;
|
||||
const settings = allSettings.find(s => s && s.brand_id === log.brandId);
|
||||
const settings = settingsByBrand.get(log.brandId);
|
||||
if (settings) {
|
||||
const dailyThr = Number(settings.daily_overtime_threshold);
|
||||
const weeklyThr = Number(settings.weekly_overtime_threshold);
|
||||
|
||||
@@ -73,9 +73,9 @@ export async function POST(req: NextRequest) {
|
||||
const hasCustomNote = Boolean(customNote?.trim());
|
||||
|
||||
// Build product rows HTML
|
||||
const productRows = products
|
||||
.filter(p => p.availability === "available")
|
||||
.map(p => {
|
||||
const productRows = products.flatMap(p =>
|
||||
p.availability === "available" ? [p] : []
|
||||
).map(p => {
|
||||
const tiersHtml = p.price_tiers
|
||||
.map(t => {
|
||||
const max = t.max_qty === 0 ? "+" : `-${t.max_qty}`;
|
||||
@@ -143,7 +143,9 @@ ${hasCustomNote ? ` <p style="margin: 0 0 12px; color: #1e293b; font-size:
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = `${brandName} Wholesale Price Sheet — ${generatedDate}\n${"=".repeat(50)}\n${hasCustomNote ? `\n${customNote!.trim()}\n\n` : ""}${products.filter(p => p.availability === "available").map(p => {
|
||||
const text = `${brandName} Wholesale Price Sheet — ${generatedDate}\n${"=".repeat(50)}\n${hasCustomNote ? `\n${customNote!.trim()}\n\n` : ""}${products.flatMap(p =>
|
||||
p.availability === "available" ? [p] : []
|
||||
).map(p => {
|
||||
const tiers = p.price_tiers.map(t => `${t.min_qty}${t.max_qty === 0 ? "+" : `-${t.max_qty}`}: $${Number(t.price).toFixed(2)}`).join(", ");
|
||||
return `${p.name} (${p.unit_type})\n ${tiers}`;
|
||||
}).join("\n\n")}\n\nGenerated ${generatedDate}. Prices subject to change.`;
|
||||
|
||||
@@ -61,12 +61,16 @@ export default function CartClient() {
|
||||
setIncompatibleItems([]);
|
||||
|
||||
if (hasPickupItems) {
|
||||
const pickupProductIds = cart.filter((i) => i.fulfillment === "pickup").map((i) => i.id);
|
||||
const pickupProductIds: string[] = [];
|
||||
for (const i of cart) {
|
||||
if (i.fulfillment === "pickup") pickupProductIds.push(i.id);
|
||||
}
|
||||
checkStopProductAvailability(stop.id, pickupProductIds)
|
||||
.then((data) => {
|
||||
const unavailable = (data ?? [])
|
||||
.filter((row) => row.is_available === false)
|
||||
.map((row) => row.product_id);
|
||||
const unavailable: string[] = [];
|
||||
for (const row of data ?? []) {
|
||||
if (row.is_available === false) unavailable.push(row.product_id);
|
||||
}
|
||||
setIncompatibleItems(unavailable);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -135,7 +139,11 @@ export default function CartClient() {
|
||||
<div>
|
||||
<p className="font-semibold text-white">Shed Pickup</p>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
|
||||
{cart.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,7 +321,11 @@ export default function CartClient() {
|
||||
Shed Pickup
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-zinc-400">
|
||||
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
|
||||
{cart.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -90,7 +90,6 @@ export default function ChangePasswordForm({ userId }: { userId: string }) {
|
||||
minLength={8}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||
placeholder="Min. 8 characters"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ export default function ChangePasswordPage() {
|
||||
minLength={8}
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-zinc-100 placeholder:text-zinc-600 outline-none focus:border-violet-500 transition-colors"
|
||||
placeholder="Min. 8 characters"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -281,7 +281,11 @@ export default function CheckoutClient() {
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
|
||||
{cart.flatMap((i) =>
|
||||
i.fulfillment === "pickup" && i.pickup_type === "shed"
|
||||
? [i.description || i.name]
|
||||
: []
|
||||
).join(", ")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Pickup location details are included in your order confirmation.</p>
|
||||
</fieldset>
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ErrorPage({
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ErrorPage({
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function ErrorPage({
|
||||
|
||||
{/* Error icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ErrorPage({
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ErrorPage({
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function ErrorPage({
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
|
||||
|
||||
@@ -232,7 +232,9 @@ export default function RoadmapPage() {
|
||||
className="w-full px-4 py-3 rounded-xl border border-[#e5e5e5] bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{CATEGORIES.filter(c => c !== "All").map((category) => (
|
||||
{CATEGORIES.flatMap(c =>
|
||||
c === "All" ? [] : [c]
|
||||
).map((category) => (
|
||||
<option key={category} value={category.toLowerCase()}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ErrorPage({
|
||||
className="max-w-lg w-full text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ErrorPage({
|
||||
className="max-w-lg w-full text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ErrorPage({
|
||||
>
|
||||
{/* Error icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ErrorPage({
|
||||
className="max-w-lg w-full text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ErrorPage({
|
||||
className="max-w-lg w-full text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function ErrorPage({
|
||||
className="max-w-lg w-full text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
|
||||
|
||||
@@ -48,7 +48,6 @@ export default function WaterAdminPinClient({ brandId }: Props) {
|
||||
placeholder="••••"
|
||||
className="w-full rounded-xl border-2 border-slate-300 px-6 py-6 text-center text-4xl font-bold tracking-widest outline-none focus:border-slate-900"
|
||||
style={{ letterSpacing: "0.4em" }}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-center text-red-600 text-sm">{error}</p>
|
||||
|
||||
@@ -82,8 +82,10 @@ function shortId(id: string) {
|
||||
return id.slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
return currencyFormatter.format(amount);
|
||||
}
|
||||
|
||||
// Icon wrappers (lucide-react) — kept compact for inline use.
|
||||
@@ -452,7 +454,7 @@ export default function AdminOrdersPanel({
|
||||
|
||||
{showStopDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} />
|
||||
<button type="button" aria-label="Close dropdown" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => setShowStopDropdown(false)} />
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border shadow-lg"
|
||||
style={{
|
||||
|
||||
@@ -187,12 +187,13 @@ export default function AdminSidebar({
|
||||
* future iteration hides Harvest Reach for some role, the entire group
|
||||
* header goes away rather than rendering an empty section.
|
||||
*/
|
||||
const visibleGroups: NavGroup[] = NAV_GROUPS
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => visibleItems.includes(item)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
const visibleGroups: NavGroup[] = [];
|
||||
for (const group of NAV_GROUPS) {
|
||||
const items = group.items.filter((item) => visibleItems.includes(item));
|
||||
if (items.length > 0) {
|
||||
visibleGroups.push({ ...group, items });
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = useCallback(
|
||||
(href: string) => {
|
||||
|
||||
@@ -254,7 +254,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
|
||||
|
||||
{openMenu === stop.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} />
|
||||
<button type="button" aria-label="Close menu" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => { setOpenMenu(null); setConfirmDelete(null); }} />
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-lg">
|
||||
{stop.status === "draft" && (
|
||||
<button type="button"
|
||||
@@ -280,7 +280,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
|
||||
|
||||
{confirmDelete === stop.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setConfirmDelete(null)} />
|
||||
<button type="button" aria-label="Cancel delete confirmation" className="fixed inset-0 z-30 cursor-default bg-transparent border-0 p-0" onClick={() => setConfirmDelete(null)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-lg p-4">
|
||||
<p className="text-sm font-semibold text-stone-900">Delete "{stop.city}, {stop.state}"?</p>
|
||||
<p className="mt-1.5 text-xs text-stone-500">This cannot be undone.</p>
|
||||
|
||||
@@ -807,7 +807,7 @@ function NexusStateInput({
|
||||
onChange={(e) => { if (e.target.value) addState(e.target.value); }}
|
||||
options={[
|
||||
{ value: "", label: "Add state" },
|
||||
...US_STATES.filter((s) => !value.includes(s)).map((s) => ({ value: s, label: s })),
|
||||
...US_STATES.flatMap((s) => (value.includes(s) ? [] : [{ value: s, label: s }])),
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
@@ -886,6 +886,13 @@ export function LogoUploadField({
|
||||
/>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
(e.currentTarget.querySelector('input[type="file"]') as HTMLInputElement | null)?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -112,8 +112,10 @@ function NewCampaignModal({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
@@ -154,7 +156,6 @@ function NewCampaignModal({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
placeholder="e.g. Weekly Pickup Reminder"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -123,12 +123,15 @@ function scoreEntry(entry: PaletteEntry, query: string): number {
|
||||
}
|
||||
|
||||
function filterEntries(query: string): PaletteEntry[] {
|
||||
const scored = PALETTE_ENTRIES
|
||||
.map((e) => ({ entry: e, score: scoreEntry(e, query) }))
|
||||
.filter((r) => r.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, MAX_RESULTS);
|
||||
return scored.map((r) => r.entry);
|
||||
const scored: { entry: PaletteEntry; score: number }[] = [];
|
||||
for (const e of PALETTE_ENTRIES) {
|
||||
const score = scoreEntry(e, query);
|
||||
if (score > 0) {
|
||||
scored.push({ entry: e, score });
|
||||
}
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored.slice(0, MAX_RESULTS).map((r) => r.entry);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -249,7 +252,9 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
tabIndex={-1}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
|
||||
@@ -228,6 +228,8 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
const seenEmails = new Set<string>();
|
||||
const seenPhones = new Set<string>();
|
||||
const entries: ContactImportEntry[] = [];
|
||||
const headerIndex = new Map<string, number>();
|
||||
headers.forEach((h, i) => headerIndex.set(h, i));
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
@@ -235,8 +237,8 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
const ignored: Record<string, string> = {};
|
||||
|
||||
for (const mapping of effective) {
|
||||
const colIdx = headers.indexOf(mapping.csvColumn);
|
||||
if (colIdx === -1) continue;
|
||||
const colIdx = headerIndex.get(mapping.csvColumn);
|
||||
if (colIdx === undefined) continue;
|
||||
const raw = row[colIdx] ?? "";
|
||||
|
||||
if (mapping.field === null) {
|
||||
@@ -266,7 +268,10 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
|
||||
break;
|
||||
case "tags":
|
||||
entry.tags = raw
|
||||
? raw.split(/[;:,]/).map((t) => t.trim()).filter(Boolean)
|
||||
? raw.split(/[;:,]/).flatMap((t) => {
|
||||
const trimmed = t.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
})
|
||||
: undefined;
|
||||
break;
|
||||
case "email_opt_in":
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge, KPIStat, EmptySta
|
||||
import { ShoppingCart, MapPin, Package, DollarSign, Plus, Send, AlertCircle, Calendar, Inbox, Sparkles, BarChart3, BrainCircuit, Upload, Clock, Droplets, Route, Truck, Receipt, Settings as SettingsIcon, Store, type LucideIcon } from "lucide-react";
|
||||
import type { DashboardStats } from "@/actions/dashboard";
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
|
||||
// Lazy-load the upgrade modal — only needed when starter tier + brandId present
|
||||
const UpgradePlanModal = dynamic(
|
||||
() => import("@/components/admin/UpgradePlanModal"),
|
||||
@@ -92,7 +94,7 @@ export default function DashboardClient({
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
return currencyFormatter.format(amount);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
@@ -163,10 +165,13 @@ export default function DashboardClient({
|
||||
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
|
||||
];
|
||||
|
||||
const visibleSections = sections
|
||||
.filter((s) => !(s.title === "Water Log" && !isWaterLogVisible))
|
||||
.filter((s) => !(s.title === "Route Trace" && !enabledAddons["route_trace"]))
|
||||
.filter((s) => groupFilter === "all" || s.group === groupFilter);
|
||||
const visibleSections: typeof sections = [];
|
||||
for (const s of sections) {
|
||||
if (s.title === "Water Log" && !isWaterLogVisible) continue;
|
||||
if (s.title === "Route Trace" && !enabledAddons["route_trace"]) continue;
|
||||
if (groupFilter !== "all" && s.group !== groupFilter) continue;
|
||||
visibleSections.push(s);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -13,29 +13,36 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function ElegantModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg", variant = "default" }: Props) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
// Native <dialog> handles focus trapping, ESC, and the backdrop.
|
||||
// showModal() opens it in modal mode; we listen to "cancel" so the
|
||||
// parent React state stays in sync with native close behavior.
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
const isElegant = variant === "elegant";
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label={title}
|
||||
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 md:p-8"
|
||||
onClick={handleBackdropClick}
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||
>
|
||||
<div
|
||||
@@ -67,9 +74,10 @@ export default function ElegantModal({ title, titleIcon, subtitle, onClose, chil
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
|
||||
>
|
||||
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg aria-hidden="true" className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -80,5 +88,6 @@ export default function ElegantModal({ title, titleIcon, subtitle, onClose, chil
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -25,29 +25,32 @@ export default function GlassModal({
|
||||
compact = false,
|
||||
eyebrow,
|
||||
}: Props) {
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
// Escape key
|
||||
// Native <dialog> handles focus trapping, ESC, and the backdrop.
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label={title}
|
||||
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 md:p-6"
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
backgroundColor: "rgba(60, 56, 37, 0.45)",
|
||||
backdropFilter: "blur(2px)",
|
||||
@@ -108,7 +111,7 @@ export default function GlassModal({
|
||||
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
|
||||
compact
|
||||
? "h-7 w-7 text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]"
|
||||
: "h-8 w-8 sm:h-9 sm:w-9"
|
||||
: "h-8 w-8 sm:h-9 sm:h-9"
|
||||
}`}
|
||||
style={
|
||||
compact
|
||||
@@ -117,7 +120,7 @@ export default function GlassModal({
|
||||
}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg aria-hidden="true" className="h-4 w-4 sm:h-5 sm:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -133,5 +136,6 @@ export default function GlassModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -236,7 +236,12 @@ export default function AnalyticsDashboard({ analytics }: Props) {
|
||||
const campaignCount = filteredAnalytics.length;
|
||||
|
||||
// Best performing campaign
|
||||
const bestCampaign = [...filteredAnalytics].sort((a, b) => Number(b.open_rate) - Number(a.open_rate))[0];
|
||||
let bestCampaign: (typeof filteredAnalytics)[number] | undefined;
|
||||
for (const a of filteredAnalytics) {
|
||||
if (!bestCampaign || Number(a.open_rate) > Number(bestCampaign.open_rate)) {
|
||||
bestCampaign = a;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyStateIcon = Icons.chart("w-10 h-10");
|
||||
|
||||
|
||||
@@ -391,7 +391,10 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
onChange({
|
||||
params: {
|
||||
...filter.params,
|
||||
zip_codes: e.target.value.split(",").map((z) => z.trim()).filter(Boolean),
|
||||
zip_codes: e.target.value.split(",").flatMap((z) => {
|
||||
const trimmed = z.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -446,7 +449,10 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
|
||||
onChange({
|
||||
params: {
|
||||
...filter.params,
|
||||
tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
tags: e.target.value.split(",").flatMap((t) => {
|
||||
const trimmed = t.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,8 +41,10 @@ export default function SegmentEditModal({ initialName = "", initialDescription
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
@@ -82,7 +84,6 @@ export default function SegmentEditModal({ initialName = "", initialDescription
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
placeholder="e.g. Fort Pierce Regulars"
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)] outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -97,6 +97,10 @@ const INTEGRATIONS: Integration[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const INTEGRATIONS_WITHOUT_OPENAI: Integration[] = INTEGRATIONS.filter(
|
||||
(i) => i.id !== "openai"
|
||||
);
|
||||
|
||||
const iconColors: Record<string, string> = {
|
||||
AI: "bg-violet-100 text-violet-700",
|
||||
Email: "bg-amber-100 text-amber-700",
|
||||
@@ -318,7 +322,7 @@ export default function IntegrationsInner({ brandId, brands }: Props) {
|
||||
|
||||
{/* Payment & email integrations grid */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{INTEGRATIONS.filter(i => i.id !== "openai").map((integration) => (
|
||||
{INTEGRATIONS_WITHOUT_OPENAI.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
|
||||
@@ -78,10 +78,7 @@ export function MoreSheet({ open, onClose }: MoreSheetProps) {
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClick={(e) => {
|
||||
// Click on the backdrop (the dialog element itself) closes; clicks on content don't
|
||||
if (e.target === dialogRef.current) onClose();
|
||||
}}
|
||||
aria-label="More navigation"
|
||||
className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40"
|
||||
style={{
|
||||
backgroundColor: "var(--color-bg)",
|
||||
|
||||
@@ -241,6 +241,13 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -50,8 +50,10 @@ type EditableItem = {
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
return currencyFormatter.format(amount);
|
||||
}
|
||||
|
||||
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
|
||||
@@ -30,8 +30,10 @@ type OrderPaymentSectionProps = {
|
||||
existingRefunds: Refund[];
|
||||
};
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
return currencyFormatter.format(amount);
|
||||
}
|
||||
|
||||
export default function OrderPaymentSection({
|
||||
|
||||
@@ -321,6 +321,13 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -311,6 +311,13 @@ export default function ProductFormModal({
|
||||
<div className="atelier-section-num mb-4">01 · Media</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (!uploading) fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDrag(true);
|
||||
|
||||
@@ -186,8 +186,10 @@ export default function ProductTableBody({
|
||||
|
||||
{openMenu === product.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-[var(--admin-card-bg)] shadow-lg ring-1 ring-[var(--admin-border)] overflow-hidden">
|
||||
@@ -203,8 +205,10 @@ export default function ProductTableBody({
|
||||
|
||||
{confirmDelete === product.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30 backdrop-blur-sm"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cancel delete confirmation"
|
||||
className="fixed inset-0 z-30 backdrop-blur-sm border-0 p-0 cursor-default"
|
||||
style={{ backgroundColor: "color-mix(in srgb, var(--admin-text-primary) 60%, transparent)" }}
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
/>
|
||||
|
||||
@@ -232,8 +232,10 @@ function ProductRowBase({
|
||||
|
||||
{openMenu === product.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
|
||||
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
|
||||
@@ -249,8 +251,10 @@ function ProductRowBase({
|
||||
|
||||
{confirmDelete === product.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cancel delete confirmation"
|
||||
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
|
||||
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
|
||||
|
||||
@@ -163,6 +163,13 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -73,8 +73,10 @@ function shortId(id: string) {
|
||||
return id.slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
||||
|
||||
function formatCurrency(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
|
||||
return currencyFormatter.format(amount);
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
@@ -526,8 +528,8 @@ function OrderCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{SHIPPING_STATUSES.filter(
|
||||
(s) => s.value !== order.shipping_status && s.value !== "label_created"
|
||||
{SHIPPING_STATUSES.flatMap((s) =>
|
||||
s.value !== order.shipping_status && s.value !== "label_created" ? [s] : []
|
||||
).map((s) => {
|
||||
const isDanger = s.value === "returned";
|
||||
return (
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
|
||||
}, [stops, search, statusFilter]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((a, b) => {
|
||||
return filtered.toSorted((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case "date":
|
||||
@@ -845,10 +845,11 @@ function StopRow({
|
||||
|
||||
{openMenu === stop.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
|
||||
onClick={() => {
|
||||
setOpenMenu(null);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
@@ -899,10 +900,11 @@ function StopRow({
|
||||
|
||||
{confirmDelete === stop.id && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Cancel delete confirmation"
|
||||
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
|
||||
onClick={() => {
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
|
||||
@@ -152,7 +152,7 @@ export default function StopsCalendarClient({ stops }: Props) {
|
||||
// Earliest/latest stop dates — used to enable/disable nav
|
||||
const dateRange = useMemo(() => {
|
||||
if (stops.length === 0) return null;
|
||||
const dates = stops.map((s) => s.date).filter(Boolean).sort();
|
||||
const dates = stops.flatMap((s) => (s.date ? [s.date] : [])).sort();
|
||||
return { min: dates[0], max: dates[dates.length - 1] };
|
||||
}, [stops]);
|
||||
|
||||
@@ -591,7 +591,7 @@ function DayRouteDrawer({
|
||||
onClose: () => void;
|
||||
onPublish: (stop: StopForView) => void;
|
||||
}) {
|
||||
const sorted = useMemo(() => [...stops].sort((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
|
||||
const sorted = useMemo(() => stops.toSorted((a, b) => (a.time || "").localeCompare(b.time || "")), [stops]);
|
||||
const counts = useMemo(() => {
|
||||
const active = sorted.filter((s) => s.status !== "draft" && s.active).length;
|
||||
const draft = sorted.filter((s) => s.status === "draft").length;
|
||||
|
||||
@@ -130,8 +130,10 @@ function NewTemplateModal({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
@@ -172,7 +174,6 @@ function NewTemplateModal({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
placeholder="e.g. Pickup Reminder"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -155,10 +155,15 @@ export default function UpgradePlanModal({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Upgrade plan"
|
||||
tabIndex={-1}
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-300 ${
|
||||
isVisible ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
{/* Glass backdrop */}
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" />
|
||||
|
||||
@@ -429,7 +429,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
||||
</button>
|
||||
{actionMenuId === user.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setActionMenuId(null)} />
|
||||
<button type="button" aria-label="Close menu" className="fixed inset-0 z-40 cursor-default bg-transparent border-0 p-0" onClick={() => setActionMenuId(null)} />
|
||||
<div className="absolute right-0 z-50 mt-1 w-48 rounded-xl bg-white shadow-xl ring-1 ring-stone-200 border border-stone-100 py-1 text-xs overflow-visible">
|
||||
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-stone-400 border-b border-stone-100">
|
||||
Actions
|
||||
@@ -508,7 +508,15 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
||||
|
||||
{/* Slide-in panel */}
|
||||
{panelOpen && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/20" onClick={closePanel}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={editing.isNew ? "Create user" : "Edit user"}
|
||||
className="fixed inset-0 z-50 flex justify-end bg-black/20"
|
||||
onClick={closePanel}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") closePanel(); }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="relative flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-xl ring-1 ring-stone-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -273,11 +273,12 @@ export default function WaterLogAdminPanel({
|
||||
|
||||
async function handlePreviewReport() {
|
||||
const all = await getWaterEntries(brandId, 1000);
|
||||
const todayRows: WaterLogReportRow[] = all
|
||||
.filter(
|
||||
(e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today,
|
||||
)
|
||||
.map(shapeWaterLogEntry);
|
||||
const todayRows: WaterLogReportRow[] = [];
|
||||
for (const e of all) {
|
||||
if ((e.logged_date ?? e.logged_at.slice(0, 10)) === today) {
|
||||
todayRows.push(shapeWaterLogEntry(e));
|
||||
}
|
||||
}
|
||||
const report = formatDailyWaterReport(todayRows, {
|
||||
recipientName: recipientName || undefined,
|
||||
previewMode: true,
|
||||
@@ -489,7 +490,6 @@ export default function WaterLogAdminPanel({
|
||||
onChange={(v) => setNewHg((s) => ({ ...s, name: v }))}
|
||||
placeholder="e.g. North Field Gate 1"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
label="Unit"
|
||||
@@ -657,7 +657,6 @@ export default function WaterLogAdminPanel({
|
||||
onChange={(v) => setNewUser((s) => ({ ...s, name: v }))}
|
||||
placeholder="Full name"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
label="Role"
|
||||
@@ -1116,7 +1115,6 @@ function Input({
|
||||
onChange,
|
||||
placeholder,
|
||||
required,
|
||||
autoFocus,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string;
|
||||
@@ -1124,7 +1122,6 @@ function Input({
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
autoFocus?: boolean;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -1138,7 +1135,6 @@ function Input({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
autoFocus={autoFocus}
|
||||
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm text-[#1d1d1f] outline-none transition focus:border-[#1a4d2e] focus:ring-2 focus:ring-[#1a4d2e]/15"
|
||||
/>
|
||||
</label>
|
||||
@@ -1199,8 +1195,7 @@ function FilterChip({ label, children }: { label: string; children: React.ReactN
|
||||
function Avatar({ name, role }: { name: string; role: string }) {
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.map((n) => n[0])
|
||||
.filter(Boolean)
|
||||
.flatMap((n) => (n[0] ? [n[0]] : []))
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function AdminActionMenu({ actions, triggerLabel, className = ""
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<button type="button" aria-label="Close menu" className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-[var(--admin-shadow-lg)] overflow-hidden">
|
||||
{actions.map((action, i) => (
|
||||
<button type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@@ -11,23 +11,32 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function AdminModal({ title, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, []);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
// Native <dialog> handles focus trapping, ESC, and the backdrop.
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
const onCancel = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
dialog.addEventListener("cancel", onCancel);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", onCancel);
|
||||
};
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
aria-label={title}
|
||||
className="w-full max-w-full max-h-full m-0 p-0 bg-transparent"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
|
||||
>
|
||||
<div
|
||||
@@ -51,10 +60,11 @@ export default function AdminModal({ title, subtitle, onClose, children, maxWidt
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
|
||||
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
|
||||
>
|
||||
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg aria-hidden="true" className="h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -64,5 +74,6 @@ export default function AdminModal({ title, subtitle, onClose, children, maxWidt
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function OrdersFilterButton() {
|
||||
</button>
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClick={(e) => { if (e.target === dialogRef.current) closeDialog(); }}
|
||||
aria-label="Filter orders"
|
||||
className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40"
|
||||
style={{ backgroundColor: "var(--color-bg)", color: "var(--color-text)" }}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
// dedicated /admin/stops page.
|
||||
export default function StopsList({ stops }: Props) {
|
||||
const sorted = useMemo(() => {
|
||||
return [...stops].sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||
return stops.toSorted((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||
}, [stops]);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
|
||||
@@ -55,13 +55,21 @@ function groupStopsByLocation(stops: Stop[]): LocationGroup[] {
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
|
||||
for (const g of map.values()) {
|
||||
const venues = new Set(g.stops.map((s) => (s.location || "").trim().toLowerCase()).filter(Boolean));
|
||||
const venues = new Set(
|
||||
g.stops.flatMap((s) => {
|
||||
const trimmed = (s.location || "").trim().toLowerCase();
|
||||
return trimmed ? [trimmed] : [];
|
||||
})
|
||||
);
|
||||
g.venueCount = Math.max(1, venues.size);
|
||||
g.upcoming = g.stops.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive").length;
|
||||
const future = g.stops
|
||||
.filter((s) => s.date >= todayStr && getStopStatus(s) !== "inactive")
|
||||
.map((s) => s.date)
|
||||
.sort();
|
||||
const future: string[] = [];
|
||||
for (const s of g.stops) {
|
||||
if (s.date >= todayStr && getStopStatus(s) !== "inactive") {
|
||||
future.push(s.date);
|
||||
}
|
||||
}
|
||||
future.sort();
|
||||
g.nextDate = future[0] ?? null;
|
||||
g.stops.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,15 @@ export default function CartRestoredToast() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-6 right-6 z-50 animate-slide-up"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setVisible(false);
|
||||
dismissRestoredToast();
|
||||
}
|
||||
}}
|
||||
className="fixed bottom-6 right-6 z-50 animate-slide-up cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 rounded-2xl"
|
||||
onClick={() => { setVisible(false); dismissRestoredToast(); }}
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-green-200 bg-green-50 px-5 py-3.5 shadow-lg cursor-pointer hover:bg-green-100 transition-colors">
|
||||
|
||||
@@ -78,7 +78,14 @@ export default function ChangelogFeed({ entries = DEFAULT_ENTRIES }: ChangelogFe
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`bg-white rounded-xl p-5 border transition-all hover:shadow-md ${
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
markAsRead(entry.id);
|
||||
}
|
||||
}}
|
||||
className={`bg-white rounded-xl p-5 border transition-all hover:shadow-md cursor-pointer ${
|
||||
isRead ? "border-gray-200 opacity-70" : "border-emerald-200 shadow-sm"
|
||||
}`}
|
||||
onClick={() => markAsRead(entry.id)}
|
||||
|
||||
@@ -562,6 +562,9 @@ export default function HeroSection() {
|
||||
|
||||
{/* ─── SCROLL INDICATOR ────────────────────────────────────────────────── */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") scrollToContent(); }}
|
||||
className="scroll-indicator absolute bottom-12 left-1/2 -translate-x-1/2 flex flex-col items-center gap-4 cursor-pointer"
|
||||
onClick={scrollToContent}
|
||||
>
|
||||
|
||||
@@ -176,6 +176,14 @@ export default function NotificationCenter({ brandId, userId }: NotificationCent
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (!notification.read) markAsRead(notification.id);
|
||||
if (notification.link) window.location.href = notification.link;
|
||||
}
|
||||
}}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
|
||||
!notification.read ? "bg-emerald-50/50" : ""
|
||||
}`}
|
||||
|
||||
@@ -287,6 +287,11 @@ export function InteractiveDemo({ onClose }: { onClose: () => void }) {
|
||||
{demoSteps.map((step, i) => (
|
||||
<div
|
||||
key={step.title}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setDemoStep(i);
|
||||
}}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
i === demoStep ? "border-primary bg-primary/5" : "border-gray-200"
|
||||
}`}
|
||||
|
||||
@@ -256,8 +256,13 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) {
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="FSMA report"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30"
|
||||
onClick={(e) => e.target === e.currentTarget && setOpen(false)}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }}
|
||||
style={{ backdropFilter: "blur(4px)" }}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -94,8 +94,10 @@ export default function LotCreateModal({ isOpen, onClose, brandId }: Props) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close modal"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm border-0 p-0 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
|
||||
@@ -758,7 +758,9 @@ export default function LotDetailPanel({
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]/50 px-3 py-3 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 transition-all"
|
||||
>
|
||||
<option value="">Select status...</option>
|
||||
{STATUS_FLOW.filter((s) => s !== lot.status).map((s) => {
|
||||
{STATUS_FLOW.flatMap((s) =>
|
||||
s !== lot.status ? [s] : []
|
||||
).map((s) => {
|
||||
const c = EVENT_CONFIG[s];
|
||||
return (
|
||||
<option key={s} value={s}>
|
||||
|
||||
@@ -288,7 +288,6 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
|
||||
value={manualInput}
|
||||
onChange={(e) => setManualInput(e.target.value.toUpperCase())}
|
||||
placeholder="e.g. TC-20260520-001"
|
||||
autoFocus
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400"
|
||||
/>
|
||||
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
|
||||
|
||||
@@ -88,7 +88,6 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
|
||||
onChange={(e) => setCropType(e.target.value)}
|
||||
placeholder="e.g. Sweet Corn"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -643,7 +643,7 @@ export default function RouteTraceDashboard({
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-white text-xs text-[var(--admin-text-primary)] px-2 py-1.5 sm:py-2 focus:outline-none focus:border-[var(--admin-accent)] flex-shrink-0"
|
||||
>
|
||||
<option value="">All Crops</option>
|
||||
{[...new Set(haulingLots.map((l) => l.crop_type))].sort().map((c) => (
|
||||
{[...new Set(haulingLots.map((l) => l.crop_type))].toSorted().map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -84,7 +84,15 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
|
||||
const qrPreviewSize = qrSize * scale * 0.55; // scaled down for preview
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Print sticker"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
|
||||
<div>
|
||||
|
||||
@@ -212,7 +212,7 @@ function SheetContent({
|
||||
<div className="relative px-6 sm:px-8 pt-2 pb-5 border-b border-stone-900/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -45 }}
|
||||
initial={{ scale: 0.95, opacity: 0 , rotate: -45 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 18, delay: 0.05 }}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#1A4D2E] text-white shadow-[0_4px_14px_rgba(26,77,46,0.4)]"
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function StorefrontHeader({
|
||||
</svg>
|
||||
{cartCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className={`absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full ${accent.cartBadge} text-[10px] font-bold text-white shadow-sm`}
|
||||
>
|
||||
|
||||
@@ -464,7 +464,6 @@ function WaterFieldInner() {
|
||||
placeholder={t.pinPlaceholder}
|
||||
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
|
||||
style={{ letterSpacing: "0.3em" }}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
|
||||
|
||||
@@ -40,7 +40,15 @@ export default function DepositModal({ order, onClose, onFulfilled }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 px-4" onClick={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Record deposit"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 px-4"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Record Deposit</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
|
||||
@@ -29,7 +29,15 @@ export default function OrderDetailsModal({ order, onClose, onFulfill, onRecordD
|
||||
const hasPhone = Boolean(order.customer_phone);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4" onClick={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Order details"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white px-6 py-4 border-b border-slate-200 flex items-center justify-between rounded-t-2xl">
|
||||
|
||||
@@ -248,7 +248,9 @@ export default function CustomersTab({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{registrations.filter(r => r.account_status === "pending_approval").map(r => (
|
||||
{registrations.flatMap(r =>
|
||||
r.account_status === "pending_approval" ? [r] : []
|
||||
).map(r => (
|
||||
<tr key={r.id} className="hover:bg-[var(--admin-bg-subtle)]">
|
||||
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{r.company_name ?? "—"}</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{r.contact_name ?? "—"}<br/><span className="text-xs text-[var(--admin-text-muted)]">{r.email}</span></td>
|
||||
|
||||
@@ -28,7 +28,15 @@ export default function PriceSheetModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4" onClick={onClose}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Send price sheet"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 px-4"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg border border-[var(--admin-border)]" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -256,10 +256,14 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const decreaseQuantity = useCallback((id: string) => {
|
||||
setCart((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, quantity: item.quantity - 1 } : item))
|
||||
.filter((item) => item.quantity > 0)
|
||||
);
|
||||
setCart((prev) => {
|
||||
const next = [];
|
||||
for (const item of prev) {
|
||||
const updated = item.id === id ? { ...item, quantity: item.quantity - 1 } : item;
|
||||
if (updated.quantity > 0) next.push(updated);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeFromCart = useCallback((id: string) => {
|
||||
|
||||
@@ -176,9 +176,8 @@ export function detectColumnMappings(
|
||||
});
|
||||
|
||||
// Collect sample values and handle disambiguation
|
||||
for (const mapping of mappings) {
|
||||
const colIdx = headers.indexOf(mapping.csvColumn);
|
||||
if (colIdx === -1 || mapping.field === null) continue;
|
||||
for (const [colIdx, mapping] of mappings.entries()) {
|
||||
if (mapping.field === null) continue;
|
||||
|
||||
const samples: string[] = [];
|
||||
for (const row of rows) {
|
||||
@@ -223,9 +222,8 @@ export function mapRowToContactEntry(
|
||||
let normalizedEmail: string | null = null;
|
||||
let normalizedPhone: string | null = null;
|
||||
|
||||
for (const mapping of mappings) {
|
||||
for (const [colIdx, mapping] of mappings.entries()) {
|
||||
if (mapping.field === null) continue;
|
||||
const colIdx = mappings.indexOf(mapping);
|
||||
const raw = row[colIdx] ?? "";
|
||||
|
||||
switch (mapping.field) {
|
||||
@@ -252,7 +250,10 @@ export function mapRowToContactEntry(
|
||||
break;
|
||||
case "tags":
|
||||
entry.tags = raw
|
||||
? raw.split(/[;:,]/).map((t) => t.trim()).filter(Boolean)
|
||||
? raw.split(/[;:,]/).flatMap((t) => {
|
||||
const trimmed = t.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
})
|
||||
: undefined;
|
||||
break;
|
||||
case "email_opt_in":
|
||||
|
||||
+10
-5
@@ -26,9 +26,11 @@ export function parseProductCSV(csvText: string): {
|
||||
}
|
||||
|
||||
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const headerSet = new Set(header);
|
||||
const VALID_TYPES = new Set(["Pickup", "Shipping", "Pickup & Shipping"]);
|
||||
const required = ["name", "price", "type"];
|
||||
for (const col of required) {
|
||||
if (!header.includes(col)) {
|
||||
if (!headerSet.has(col)) {
|
||||
return { success: false, error: `Missing required column: ${col}` };
|
||||
}
|
||||
}
|
||||
@@ -60,7 +62,7 @@ export function parseProductCSV(csvText: string): {
|
||||
}
|
||||
|
||||
const type = cols[typeIdx] ?? "";
|
||||
if (!["Pickup", "Shipping", "Pickup & Shipping"].includes(type)) {
|
||||
if (!VALID_TYPES.has(type)) {
|
||||
errors.push({ row: i + 1, error: `Invalid type: ${type}` });
|
||||
continue;
|
||||
}
|
||||
@@ -125,11 +127,13 @@ export function parseStopCSV(csvText: string): {
|
||||
// Build column index map — common synonyms supported
|
||||
const rawHeader = lines[0].split(",").map((h) => h.trim());
|
||||
const header = rawHeader.map((h) => h.toLowerCase());
|
||||
const headerIndex = new Map<string, number>();
|
||||
header.forEach((h, i) => headerIndex.set(h, i));
|
||||
|
||||
function idx(...names: string[]): number {
|
||||
for (const n of names) {
|
||||
const i = header.indexOf(n);
|
||||
if (i >= 0) return i;
|
||||
const i = headerIndex.get(n);
|
||||
if (i !== undefined) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -215,9 +219,10 @@ export function parseOrderCSV(csvText: string): {
|
||||
}
|
||||
|
||||
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const headerSet = new Set(header);
|
||||
const required = ["customer_name", "customer_email", "stop_id", "product_id", "quantity", "fulfillment"];
|
||||
for (const col of required) {
|
||||
if (!header.includes(col)) {
|
||||
if (!headerSet.has(col)) {
|
||||
return { success: false, error: `Missing required column: ${col}` };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user