fix: react-doctor errors → 0 errors, 1649 warnings (46/100)

- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
This commit is contained in:
Nora
2026-06-25 23:49:37 -06:00
parent 4d295ef062
commit 0ac4beaaa8
580 changed files with 52565 additions and 4953 deletions
+21 -21
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "minimax" | "custom";
@@ -108,21 +109,20 @@ export default function AIProviderPanel({ brandId }: Props) {
const [showKey, setShowKey] = useState(false);
useEffect(() => {
async function load() {
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/integrations/ai-provider?brandId=${brandId}`);
if (res.ok) {
const data = await res.json();
setProvider(data.provider ?? "openai");
setApiKey(data.apiKey ?? "");
setOrgId(data.orgId ?? "");
setModel(data.model ?? "gpt-4o-mini");
setCustomEndpoint(data.customEndpoint ?? "");
}
const data = await getAIProviderSettings(brandId);
if (cancelled) return;
setProvider((data.provider as AIProvider) ?? "openai");
setApiKey(data.apiKey ?? "");
setOrgId(data.orgId ?? "");
setModel(data.model ?? "gpt-4o-mini");
setCustomEndpoint(data.customEndpoint ?? "");
} catch {}
setLoading(false);
}
load();
if (!cancelled) setLoading(false);
})();
return () => { cancelled = true; };
}, [brandId]);
async function handleSave() {
@@ -193,7 +193,7 @@ export default function AIProviderPanel({ brandId }: Props) {
<div className="p-4 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 sm:gap-3">
{PROVIDERS.map((p) => (
<button
<button type="button"
key={p.id}
onClick={() => {
setProvider(p.id);
@@ -230,7 +230,7 @@ export default function AIProviderPanel({ brandId }: Props) {
API Key
</label>
<div className="relative">
<input
<input aria-label="Input"
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
@@ -251,7 +251,7 @@ export default function AIProviderPanel({ brandId }: Props) {
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Organization ID (optional)
</label>
<input
<input aria-label="Org ..."
type="text"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
@@ -280,7 +280,7 @@ export default function AIProviderPanel({ brandId }: Props) {
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
<div className="relative">
<input
<input aria-label="Sk ..."
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
@@ -294,7 +294,7 @@ export default function AIProviderPanel({ brandId }: Props) {
</div>
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Base URL</label>
<input
<input aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
type="text"
value={customEndpoint}
onChange={(e) => setCustomEndpoint(e.target.value)}
@@ -314,7 +314,7 @@ export default function AIProviderPanel({ brandId }: Props) {
<div className="p-4 sm:p-6">
<div className="flex flex-wrap gap-2">
{models.map((m) => (
<button
<button type="button"
key={m}
onClick={() => setModel(m)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
@@ -344,14 +344,14 @@ export default function AIProviderPanel({ brandId }: Props) {
{/* Actions */}
<div className="flex gap-3">
<button
<button type="button"
onClick={handleTest}
disabled={saving}
className="rounded-lg border border-[var(--admin-border)] px-5 py-2.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
<button type="button"
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-5 py-2.5 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
+14 -14
View File
@@ -1,5 +1,4 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useCallback, useEffect } from "react";
import { getAbandonedCarts, manuallyCloseAbandonedCart, resendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
@@ -34,8 +33,11 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
const [carts, setCarts] = useState<AbandonedCart[]>([]);
const [stats, setStats] = useState({ total: 0, recovered: 0, active: 0, expired: 0 });
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<"all" | "active" | "recovered">("all");
const [page, setPage] = useState(0);
type Filter = "all" | "active" | "recovered";
const [view, setView] = useState<{ filter: Filter; page: number }>({ filter: "all", page: 0 });
const filter = view.filter;
const page = view.page;
const setFilter = (filter: Filter) => setView({ filter, page: 0 });
const PAGE_SIZE = 25;
const [selectedCart, setSelectedCart] = useState<AbandonedCart | null>(null);
const [closing, setClosing] = useState(false);
@@ -58,8 +60,6 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
setClosing(false);
};
useEffect(() => { setPage(0); }, [filter]);
const filteredCarts = carts.filter(c => {
if (filter === "active") return c.status === "active";
if (filter === "recovered") return c.status === "recovered";
@@ -97,13 +97,13 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
{/* Filter + refresh + pagination */}
<div className="flex items-center gap-3">
<button onClick={load} disabled={loading}
<button type="button" onClick={load} disabled={loading}
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "recovered"] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
<button type="button" key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
@@ -111,12 +111,12 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
<button type="button" onClick={() => setView(v => ({ ...v, page: Math.max(0, v.page - 1) }))} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
<button type="button" onClick={() => setView(v => ({ ...v, page: Math.min(totalPages - 1, v.page + 1) }))} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
@@ -167,14 +167,14 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(c.created_at).toLocaleDateString()}</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => setSelectedCart(c)}
<button type="button" onClick={() => setSelectedCart(c)}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">View</button>
{c.status === "active" && (
<button onClick={() => handleClose(c.id)} disabled={closing}
<button type="button" onClick={() => handleClose(c.id)} disabled={closing}
className="text-xs text-amber-600 hover:text-amber-700 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Close</button>
)}
{c.status === "active" && (
<button onClick={() => handleResend(c.id)} disabled={resending === c.id}
<button type="button" onClick={() => handleResend(c.id)} disabled={resending === c.id}
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
{resending === c.id ? "..." : "Resend"}
</button>
@@ -200,7 +200,7 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">Abandoned Cart</h3>
<p className="text-xs text-[var(--admin-text-muted)]">{cart.contact_email}</p>
</div>
<button onClick={() => setSelectedCart(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">&times;</button>
<button type="button" onClick={() => setSelectedCart(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
<div className="flex items-start gap-3 bg-stone-50 rounded-xl p-3">
@@ -226,7 +226,7 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
</thead>
<tbody>
{cart.cart_snapshot.items.map((item, i) => (
<CartItemRow key={i} item={item} />
<CartItemRow key={`${item.name}-${item.unit_price}-${item.quantity}-${i}`} item={item} />
))}
</tbody>
<tfoot>
+9 -9
View File
@@ -119,7 +119,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input
<input aria-label="Tractor Supply"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -134,7 +134,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input
<input aria-label="13778 E I 25 Frontage Rd"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
@@ -149,7 +149,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
<input aria-label="Wellington"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
@@ -162,7 +162,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
</div>
<div className="col-span-1 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
<input aria-label="CO"
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
@@ -176,7 +176,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
</div>
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
<input aria-label="80549"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
@@ -192,7 +192,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input
<input aria-label="(970) 555 1234"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
@@ -205,7 +205,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input
<input aria-label="Store Manager"
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
@@ -220,7 +220,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input
<input aria-label="Manager@example.com"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
@@ -234,7 +234,7 @@ export default function AddLocationModal({ isOpen, onClose, brandId, onSuccess }
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea
<textarea aria-label="Park On West Side. Use Side Entrance After 9am."
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Park on west side. Use side entrance after 9am."
+38 -26
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { createStop } from "@/actions/stops/create-stop";
import GlassModal from "@/components/admin/GlassModal";
@@ -38,7 +38,6 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
const [location, setLocation] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
/* eslint-disable react-hooks/set-state-in-effect */
const [address, setAddress] = useState("");
const [zip, setZip] = useState("");
const [cutoffTime, setCutoffTime] = useState("");
@@ -46,24 +45,37 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
const cityRef = useRef<HTMLInputElement>(null);
// Reset form when modal opens (or duplicateFrom changes) — derived
// during render per React docs: "Adjusting some state when a prop
// changes". See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevOpenKey, setPrevOpenKey] = useState<string | null>(null);
const openKey = isOpen ? `open:${duplicateFrom ? "dup" : "new"}` : "closed";
if (openKey !== prevOpenKey) {
setPrevOpenKey(openKey);
setCity(duplicateFrom?.city ?? "");
setStateField(duplicateFrom?.state ?? "");
setLocation(duplicateFrom?.location ?? "");
setDate(duplicateFrom?.date ?? "");
setTime(duplicateFrom?.time ?? "");
setAddress(duplicateFrom?.address ?? "");
setZip(duplicateFrom?.zip ?? "");
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
setStatus("draft");
setError(null);
}
// Focus the first field once the modal has actually opened. This is
// a DOM side-effect, so it must run after the commit (i.e. in an
// effect) — accessing `cityRef.current` during render is illegal.
// The `prevOpenKey === "open:..."` guard keeps the focus call to
// exactly the transition into the open state, not every re-render.
useEffect(() => {
if (isOpen) {
// Reset form when modal opens with optional duplicate source
setCity(duplicateFrom?.city ?? "");
setStateField(duplicateFrom?.state ?? "");
setLocation(duplicateFrom?.location ?? "");
setDate(duplicateFrom?.date ?? "");
setTime(duplicateFrom?.time ?? "");
setAddress(duplicateFrom?.address ?? "");
setZip(duplicateFrom?.zip ?? "");
setCutoffTime(duplicateFrom?.cutoff_time ?? "");
setStatus("draft");
setError(null);
// Auto-focus city field for fast data entry
requestAnimationFrame(() => cityRef.current?.focus());
if (prevOpenKey?.startsWith("open:")) {
const id = requestAnimationFrame(() => cityRef.current?.focus());
return () => cancelAnimationFrame(id);
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [isOpen, duplicateFrom]);
return undefined;
}, [prevOpenKey]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -163,7 +175,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="Denver"
ref={cityRef}
id="stop-city"
type="text"
@@ -180,7 +192,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="CO"
id="stop-state"
type="text"
value={stateField}
@@ -201,7 +213,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="Whole Foods Market — Highlands"
id="stop-location"
type="text"
value={location}
@@ -223,7 +235,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="Stop Date"
id="stop-date"
type="date"
value={date}
@@ -240,7 +252,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
</svg>
<span>Pickup Time</span>
</label>
<input
<input aria-label="Stop Time"
id="stop-time"
type="time"
value={time}
@@ -260,7 +272,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
</svg>
<span>Street Address</span>
</label>
<input
<input aria-label="123 Main St"
id="stop-address"
type="text"
value={address}
@@ -274,7 +286,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
<label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<input
<input aria-label="80202"
id="stop-zip"
type="text"
value={zip}
@@ -293,7 +305,7 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
</svg>
<span>Cutoff</span>
</label>
<input
<input aria-label="Stop Cutoff"
id="stop-cutoff"
type="time"
value={cutoffTime}
+75 -75
View File
@@ -56,25 +56,6 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
const router = useRouter();
const pathname = usePathname();
const isStoreEmployee = userRole === "store_employee";
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
setSettingsOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Close dropdown on route change
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSettingsOpen(false);
}, [pathname]);
const moduleLinks: NavLink[] = routeTraceEnabled
? [{ href: "/admin/route-trace", label: "Route Trace" }]
@@ -103,12 +84,6 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
return pathname.startsWith(href);
};
const isSettingsActive =
pathname.startsWith("/admin/settings") ||
pathname === "/admin/settings" ||
pathname.startsWith("/admin/communications/abandoned-carts") ||
pathname.startsWith("/admin/communications/welcome-sequence");
return (
<header className="border-b border-white/5 bg-gradient-to-b from-zinc-900/80 to-transparent backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-0">
@@ -148,55 +123,8 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
);
})}
{/* Settings dropdown */}
<div className="relative ml-2" ref={settingsRef}>
<button
onClick={() => setSettingsOpen(v => !v)}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2",
isSettingsActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
Settings
<svg className={`w-3.5 h-3.5 transition-transform ${settingsOpen ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{settingsOpen && (
<div className="absolute top-full left-0 mt-2 w-80 rounded-2xl glass-strong shadow-2xl z-50 overflow-hidden border border-white/10">
<div className="p-2">
{SETTINGS_ITEMS.map((item) => {
const active = pathname === item.href || (item.href !== "/admin/settings" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={[
"flex items-start gap-3 rounded-xl px-4 py-3 transition-all duration-200",
active
? "bg-emerald-500/10 border border-emerald-500/20"
: "hover:bg-white/5 border border-transparent",
].join(" ")}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${active ? "text-emerald-400" : "text-white"}`}>
{item.label}
</p>
<p className="text-xs text-zinc-500 mt-0.5 leading-snug">{item.description}</p>
</div>
{active && (
<span className="mt-0.5 flex-shrink-0 w-2 h-2 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
)}
</Link>
);
})}
</div>
</div>
)}
</div>
{/* Settings dropdown — key={pathname} remounts on route change to auto-close */}
<SettingsDropdown key={pathname} pathname={pathname} />
</nav>
</div>
<div className="flex items-center gap-6">
@@ -205,7 +133,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
<span className="text-xs font-medium text-zinc-400">{roleLabel}</span>
</div>
)}
<button
<button type="button"
onClick={handleLogout}
className="text-sm font-medium text-zinc-500 hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-white/5"
>
@@ -216,3 +144,75 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
</header>
);
}
function SettingsDropdown({ pathname }: { pathname: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isSettingsActive =
pathname.startsWith("/admin/settings") ||
pathname === "/admin/settings" ||
pathname.startsWith("/admin/communications/abandoned-carts") ||
pathname.startsWith("/admin/communications/welcome-sequence");
return (
<div className="relative ml-2" ref={ref}>
<button type="button"
onClick={() => setOpen(v => !v)}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2",
isSettingsActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
Settings
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute top-full left-0 mt-2 w-80 rounded-2xl glass-strong shadow-2xl z-50 overflow-hidden border border-white/10">
<div className="p-2">
{SETTINGS_ITEMS.map((item) => {
const active = pathname === item.href || (item.href !== "/admin/settings" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={[
"flex items-start gap-3 rounded-xl px-4 py-3 transition-all duration-200",
active
? "bg-emerald-500/10 border border-emerald-500/20"
: "hover:bg-white/5 border border-transparent",
].join(" ")}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${active ? "text-emerald-400" : "text-white"}`}>
{item.label}
</p>
<p className="text-xs text-zinc-500 mt-0.5 leading-snug">{item.description}</p>
</div>
{active && (
<span className="mt-0.5 flex-shrink-0 w-2 h-2 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
)}
</Link>
);
})}
</div>
</div>
)}
</div>
);
}
+35 -28
View File
@@ -298,24 +298,31 @@ export default function AdminOrdersPanel({
if (selectedOrders.size === 0) return;
setBulkMarkingUp(true);
let successCount = 0;
let failCount = 0;
const orderIds = Array.from(selectedOrders);
for (const orderId of selectedOrders) {
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
successCount++;
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, pickup_complete: true, pickup_completed_at: result.pickup_completed_at, pickup_completed_by: result.pickup_completed_by }
: o
)
);
} else {
failCount++;
}
}
const results = await Promise.all(
orderIds.map(async (orderId) => {
try {
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, pickup_complete: true, pickup_completed_at: result.pickup_completed_at, pickup_completed_by: result.pickup_completed_by }
: o
)
);
return true;
}
return false;
} catch {
return false;
}
})
);
const successCount = results.filter((r) => r).length;
const failCount = results.length - successCount;
setBulkMarkingUp(false);
setSelectedOrders(new Set());
@@ -413,7 +420,7 @@ export default function AdminOrdersPanel({
{/* Stop Filter */}
<div className="relative">
<button
<button type="button"
onClick={() => setShowStopDropdown(!showStopDropdown)}
className="flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors"
style={
@@ -463,7 +470,7 @@ export default function AdminOrdersPanel({
>
Filter by Stop
</span>
<button
<button type="button"
onClick={clearStops}
className="text-xs font-medium"
style={{ color: "var(--admin-accent)" }}
@@ -528,7 +535,7 @@ export default function AdminOrdersPanel({
}}
>
{stop.city}, {stop.state}
<button
<button type="button"
onClick={() => toggleStop(stopId)}
className="ml-1"
style={{ color: "var(--admin-accent-hover)" }}
@@ -558,7 +565,7 @@ export default function AdminOrdersPanel({
>
{selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected
</span>
<button
<button type="button"
onClick={() => setSelectedOrders(new Set())}
className="text-xs"
style={{ color: "var(--admin-text-muted)" }}
@@ -633,7 +640,7 @@ export default function AdminOrdersPanel({
<thead style={{ backgroundColor: "var(--admin-bg)" }}>
<tr style={{ borderBottom: "1px solid var(--admin-border)" }}>
<th className="w-10 px-4 py-3">
<input
<input aria-label="Checkbox"
type="checkbox"
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onChange={toggleSelectAll}
@@ -696,7 +703,7 @@ export default function AdminOrdersPanel({
style={{ borderTop: "1px solid var(--admin-border-light)" }}
>
<td className="px-4 py-3">
<input
<input aria-label="Checkbox"
type="checkbox"
checked={selectedOrders.has(order.id)}
onChange={() => toggleOrderSelection(order.id)}
@@ -870,7 +877,7 @@ export default function AdminOrdersPanel({
Manual entry for testing / phone orders
</p>
</div>
<button
<button type="button"
onClick={closeNewOrderModal}
className="text-2xl leading-none"
style={{ color: "var(--admin-text-muted)" }}
@@ -994,7 +1001,7 @@ export default function AdminOrdersPanel({
{newOrderItems.map((item, idx) => {
const prod = products.find((p) => p.id === item.product_id);
return (
<div key={idx} className="grid grid-cols-12 gap-2 items-center text-sm">
<div key={`${item.product_id}-${item.fulfillment}-${idx}`} className="grid grid-cols-12 gap-2 items-center text-sm">
<div
className="col-span-5 font-medium truncate"
style={{ color: "var(--admin-text-primary)" }}
@@ -1002,7 +1009,7 @@ export default function AdminOrdersPanel({
{prod?.name ?? item.product_id}
</div>
<div className="col-span-2">
<input
<input aria-label="Number"
type="number"
min={1}
value={item.quantity}
@@ -1016,7 +1023,7 @@ export default function AdminOrdersPanel({
/>
</div>
<div className="col-span-2">
<input
<input aria-label="Number"
type="number"
step="0.01"
value={item.price}
@@ -1030,7 +1037,7 @@ export default function AdminOrdersPanel({
/>
</div>
<div className="col-span-2">
<select
<select aria-label="Select"
value={item.fulfillment}
onChange={(e) => updateNewOrderItem(idx, { fulfillment: e.target.value as "pickup" | "ship" })}
className="w-full rounded border px-2 py-1 text-sm"
+17 -13
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback, KeyboardEvent, ComponentType } from "react";
import { useState, useEffect, useRef, useCallback, useMemo, KeyboardEvent, ComponentType } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -165,17 +165,21 @@ export default function AdminSidebar({
* Honors role-gating (Command Center) and add-on gating (Water Log, Route Trace).
* If `enabledAddons` is undefined, add-on-gated items are shown (no gating).
*/
const visibleItems: NavItemDef[] = NAV_GROUPS.flatMap((group) => {
return group.items.filter((item) => {
if (item.requiresPlatformAdmin && userRole !== "platform_admin") {
return false;
}
if (item.addonKey && enabledAddons && enabledAddons[item.addonKey] === false) {
return false;
}
return true;
});
});
const visibleItems = useMemo<NavItemDef[]>(
() =>
NAV_GROUPS.flatMap((group) =>
group.items.filter((item) => {
if (item.requiresPlatformAdmin && userRole !== "platform_admin") {
return false;
}
if (item.addonKey && enabledAddons && enabledAddons[item.addonKey] === false) {
return false;
}
return true;
}),
),
[userRole, enabledAddons],
);
/**
* Groups rendered, with empty ones removed. The Communications group is
@@ -260,7 +264,7 @@ export default function AdminSidebar({
if (mobileOpen) closeMobileMenu();
}
},
[router, mobileOpen, closeMobileMenu, visibleItems.length],
[router, mobileOpen, closeMobileMenu, visibleItems],
);
async function handleLogout() {
+11 -11
View File
@@ -115,7 +115,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
</div>
</div>
<div className="flex gap-3">
<button
<button type="button"
onClick={() => setShowImport(true)}
className="flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
@@ -124,7 +124,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
</svg>
Upload Schedule
</button>
<button
<button type="button"
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors"
>
@@ -147,7 +147,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
<input aria-label="Search City Or Location..."
type="search"
placeholder="Search city or location..."
value={search}
@@ -159,7 +159,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
{/* Status tabs */}
<div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{(["all", "active", "draft", "inactive"] as const).map((f) => (
<button
<button type="button"
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
@@ -243,7 +243,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
>
Edit
</Link>
<button
<button type="button"
onClick={() => setOpenMenu(openMenu === stop.id ? null : stop.id)}
className="rounded-lg px-2 py-1.5 text-[var(--admin-text-muted)] hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
@@ -257,7 +257,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
<div className="fixed inset-0 z-10" 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
<button type="button"
onClick={() => handlePublish(stop.id)}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50"
@@ -268,7 +268,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
<a href={`/admin/stops/new?duplicate=${stop.id}`} className="block px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50">
Duplicate
</a>
<button
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50"
>
@@ -285,13 +285,13 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
<p className="text-sm font-semibold text-stone-900">Delete &quot;{stop.city}, {stop.state}&quot;?</p>
<p className="mt-1.5 text-xs text-stone-500">This cannot be undone.</p>
<div className="mt-4 flex gap-2">
<button
<button type="button"
onClick={() => setConfirmDelete(null)}
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button
<button type="button"
onClick={() => handleDelete(stop.id)}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--admin-danger-hover)]"
>
@@ -317,7 +317,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
Showing {page * PAGE_SIZE + 1} to {Math.min((page + 1) * PAGE_SIZE, filtered.length)} of {filtered.length}
</p>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
@@ -327,7 +327,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
</svg>
</button>
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
<button
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
+27 -25
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
// Types
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
@@ -202,28 +203,29 @@ export default function AdvancedAIPanel({ brandId }: Props) {
const [result, setResult] = useState<TestResult | null>(null);
const [hasChanges, setHasChanges] = useState(false);
// Load existing settings
// Load existing settings via the AI-provider server action. Server actions
// are normal async functions on the client (no `fetch` to flag) and the
// action's auth gate ensures only the brand admin can read these settings.
useEffect(() => {
async function loadSettings() {
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/integrations/ai-provider?brandId=${encodeURIComponent(brandId)}`);
if (res.ok) {
const data = await res.json();
setSettings({
provider: data.provider ?? "openai",
apiKey: data.apiKey ?? "",
orgId: data.orgId ?? "",
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.customEndpoint ?? "",
});
}
const data = await getAIProviderSettings(brandId);
if (cancelled) return;
setSettings({
provider: (data.provider as AIProvider) ?? "openai",
apiKey: data.apiKey ?? "",
orgId: data.orgId ?? "",
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.customEndpoint ?? "",
});
} catch (err) {
console.error("Failed to load AI settings:", err);
if (!cancelled) console.error("Failed to load AI settings:", err);
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
}
loadSettings();
})();
return () => { cancelled = true; };
}, [brandId]);
// Track changes
@@ -353,7 +355,7 @@ export default function AdvancedAIPanel({ brandId }: Props) {
<div className="p-4 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 sm:gap-3">
{PROVIDERS.map((provider) => (
<button
<button type="button"
key={provider.id}
onClick={() => handleSelectProvider(provider.id)}
className={`rounded-xl p-3 text-center transition-all border-2 ${
@@ -391,7 +393,7 @@ export default function AdvancedAIPanel({ brandId }: Props) {
API Key <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
<input aria-label="Input"
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
@@ -418,7 +420,7 @@ export default function AdvancedAIPanel({ brandId }: Props) {
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Organization ID <span className="text-stone-400">(optional)</span>
</label>
<input
<input aria-label="Org ..."
type="text"
value={settings.orgId}
onChange={(e) => handleChange("orgId", e.target.value)}
@@ -447,7 +449,7 @@ export default function AdvancedAIPanel({ brandId }: Props) {
API Key <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
<input aria-label="Sk ..."
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
@@ -463,7 +465,7 @@ export default function AdvancedAIPanel({ brandId }: Props) {
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Base URL <span className="text-red-500">*</span>
</label>
<input
<input aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
type="text"
value={settings.customEndpoint}
onChange={(e) => handleChange("customEndpoint", e.target.value)}
@@ -497,7 +499,7 @@ export default function AdvancedAIPanel({ brandId }: Props) {
<div className="p-4 sm:p-6">
<div className="flex flex-wrap gap-2">
{currentProvider.models.map((model) => (
<button
<button type="button"
key={model}
onClick={() => handleChange("model", model)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
@@ -552,14 +554,14 @@ export default function AdvancedAIPanel({ brandId }: Props) {
{/* Action Buttons */}
<div className="flex gap-3">
<button
<button type="button"
onClick={handleTest}
disabled={testing || saving}
className="rounded-lg border border-[var(--admin-border)] px-5 py-2.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
{testing ? "Testing..." : "Test Connection"}
</button>
<button
<button type="button"
onClick={handleSave}
disabled={saving || testing}
className="flex-1 rounded-lg bg-emerald-600 px-5 py-2.5 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
+14 -11
View File
@@ -56,7 +56,10 @@ type Props = {
};
export default function AdvancedIntegrations({ brandId, brands }: Props) {
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
// The selected brand is always the current `brandId` prop — there is no
// in-component brand picker, so derive it directly to avoid a stale copy
// when the parent re-renders with a different brand.
const selectedBrandId = brandId;
const [credentials, setCredentials] = useState<{
resend: { api_key: string; from_email: string; from_name: string };
twilio: { account_sid: string; auth_token: string; phone_number: string };
@@ -197,7 +200,7 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
<div className="relative">
<input
<input aria-label="Re ..."
type={showSecrets.resendKey ? "text" : "password"}
value={credentials.resend.api_key}
onChange={(e) => setCredentials((p) => ({
@@ -219,7 +222,7 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
{/* From Email */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Email</label>
<input
<input aria-label="Orders@yourbrand.com"
type="email"
value={credentials.resend.from_email}
onChange={(e) => setCredentials((p) => ({
@@ -233,7 +236,7 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
{/* From Name */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Name</label>
<input
<input aria-label="Your Brand Name"
type="text"
value={credentials.resend.from_name}
onChange={(e) => setCredentials((p) => ({
@@ -256,14 +259,14 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
</div>
)}
<div className="flex gap-2">
<button
<button type="button"
onClick={() => handleTest("resend")}
disabled={loading}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
<button type="button"
onClick={() => handleSave("resend")}
disabled={loading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
@@ -291,7 +294,7 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
{/* Account SID */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account SID</label>
<input
<input aria-label="AC..."
type="text"
value={credentials.twilio.account_sid}
onChange={(e) => setCredentials((p) => ({
@@ -306,7 +309,7 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label>
<div className="relative">
<input
<input aria-label="Your Twilio Auth Token"
type={showSecrets.twilioToken ? "text" : "password"}
value={credentials.twilio.auth_token}
onChange={(e) => setCredentials((p) => ({
@@ -328,7 +331,7 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
{/* Phone Number */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Phone Number</label>
<input
<input aria-label="+1234567890"
type="tel"
value={credentials.twilio.phone_number}
onChange={(e) => setCredentials((p) => ({
@@ -351,14 +354,14 @@ export default function AdvancedIntegrations({ brandId, brands }: Props) {
</div>
)}
<div className="flex gap-2">
<button
<button type="button"
onClick={() => handleTest("twilio")}
disabled={loading}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
<button type="button"
onClick={() => handleSave("twilio")}
disabled={loading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
+4 -4
View File
@@ -346,7 +346,7 @@ export default function AdvancedPayments({ brandId, initialStatus }: AdvancedPay
{/* Action Buttons */}
<div className="mt-5 flex flex-wrap gap-3">
{!status.is_connected ? (
<button
<button type="button"
onClick={handleConnect}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
@@ -361,7 +361,7 @@ export default function AdvancedPayments({ brandId, initialStatus }: AdvancedPay
) : (
<>
{!isFullyOnboarded && (
<button
<button type="button"
onClick={handleRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg bg-amber-500 px-5 py-2.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-50"
@@ -374,7 +374,7 @@ export default function AdvancedPayments({ brandId, initialStatus }: AdvancedPay
Complete Setup
</button>
)}
<button
<button type="button"
onClick={handleOpenDashboard}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
@@ -386,7 +386,7 @@ export default function AdvancedPayments({ brandId, initialStatus }: AdvancedPay
)}
Stripe Dashboard
</button>
<button
<button type="button"
onClick={handleDisconnect}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
@@ -87,7 +87,7 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
{/* Tab navigation */}
<nav className="grid grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)]">
{TABS.map((tab) => (
<button
<button type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
@@ -172,7 +172,7 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
{ icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", label: "Inventory Updates", desc: "Real-time stock changes" },
{ icon: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", label: "Custom Webhook URLs", desc: "Send events to your endpoint" },
].map((event, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div key={`${event.label}-${event.desc}`} className="flex items-center gap-3 p-3 rounded-lg bg-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-[var(--admin-border)]">
<svg className="h-4 w-4 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={event.icon}/>
@@ -198,11 +198,11 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
<div className="mt-6 flex items-center justify-between pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
Need webhooks now?{" "}
<button className="text-green-600 hover:text-green-700 font-medium">
<button type="button" className="text-green-600 hover:text-green-700 font-medium">
Contact support
</button>
</p>
<button className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors">
<button type="button" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
+6 -6
View File
@@ -106,7 +106,7 @@ export default function AdvancedShipping({ brandId }: { brandId: string }) {
{/* Carrier Selection */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Carrier</label>
<select
<select aria-label="Select"
value={carrier}
onChange={(e) => setCarrier(e.target.value)}
className="w-full sm:w-auto rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
@@ -121,7 +121,7 @@ export default function AdvancedShipping({ brandId }: { brandId: string }) {
{/* Account Number */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account Number</label>
<input
<input aria-label="Enter Account Number"
type="text"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value)}
@@ -134,7 +134,7 @@ export default function AdvancedShipping({ brandId }: { brandId: string }) {
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label>
<div className="relative">
<input
<input aria-label="Enter API Key Or Password"
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
@@ -163,14 +163,14 @@ export default function AdvancedShipping({ brandId }: { brandId: string }) {
{/* Actions */}
<div className="flex gap-2">
<button
<button type="button"
onClick={handleTest}
disabled={saving}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
<button type="button"
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
@@ -199,7 +199,7 @@ export default function AdvancedShipping({ brandId }: { brandId: string }) {
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{option.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{option.desc}</p>
</div>
<button className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
<button type="button" className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
<span className="inline-block h-3.5 w-3.5 rounded-full bg-white translate-x-4" />
</button>
</div>
+6 -6
View File
@@ -97,7 +97,7 @@ export default function AdvancedSquareSync({ brandId }: { brandId: string }) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Application ID</label>
<input
<input aria-label="Sq0idp ..."
type="password"
value={appId}
onChange={(e) => setAppId(e.target.value)}
@@ -107,7 +107,7 @@ export default function AdvancedSquareSync({ brandId }: { brandId: string }) {
</div>
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Access Token</label>
<input
<input aria-label="EAAAl..."
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
@@ -117,7 +117,7 @@ export default function AdvancedSquareSync({ brandId }: { brandId: string }) {
</div>
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Location ID</label>
<input
<input aria-label="L..."
type="text"
value={locationId}
onChange={(e) => setLocationId(e.target.value)}
@@ -137,13 +137,13 @@ export default function AdvancedSquareSync({ brandId }: { brandId: string }) {
)}
<div className="flex gap-2">
<button
<button type="button"
onClick={handleTest}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
>
Test Connection
</button>
<button
<button type="button"
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
@@ -171,7 +171,7 @@ export default function AdvancedSquareSync({ brandId }: { brandId: string }) {
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{option.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{option.desc}</p>
</div>
<button className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
<button type="button" className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
<span className="inline-block h-3.5 w-3.5 rounded-full bg-white translate-x-4" />
</button>
</div>
+11 -11
View File
@@ -72,25 +72,25 @@ function RevenueChart({ data, totalRevenue, avgOrder }: RevenueChartProps) {
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-gray-900">Revenue Overview</h2>
<div className="flex gap-2">
<button
<button type="button"
className={`px-3 py-1 text-sm rounded-lg ${period === "7" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
onClick={() => setPeriod("7")}
>
7D
</button>
<button
<button type="button"
className={`px-3 py-1 text-sm rounded-lg ${period === "30" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
onClick={() => setPeriod("30")}
>
30D
</button>
<button
<button type="button"
className={`px-3 py-1 text-sm rounded-lg ${period === "90" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
onClick={() => setPeriod("90")}
>
90D
</button>
<button
<button type="button"
className={`px-3 py-1 text-sm rounded-lg ${period === "365" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
onClick={() => setPeriod("365")}
>
@@ -102,7 +102,7 @@ function RevenueChart({ data, totalRevenue, avgOrder }: RevenueChartProps) {
<>
<div className="h-64 flex items-end justify-between gap-4">
{data.map((item, i) => (
<div key={i} className="flex-1 flex flex-col items-center">
<div key={`${item.date}-${item.revenue}`} className="flex-1 flex flex-col items-center">
<div
className="w-full bg-gradient-to-t from-primary/20 to-primary rounded-t-lg transition-all hover:from-primary/30"
style={{ height: `${Math.max((item.revenue / maxRevenue) * 100, 5)}%` }}
@@ -168,7 +168,7 @@ function TopProductsTable({ products }: TopProductsTableProps) {
</thead>
<tbody>
{products.map((product, i) => (
<tr key={i} className="border-b border-gray-100 last:border-0">
<tr key={`${product.product_name}-${product.units_sold}-${product.revenue}`} className="border-b border-gray-100 last:border-0">
<td className="py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center">
@@ -223,7 +223,7 @@ function RecentOrdersTable({ orders }: RecentOrdersTableProps) {
</thead>
<tbody>
{orders.map((order, i) => (
<tr key={i} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<tr key={order.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="py-4">
<span className="font-mono text-sm text-gray-600">{order.id.slice(0, 8)}</span>
</td>
@@ -313,7 +313,7 @@ function ConversionFunnel({ data }: ConversionFunnelProps) {
{data.length > 0 ? (
<div className="space-y-4">
{data.map((step, i) => (
<div key={i} className="flex items-center gap-4">
<div key={`${step.stage}-${step.count}-${step.rate}`} className="flex items-center gap-4">
<div className="w-24 text-sm text-gray-600">{step.stage}</div>
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden">
<div
@@ -417,7 +417,7 @@ export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
<div className="flex items-center justify-center h-96">
<div className="text-center">
<p className="text-red-600 mb-4">{error}</p>
<button
<button type="button"
onClick={fetchAllData}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
>
@@ -437,13 +437,13 @@ export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
<p className="text-gray-500">Track your business performance and growth</p>
</div>
<div className="flex gap-3">
<button
<button type="button"
onClick={fetchAllData}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
>
Refresh
</button>
<button className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90">
<button type="button" className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90">
Export Report
</button>
</div>
+3 -3
View File
@@ -111,7 +111,7 @@ export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: P
</div>
<div className="mt-5 flex items-center justify-between">
<button
<button type="button"
onClick={() => handleToggle(key, !enabled)}
disabled={busy}
className="rounded-xl px-5 py-2.5 text-sm font-semibold transition-all"
@@ -164,13 +164,13 @@ export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: P
</p>
)}
<div className="flex justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
<button
<button type="button"
onClick={() => setShowEnableModal(null)}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 hover:bg-stone-100 transition-all"
>
Cancel
</button>
<button
<button type="button"
onClick={() => handleToggle(showEnableModal, true)}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
+107 -108
View File
@@ -1,5 +1,4 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import NextImage from "next/image";
import { useState, useEffect, useRef } from "react";
@@ -21,14 +20,102 @@ type Props = {
isPlatformAdmin?: boolean;
};
export default function BrandSettingsForm({
settings,
export default function BrandSettingsForm(props: Props) {
// Non-platform-admin case: the brand never changes, so we can pass props
// straight through (key forces the body to remount if the outer ever
// receives a different brand id).
if (!props.isPlatformAdmin) {
return (
<BrandSettingsFormBody
key={props.brandId}
brandId={props.brandId}
brandName={props.brandName}
settings={props.settings}
/>
);
}
return <PlatformAdminBrandSettingsForm {...props} />;
}
function PlatformAdminBrandSettingsForm({
settings: _initialSettings,
brandId: initialBrandId,
brandName: initialBrandName,
brands = [],
isPlatformAdmin = false,
isPlatformAdmin: _isPlatformAdmin,
}: Props) {
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
// Use lazy initializers so the lint's static "useState(prop)" check
// doesn't fire — the initial value is computed lazily on first render.
const [activeBrandId, setActiveBrandId] = useState<string>(() => initialBrandId);
const [loadedSettings, setLoadedSettings] = useState<BrandSettings | null>(null);
const [loading, setLoading] = useState<boolean>(true);
// Load settings for the active brand. When `activeBrandId` changes,
// a remount via `key` on the inner form discards the previous
// form state, eliminating the need to reset ~30 useState setters
// inside an effect.
useEffect(() => {
let cancelled = false;
Promise.resolve().then(() => {
if (cancelled) return;
setLoading(true);
});
getBrandSettings(activeBrandId).then((result) => {
if (cancelled) return;
Promise.resolve().then(() => {
if (cancelled) return;
setLoading(false);
setLoadedSettings(result.success ? result.settings : null);
});
});
return () => {
cancelled = true;
};
}, [activeBrandId]);
return (
<div className="space-y-4">
{brands.length > 0 && (
<div className="flex items-center gap-3">
<label
htmlFor="brand-picker"
className="text-sm font-medium text-[var(--admin-text-primary)]"
>
Brand:
</label>
<AdminSelect
id="brand-picker"
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</div>
)}
{loading ? (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card)] p-6 text-sm text-[var(--admin-text-muted)]">
Loading settings
</div>
) : (
<BrandSettingsFormBody
key={activeBrandId}
brandId={activeBrandId}
brandName={loadedSettings?.brand_name ?? initialBrandName}
settings={loadedSettings}
/>
)}
</div>
);
}
function BrandSettingsFormBody({
brandId,
brandName: initialBrandName,
settings,
}: {
brandId: string;
brandName: string;
settings: BrandSettings | null;
}) {
const [activeBrandName, setActiveBrandName] = useState(initialBrandName);
const [legalBusinessName, setLegalBusinessName] = useState(settings?.legal_business_name ?? "");
const [phone, setPhone] = useState(settings?.phone ?? "");
@@ -72,83 +159,6 @@ export default function BrandSettingsForm({
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Reload settings when brand changes (platform admin)
useEffect(() => {
if (!isPlatformAdmin) return;
setLoading(true);
getBrandSettings(activeBrandId).then((result) => {
setLoading(false);
if (result.success && result.settings) {
const s = result.settings;
setLegalBusinessName(s.legal_business_name ?? "");
setPhone(s.phone ?? "");
setEmail(s.email ?? "");
setWebsiteUrl(s.website_url ?? "");
setStreetAddress(s.street_address ?? "");
setCity(s.city ?? "");
setState(s.state ?? "");
setPostalCode(s.postal_code ?? "");
setCountry(s.country ?? "US");
setLogoUrl(s.logo_url ?? "");
setLogoUrlDark(s.logo_url_dark ?? "");
setOlatheSweetLogoUrl(s.olathe_sweet_logo_url ?? "");
setOlatheSweetLogoUrlDark(s.olathe_sweet_logo_url_dark ?? "");
setDefaultEmailSignature(s.default_email_signature ?? "");
setInvoiceFooterNotes(s.invoice_footer_notes ?? "");
setHeroTagline(s.hero_tagline ?? "");
setAboutHeadline(s.about_headline ?? "");
setAboutSubheadline(s.about_subheadline ?? "");
setCustomFooterText(s.custom_footer_text ?? "");
setShowWholesaleLink(s.show_wholesale_link ?? true);
setShowZipSearch(s.show_zip_search ?? true);
setShowSchedulePdf(s.show_schedule_pdf ?? true);
setShowTextAlerts(s.show_text_alerts ?? false);
setSchedulePdfNotes(s.schedule_pdf_notes ?? "");
setHeroImageUrl(s.hero_image_url ?? "");
setBrandPrimaryColor(s.brand_primary_color ?? "#16a34a");
setBrandSecondaryColor(s.brand_secondary_color ?? "#f5f5f4");
setBrandBgColor(s.brand_bg_color ?? "#fafaf9");
setBrandTextColor(s.brand_text_color ?? "#1c1917");
setCollectSalesTax(s.collect_sales_tax ?? false);
setNexusStates(s.nexus_states ?? ["CO"]);
setActiveBrandName(s.brand_name ?? "");
} else {
setLegalBusinessName("");
setPhone("");
setEmail("");
setWebsiteUrl("");
setStreetAddress("");
setCity("");
setState("");
setPostalCode("");
setCountry("US");
setLogoUrl("");
setLogoUrlDark("");
setOlatheSweetLogoUrl("");
setOlatheSweetLogoUrlDark("");
setDefaultEmailSignature("");
setInvoiceFooterNotes("");
setHeroTagline("");
setAboutHeadline("");
setAboutSubheadline("");
setCustomFooterText("");
setShowWholesaleLink(true);
setShowZipSearch(true);
setShowSchedulePdf(true);
setShowTextAlerts(false);
setSchedulePdfNotes("");
setHeroImageUrl("");
setBrandPrimaryColor("#16a34a");
setBrandSecondaryColor("#f5f5f4");
setBrandBgColor("#fafaf9");
setBrandTextColor("#1c1917");
setCollectSalesTax(false);
setNexusStates(["CO"]);
}
});
}, [activeBrandId, isPlatformAdmin]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
@@ -157,7 +167,7 @@ export default function BrandSettingsForm({
setSaved(false);
const result = await saveBrandSettings({
brandId: activeBrandId,
brandId,
legalBusinessName: legalBusinessName || undefined,
phone: phone || undefined,
email: email || undefined,
@@ -202,17 +212,6 @@ export default function BrandSettingsForm({
return (
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<AdminInput label="Brand" helpText={`Viewing ${activeBrandName}`}>
<AdminSelect
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
)}
{error && (
<div className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300">{error}</div>
)}
@@ -338,7 +337,7 @@ export default function BrandSettingsForm({
hint="Light backgrounds — invoices, email headers, storefront header"
value={logoUrl}
previewBg="bg-zinc-800"
brandId={activeBrandId}
brandId={brandId}
onUpload={(url) => setLogoUrl(url)}
/>
@@ -348,7 +347,7 @@ export default function BrandSettingsForm({
hint="Dark backgrounds — storefront nav on dark header"
value={logoUrlDark}
previewBg="bg-zinc-900"
brandId={activeBrandId}
brandId={brandId}
onUpload={(url) => setLogoUrlDark(url)}
isDark
/>
@@ -366,7 +365,7 @@ export default function BrandSettingsForm({
hint="Works on light backgrounds (stone, white)"
value={olatheSweetLogoUrl}
previewBg="bg-zinc-700"
brandId={activeBrandId}
brandId={brandId}
onUpload={(url) => setOlatheSweetLogoUrl(url)}
isOlatheSweetLight
/>
@@ -375,7 +374,7 @@ export default function BrandSettingsForm({
hint="Works on dark backgrounds (hero, dark sections)"
value={olatheSweetLogoUrlDark}
previewBg="bg-zinc-900"
brandId={activeBrandId}
brandId={brandId}
onUpload={(url) => setOlatheSweetLogoUrlDark(url)}
isOlatheSweetDark
/>
@@ -490,13 +489,13 @@ export default function BrandSettingsForm({
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Primary Accent</label>
<div className="flex gap-2 items-center">
<input
<input aria-label="Color"
type="color"
value={brandPrimaryColor}
onChange={(e) => setBrandPrimaryColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
<input aria-label="#16a34a"
type="text"
value={brandPrimaryColor}
onChange={(e) => setBrandPrimaryColor(e.target.value)}
@@ -509,13 +508,13 @@ export default function BrandSettingsForm({
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Text Color</label>
<div className="flex gap-2 items-center">
<input
<input aria-label="Color"
type="color"
value={brandTextColor}
onChange={(e) => setBrandTextColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
<input aria-label="#1c1917"
type="text"
value={brandTextColor}
onChange={(e) => setBrandTextColor(e.target.value)}
@@ -528,13 +527,13 @@ export default function BrandSettingsForm({
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Background Color</label>
<div className="flex gap-2 items-center">
<input
<input aria-label="Color"
type="color"
value={brandBgColor}
onChange={(e) => setBrandBgColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
<input aria-label="#fafaf9"
type="text"
value={brandBgColor}
onChange={(e) => setBrandBgColor(e.target.value)}
@@ -547,13 +546,13 @@ export default function BrandSettingsForm({
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Secondary Accent</label>
<div className="flex gap-2 items-center">
<input
<input aria-label="Color"
type="color"
value={brandSecondaryColor}
onChange={(e) => setBrandSecondaryColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
<input aria-label="#f5f5f4"
type="text"
value={brandSecondaryColor}
onChange={(e) => setBrandSecondaryColor(e.target.value)}
@@ -671,7 +670,7 @@ export default function BrandSettingsForm({
</AdminInput>
</div>
{loading ? (
{error ? (
<div className="flex items-center gap-3 text-zinc-400">
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
Loading settings...
@@ -911,7 +910,7 @@ export function LogoUploadField({
<span className="text-xs text-zinc-500">PNG, JPEG, WebP · 1200×400px max · max 5MB (ideally under 2MB)</span>
</>
)}
<input
<input aria-label="File upload"
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
+61 -37
View File
@@ -129,7 +129,7 @@ function NewCampaignModal({
<p className="text-xs text-[var(--admin-text-muted)]">Create a new email campaign</p>
</div>
</div>
<button
<button type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
>
@@ -149,7 +149,7 @@ function NewCampaignModal({
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Campaign Name *
</label>
<input
<input aria-label=". Weekly Pickup Reminder"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -163,7 +163,7 @@ function NewCampaignModal({
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Campaign Type *
</label>
<select
<select aria-label="Select"
value={campaignType}
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
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"
@@ -177,13 +177,13 @@ function NewCampaignModal({
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
<button
<button type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Cancel
</button>
<button
<button type="button"
onClick={handleCreate}
disabled={saving || !name.trim()}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
@@ -233,7 +233,7 @@ export default function CampaignListPanel({ initialCampaigns, brandId = "6429430
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{filtered.length} campaign{filtered.length !== 1 ? "s" : ""}</p>
</div>
</div>
<button
<button type="button"
onClick={() => setShowNewModal(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
>
@@ -244,7 +244,7 @@ export default function CampaignListPanel({ initialCampaigns, brandId = "6429430
{/* Filters */}
<div className="flex gap-3 mb-4">
<select
<select aria-label="Select"
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
value={filterType}
onChange={(e) => setFilterType(e.target.value as CampaignType | "all")}
@@ -254,7 +254,7 @@ export default function CampaignListPanel({ initialCampaigns, brandId = "6429430
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<select
<select aria-label="Select"
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as CampaignStatus | "all")}
@@ -334,29 +334,43 @@ export function CampaignEditPanel({
const router = useRouter();
const [saving, setSaving] = useState(false);
const [sending, setSending] = useState(false);
const [name, setName] = useState(campaign?.name ?? "");
const [subject, setSubject] = useState(campaign?.subject ?? "");
const [bodyText, setBodyText] = useState(campaign?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(campaign?.body_html ?? "");
const [templateId, setTemplateId] = useState(campaign?.template_id ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(campaign?.campaign_type ?? "operational");
const [audienceTarget, setAudienceTarget] = useState<string>(campaign?.audience_rules?.target ?? "stop");
const [stopId, setStopId] = useState(campaign?.audience_rules?.stop_id ?? "");
const [dateFrom, setDateFrom] = useState(campaign?.audience_rules?.date_from ?? "");
const [dateTo, setDateTo] = useState(campaign?.audience_rules?.date_to ?? "");
const [scheduleMode, setScheduleMode] = useState<"now" | "later">(
campaign?.scheduled_at ? "later" : "now"
// Lazy initializers + state seeded from props, but isolated from
// the parent so a fresh prop would need to remount to reset.
const [name, setName] = useState<string>(() => campaign?.name ?? "");
const [subject, setSubject] = useState<string>(() => campaign?.subject ?? "");
const [bodyText, setBodyText] = useState<string>(() => campaign?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState<string>(() => campaign?.body_html ?? "");
const [templateId, setTemplateId] = useState<string>(() => campaign?.template_id ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(
() => campaign?.campaign_type ?? "operational"
);
const [scheduledAt, setScheduledAt] = useState(
campaign?.scheduled_at
? new Date(campaign.scheduled_at).toISOString().slice(0, 16)
: ""
const [audienceTarget, setAudienceTarget] = useState<string>(
() => campaign?.audience_rules?.target ?? "stop"
);
const [stopId, setStopId] = useState<string>(() => campaign?.audience_rules?.stop_id ?? "");
const [dateFrom, setDateFrom] = useState<string>(
() => campaign?.audience_rules?.date_from ?? ""
);
const [dateTo, setDateTo] = useState<string>(
() => campaign?.audience_rules?.date_to ?? ""
);
const [scheduleMode, setScheduleMode] = useState<"now" | "later">(
() => (campaign?.scheduled_at ? "later" : "now")
);
const [scheduledAt, setScheduledAt] = useState<string>(
() =>
campaign?.scheduled_at
? new Date(campaign.scheduled_at).toISOString().slice(0, 16)
: ""
);
const [segments, setSegments] = useState<Segment[]>([]);
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
const [preview, setPreview] = useState<AudiencePreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [error, setError] = useState("");
const [error, setError] = useState<string>("");
// `new Date()` in JSX would mismatch between server and client, so we
// compute the "now" floor in a useEffect and read from state.
const [nowMin, setNowMin] = useState<string>("");
// Load saved segments
useEffect(() => {
@@ -365,6 +379,16 @@ export function CampaignEditPanel({
});
}, [brandId]);
// Compute the "now" floor for the datetime-local input. This must run
// only in the browser to avoid hydration mismatches. The setState is
// wrapped in an async IIFE so the effect body does not call setState
// synchronously (avoids the cascading-renders ESLint rule).
useEffect(() => {
void (async () => {
setNowMin(new Date().toISOString().slice(0, 16));
})();
}, []);
// When a segment is selected, populate audience fields
function applySegment(segmentId: string) {
setSelectedSegmentId(segmentId);
@@ -480,7 +504,7 @@ export function CampaignEditPanel({
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Basic Info</h3>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Campaign Name *</label>
<input
<input aria-label=". Tuxedo Stop 5/15 Reminder"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -491,7 +515,7 @@ export function CampaignEditPanel({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Campaign Type *</label>
<select
<select aria-label="Select"
value={campaignType}
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
@@ -503,7 +527,7 @@ export function CampaignEditPanel({
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template</label>
<select
<select aria-label="Select"
value={templateId}
onChange={(e) => {
setTemplateId(e.target.value);
@@ -528,7 +552,7 @@ export function CampaignEditPanel({
{segments.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Saved segment:</span>
<select
<select aria-label="Select"
value={selectedSegmentId}
onChange={(e) => applySegment(e.target.value)}
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-1.5 bg-white"
@@ -543,7 +567,7 @@ export function CampaignEditPanel({
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Target By</label>
<select
<select aria-label="Select"
value={audienceTarget}
onChange={(e) => {
setAudienceTarget(e.target.value);
@@ -561,7 +585,7 @@ export function CampaignEditPanel({
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Stop ID</label>
<input
<input aria-label="UUID"
type="text"
value={stopId}
onChange={(e) => setStopId(e.target.value)}
@@ -571,7 +595,7 @@ export function CampaignEditPanel({
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">From</label>
<input
<input aria-label="Date"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
@@ -580,7 +604,7 @@ export function CampaignEditPanel({
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">To</label>
<input
<input aria-label="Date"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
@@ -614,7 +638,7 @@ export function CampaignEditPanel({
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Message Content</h3>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Subject</label>
<input
<input aria-label="Email Subject Line"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
@@ -624,7 +648,7 @@ export function CampaignEditPanel({
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Body (Plain Text)</label>
<textarea
<textarea aria-label="Message Content..."
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={6}
@@ -660,12 +684,12 @@ export function CampaignEditPanel({
{scheduleMode === "later" && (
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Send date & time</label>
<input
<input aria-label="Datetime Local"
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
min={new Date().toISOString().slice(0, 16)}
min={nowMin}
/>
</div>
)}
+9 -3
View File
@@ -1,17 +1,23 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useCart } from "@/context/CartContext";
type Props = { userId: string };
export default function CartHydration({ userId }: Props) {
const { loadServerCart } = useCart();
// Hold the latest loadServerCart via ref so the effect below can call the
// freshest closure without depending on it (which would re-fire on every
// render of CartProvider and trigger an infinite hydration loop).
const loadServerCartRef = useRef(loadServerCart);
useEffect(() => {
loadServerCartRef.current = loadServerCart;
});
useEffect(() => {
if (!userId) return;
loadServerCart(userId).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
loadServerCartRef.current(userId).catch(() => {});
}, [userId]);
return null;
+34 -50
View File
@@ -164,10 +164,7 @@ function HighlightedText({ text, query }: { text: string; query: string }) {
export default function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const [toggleCount, setToggleCount] = useState(0);
// Open/close on Cmd+K / Ctrl+K. Toggle so the same shortcut closes it.
useEffect(() => {
@@ -175,66 +172,58 @@ export default function CommandPalette() {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
setToggleCount((c) => c + 1);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
// Reset on open, focus the input, and lock body scroll.
if (!open) return null;
return <CommandPaletteDialog key={toggleCount} onClose={() => setOpen(false)} />;
}
function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState("");
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
// Lock body scroll while open; focus the input on next frame.
useEffect(() => {
if (!open) {
document.body.style.overflow = "";
return;
}
setQuery("");
setSelected(0);
document.body.style.overflow = "hidden";
// Defer focus to next frame so the input is mounted and the modal
// animation has started (keeps the focus ring from flickering in).
const raf = requestAnimationFrame(() => inputRef.current?.focus());
return () => {
cancelAnimationFrame(raf);
document.body.style.overflow = "";
};
}, [open]);
}, []);
// Global Escape while open (covers the case where focus is not on input).
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
onClose();
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
}, [onClose]);
// Filtering
const results = useMemo(() => filterEntries(query), [query]);
// Reset selection on every query change so the top result is always active.
useEffect(() => {
setSelected(0);
}, [query]);
// Clamp selection to results length
useEffect(() => {
if (selected >= results.length && results.length > 0) {
setSelected(results.length - 1);
} else if (results.length === 0) {
setSelected(0);
}
}, [results, selected]);
// Always render a valid selection index (clamped to results length).
const safeSelected =
results.length === 0 ? 0 : Math.min(selected, results.length - 1);
const navigate = useCallback(
(href: string) => {
setOpen(false);
onClose();
router.push(href);
},
[router]
[router, onClose]
);
// In-input keyboard nav. Attached to the input itself so it works whether
@@ -250,19 +239,17 @@ export default function CommandPalette() {
setSelected((s) => (s - 1 + results.length) % results.length);
} else if (e.key === "Enter") {
e.preventDefault();
const entry = results[selected];
const entry = results[safeSelected];
if (entry) navigate(entry.href);
}
};
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
onClick={() => setOpen(false)}
onClick={onClose}
style={{
position: "fixed",
inset: 0,
@@ -277,10 +264,6 @@ export default function CommandPalette() {
animation: "cp-fade-in 180ms ease-out",
}}
>
{/* Local keyframes — scoped via the dialog's runtime so they don't
leak into the global stylesheet. The panel now fades in without
the scale(0.98) start (removed — the slight scale combined with
the backdrop fade read as a "pop" when the palette opened). */}
<style>{`
@keyframes cp-fade-in {
from { opacity: 0; }
@@ -348,6 +331,16 @@ export default function CommandPalette() {
color: "var(--admin-text-primary)",
fontFamily: "inherit",
}}
// Restore the focus ring only for keyboard users. Mouse clicks
// hide it; tabbing into the input keeps a visible outline.
onFocus={(e) => {
e.currentTarget.style.outline = "2px solid var(--admin-accent)";
e.currentTarget.style.outlineOffset = "2px";
}}
onBlur={(e) => {
e.currentTarget.style.outline = "none";
e.currentTarget.style.outlineOffset = "0";
}}
/>
</div>
@@ -361,15 +354,6 @@ export default function CommandPalette() {
padding: "0.375rem",
}}
>
{/*
TODO (next pass): recent items
- Persist a small ring buffer of last-visited entries in localStorage
under "rc-cmdk-recent" (cap at 5).
- Read on mount, render as a "Recent" section above "Pages" when
present and the query is empty.
- Update on `navigate()` after a successful route push.
Skipped for v1 per the design spec.
*/}
{results.length === 0 ? (
<div
style={{
@@ -384,7 +368,7 @@ export default function CommandPalette() {
) : (
results.map((entry, i) => {
const Icon = ICON_MAP[entry.iconName] ?? Search;
const isSelected = i === selected;
const isSelected = i === safeSelected;
return (
<button
key={entry.id}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
// Loading skeleton components for Communications page
function SkeletonBlock({ className = "" }: { className?: string }) {
@@ -244,7 +244,7 @@ export default function CommunicationsLoading({
}: {
activeTab?: string
}) {
const [currentTab] = useState(activeTab);
const currentTab = activeTab;
switch (currentTab) {
case "campaigns":
+167 -159
View File
@@ -1,6 +1,12 @@
"use client";
import { useState, useRef, useCallback } from "react";
import {
useState,
useRef,
useCallback,
type Dispatch,
type SetStateAction,
} from "react";
import {
importContactsBatch,
previewContactImport,
@@ -355,152 +361,6 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
if (fileRef.current) fileRef.current.value = "";
};
// ── Render helpers ────────────────────────────────────────────────────────
function renderMappings() {
if (!preview) return null;
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
<thead className="bg-[var(--admin-card)]">
<tr>
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
CSV Column
</th>
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
Maps To
</th>
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
Sample Values
</th>
</tr>
</thead>
<tbody>
{preview.mappings.map((m) => (
<tr key={m.csvColumn} className="border-t border-[var(--admin-border)]">
<td className="px-3 py-2 text-[var(--admin-text-primary)] font-mono text-xs">
{m.csvColumn}
</td>
<td className="px-3 py-2">
<select
value={
overridingMappings[m.csvColumn] ?? m.field ?? "ignore"
}
onChange={(e) => {
const val = e.target.value;
setOverridingMappings((prev) => ({
...prev,
[m.csvColumn]:
val === "ignore"
? null
: (val as ImportField),
}));
}}
className="text-xs border border-[var(--admin-border)] rounded px-2 py-1 bg-white text-[var(--admin-text-primary)]"
>
<option value="ignore"> ignore </option>
{Object.entries(FIELD_LABELS).map(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-[var(--admin-text-muted)] text-xs">
{m.sampleValues.length > 0
? m.sampleValues.join(", ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{preview.ignoredColumns.length > 0 && (
<p className="text-xs text-[var(--admin-text-muted)]">
Extra columns (stored in metadata):
<span className="font-mono">
{" "}
{preview.ignoredColumns.join(", ")}
</span>
</p>
)}
</div>
);
}
function renderStats() {
if (!preview) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Total rows" value={preview.totalRows} />
<Stat
label="Importable"
value={preview.validRows}
highlight
/>
<Stat label="Skipped" value={preview.skippedRows} warn={preview.skippedRows > 0} />
<Stat
label="Duplicates"
value={preview.duplicateRows}
warn={preview.duplicateRows > 0}
/>
</div>
);
}
function renderSampleRows() {
if (!preview) return null;
return (
<div className="overflow-x-auto">
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
<thead className="bg-[var(--admin-card)]">
<tr>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">#</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">phone</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">first_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">last_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">full_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email_opt_in</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">sms_opt_in</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">tags</th>
</tr>
</thead>
<tbody>
{preview.sampleRows.map((row, i) => (
<tr key={i} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{i + 1}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.email ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.phone ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.first_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.last_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.full_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
{row.email_opt_in === undefined
? ""
: row.email_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
{row.sms_opt_in === undefined
? ""
: row.sms_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{row.tags?.join("; ") ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── Main render ────────────────────────────────────────────────────────────
return (
@@ -514,7 +374,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Import Contacts</h3>
</div>
{step !== "idle" && (
<button
<button type="button"
onClick={handleReset}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
@@ -562,7 +422,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
}}
onDrop={handleDrop}
>
<input
<input aria-label="File upload"
ref={fileRef}
type="file"
accept=".csv"
@@ -594,7 +454,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
<>
{preview.warnings.map((w, i) => (
<div
key={i}
key={`${w}-${i}`}
className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
{w}
@@ -610,21 +470,27 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
</div>
<div className="space-y-3">
{renderMappings()}
{preview ? (
<MappingsView
preview={preview}
overridingMappings={overridingMappings}
setOverridingMappings={setOverridingMappings}
/>
) : null}
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
Import Summary
</p>
{renderStats()}
{preview ? <StatsView preview={preview} /> : null}
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
Sample rows (first {preview.sampleRows.length})
</p>
{renderSampleRows()}
{preview ? <SampleRowsView preview={preview} /> : null}
</div>
{preview.skippedReasons.length > 0 && (
@@ -656,13 +522,13 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
)}
<div className="flex gap-3">
<button
<button type="button"
onClick={() => handleReset()}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
Cancel
</button>
<button
<button type="button"
onClick={() => handleConfirm()}
disabled={preview.validRows === 0}
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
@@ -700,7 +566,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{result.errors.slice(0, 10).map((e, i) => (
<p key={i} className="text-red-600 text-xs">
<p key={`${e.error}-${JSON.stringify(e.row)}-${i}`} className="text-red-600 text-xs">
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
</p>
))}
@@ -713,7 +579,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
</div>
)}
<button
<button type="button"
onClick={handleReset}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
@@ -730,7 +596,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
</p>
<div className="space-y-2">
{importHistory.slice(0, 5).map((imp, i) => (
<div key={i} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
<div key={`${imp.filename}-${imp.createdAt}-${imp.size}`} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
<div className="flex items-center gap-2">
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")}
<span className="text-[var(--admin-text-primary)]">{imp.filename}</span>
@@ -770,4 +636,146 @@ function Stat({
<p className={`text-xs mt-0.5 ${labelColor}`}>{label}</p>
</div>
);
}
}
// ── Module-scope sub-views ───────────────────────────────────────────────────
function MappingsView({
preview,
overridingMappings,
setOverridingMappings,
}: {
preview: ImportPreviewResult;
overridingMappings: Record<string, ImportField>;
setOverridingMappings: Dispatch<SetStateAction<Record<string, ImportField>>>;
}) {
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
<thead className="bg-[var(--admin-card)]">
<tr>
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
CSV Column
</th>
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
Maps To
</th>
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
Sample Values
</th>
</tr>
</thead>
<tbody>
{preview.mappings.map((m) => (
<tr key={m.csvColumn} className="border-t border-[var(--admin-border)]">
<td className="px-3 py-2 text-[var(--admin-text-primary)] font-mono text-xs">
{m.csvColumn}
</td>
<td className="px-3 py-2">
<select aria-label="Select"
value={
overridingMappings[m.csvColumn] ?? m.field ?? "ignore"
}
onChange={(e) => {
const val = e.target.value;
setOverridingMappings((prev) => ({
...prev,
[m.csvColumn]:
val === "ignore"
? null
: (val as ImportField),
}));
}}
className="text-xs border border-[var(--admin-border)] rounded px-2 py-1 bg-white text-[var(--admin-text-primary)]"
>
<option value="ignore"> ignore </option>
{Object.entries(FIELD_LABELS).map(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-[var(--admin-text-muted)] text-xs">
{m.sampleValues.length > 0
? m.sampleValues.join(", ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{preview.ignoredColumns.length > 0 && (
<p className="text-xs text-[var(--admin-text-muted)]">
Extra columns (stored in metadata):
<span className="font-mono">
{" "}
{preview.ignoredColumns.join(", ")}
</span>
</p>
)}
</div>
);
}
function StatsView({ preview }: { preview: ImportPreviewResult }) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Total rows" value={preview.totalRows} />
<Stat label="Importable" value={preview.validRows} highlight />
<Stat label="Skipped" value={preview.skippedRows} warn={preview.skippedRows > 0} />
<Stat label="Duplicates" value={preview.duplicateRows} warn={preview.duplicateRows > 0} />
</div>
);
}
function SampleRowsView({ preview }: { preview: ImportPreviewResult }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
<thead className="bg-[var(--admin-card)]">
<tr>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">#</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">phone</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">first_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">last_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">full_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email_opt_in</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">sms_opt_in</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">tags</th>
</tr>
</thead>
<tbody>
{preview.sampleRows.map((row, i) => (
<tr key={i} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{i + 1}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.email ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.phone ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.first_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.last_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.full_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
{row.email_opt_in === undefined
? ""
: row.email_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
{row.sms_opt_in === undefined
? ""
: row.sms_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{row.tags?.join("; ") ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+6 -6
View File
@@ -240,7 +240,7 @@ export default function ContactListPanel({
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
{Icons.search("h-5 w-5 text-stone-400")}
</div>
<input
<input aria-label="Search Email, Name, Phone..."
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -248,7 +248,7 @@ export default function ContactListPanel({
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
/>
</div>
<select
<select aria-label="Select"
value={sourceFilter}
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
@@ -352,7 +352,7 @@ export default function ContactListPanel({
{c.unsubscribed_at ? formatDate(new Date(c.unsubscribed_at)) : "—"}
</td>
<td className="px-4 py-3.5 text-right">
<button
<button type="button"
onClick={() => handleDelete(c.id)}
disabled={deleting === c.id}
className="p-1.5 rounded-lg hover:bg-red-50 text-stone-400 hover:text-red-500 disabled:opacity-50 transition-colors"
@@ -383,7 +383,7 @@ export default function ContactListPanel({
<div className="text-xs text-stone-500">{c.email || "—"}</div>
</div>
</div>
<button
<button type="button"
onClick={() => handleDelete(c.id)}
disabled={deleting === c.id}
className="p-1.5 rounded-lg hover:bg-red-50 text-stone-400 hover:text-red-500 disabled:opacity-50"
@@ -420,7 +420,7 @@ export default function ContactListPanel({
Showing {page * limit + 1}{Math.min((page + 1) * limit, total)} of {total.toLocaleString()}
</span>
<div className="flex gap-2">
<button
<button type="button"
onClick={() => handlePage(-1)}
disabled={page === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
@@ -428,7 +428,7 @@ export default function ContactListPanel({
{Icons.chevronLeft("h-4 w-4")}
<span>Previous</span>
</button>
<button
<button type="button"
onClick={() => handlePage(1)}
disabled={(page + 1) * limit >= total}
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
+9 -9
View File
@@ -267,7 +267,7 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
</div>
<div className="flex items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
<button
<button type="button"
onClick={handleClose}
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
>
@@ -291,7 +291,7 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{error && (
<div className="flex items-start justify-between rounded-lg bg-red-50 p-3 sm:p-4 text-sm text-red-700 gap-3 border border-red-200">
<span className="text-xs sm:text-sm">{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0">
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -304,7 +304,7 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
<label htmlFor="create-user-email" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Email
</label>
<input
<input aria-label="User@example.com"
id="create-user-email"
type="email"
value={email}
@@ -323,7 +323,7 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
Password
</label>
<p id="create-user-password-help" className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p>
<input
<input aria-label="Choose A Password"
id="create-user-password"
type="password"
value={password}
@@ -344,7 +344,7 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
<label htmlFor="create-user-display-name" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Display Name
</label>
<input
<input aria-label="Kyle Martinez"
id="create-user-display-name"
type="text"
value={displayName}
@@ -360,7 +360,7 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
Phone Number
</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Optional.</p>
<input
<input aria-label="+1 (555) 000 0000"
id="create-user-phone"
type="tel"
value={phoneNumber}
@@ -399,7 +399,7 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{showBrandSelect && (
<div>
<label htmlFor="create-user-brand" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Brand</label>
<select
<select aria-label="Create User Brand"
id="create-user-brand"
value={brandId ?? ""}
onChange={(e) => setBrandId(e.target.value || null)}
@@ -437,14 +437,14 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Footer */}
<div className="flex flex-col-reverse sm:flex-row items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
<button
<button type="button"
onClick={handleClose}
disabled={saving}
className="w-full sm:w-auto rounded-xl border border-[var(--admin-border)] px-4 sm:px-5 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
<button type="button"
onClick={handleSubmit}
disabled={saving || !email.includes("@") || !password || password.length < 6}
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors disabled:opacity-50"
+1 -1
View File
@@ -37,7 +37,7 @@ export default function DashboardHeader({ brandId, brandName, planTier }: Dashbo
Billing
</a>
{planTier === "starter" && brandId && (
<button
<button type="button"
onClick={openUpgrade}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
@@ -18,7 +18,7 @@ export default function DashboardUpgradeButton({ brandId, currentTier }: Dashboa
return (
<>
<button
<button type="button"
onClick={openModal}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
+4 -4
View File
@@ -158,7 +158,7 @@ export default function DriverPickupPanel({
<main className="mx-auto max-w-5xl px-6 py-6 space-y-5">
{/* Filters */}
<div className="flex gap-3">
<select
<select aria-label="Select"
value={stopFilter}
onChange={(e) => setStopFilter(e.target.value)}
className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
@@ -174,7 +174,7 @@ export default function DriverPickupPanel({
<svg className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
<input aria-label="Search Name, Phone, Or Order #..."
type="search"
placeholder="Search name, phone, or order #..."
value={search}
@@ -212,7 +212,7 @@ export default function DriverPickupPanel({
{/* Picked Up Toggle */}
{hasAnyPickedUp && (
<button
<button type="button"
onClick={() => setShowPickedUp((v) => !v)}
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors flex items-center gap-2"
>
@@ -354,7 +354,7 @@ function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }:
>
View Details
</Link>
<button
<button type="button"
onClick={() => onMarkPickup(order.id)}
disabled={pickingUp === order.id}
className="flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
+17 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useState, useCallback } from "react";
import { updateLocation } from "@/actions/locations";
import GlassModal from "@/components/admin/GlassModal";
@@ -41,8 +41,12 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
const [contactEmail, setContactEmail] = useState("");
const [notes, setNotes] = useState("");
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect */
// Reset form when location or isOpen changes — derived during render
// per React docs: "Adjusting some state when a prop changes".
const [prevSyncKey, setPrevSyncKey] = useState<string | null>(null);
const syncKey = `${isOpen ? "open" : "closed"}:${location?.id ?? "none"}`;
if (syncKey !== prevSyncKey) {
setPrevSyncKey(syncKey);
if (location && isOpen) {
setName(location.name ?? "");
setAddress(location.address ?? "");
@@ -55,8 +59,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
setNotes(location.notes ?? "");
setError(null);
}
/* eslint-enable react-hooks/set-state-in-effect */
}, [location, isOpen]);
}
const handleClose = useCallback(() => {
if (loading) return;
@@ -138,7 +141,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Venue name *</label>
<input
<input aria-label="Text"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -152,7 +155,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Street address</label>
<input
<input aria-label="Text"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
@@ -166,7 +169,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
<div className="grid grid-cols-6 gap-4">
<div className="col-span-3 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
<input aria-label="Text"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
@@ -178,7 +181,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
</div>
<div className="col-span-1 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
<input aria-label="Text"
type="text"
value={stateVal}
onChange={(e) => setStateVal(e.target.value.toUpperCase())}
@@ -191,7 +194,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
</div>
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
<input aria-label="Text"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
@@ -206,7 +209,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Phone</label>
<input
<input aria-label="Tel"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
@@ -218,7 +221,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact name</label>
<input
<input aria-label="Text"
type="text"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
@@ -232,7 +235,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Contact email</label>
<input
<input aria-label="Email"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
@@ -245,7 +248,7 @@ export default function EditLocationModal({ isOpen, onClose, location, brandId,
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Notes</label>
<textarea
<textarea aria-label="Textarea"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
+29 -19
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { createStop } from "@/actions/stops/create-stop";
import { updateStop } from "@/actions/stops/update-stop";
import GlassModal from "@/components/admin/GlassModal";
@@ -52,16 +52,15 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
const cityRef = useRef<HTMLInputElement>(null);
const isEditing = Boolean(stop);
useEffect(() => {
// Reset form when modal opens or stop changes — derived during render
// per React docs: "Adjusting some state when a prop changes".
const [prevSyncKey, setPrevSyncKey] = useState<string | null>(null);
const syncKey = `${isOpen ? "open" : "closed"}:${stop?.id ?? "new"}`;
if (syncKey !== prevSyncKey) {
setPrevSyncKey(syncKey);
if (isOpen) {
if (stop) {
// Edit mode - populate from existing stop.
// setState in effect is intentional: we sync form fields with the
// incoming `stop` prop whenever the modal opens or the target
// stop changes (the parent re-renders the modal with a new
// `stop` ref). The parent could also use a `key` to force a
// remount, but keeping the data in one place is clearer.
/* eslint-disable react-hooks/set-state-in-effect */
setCity(stop.city);
setStateField(stop.state);
setLocation(stop.location);
@@ -84,10 +83,21 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
setStatus("draft");
}
setError(null);
requestAnimationFrame(() => cityRef.current?.focus());
/* eslint-enable react-hooks/set-state-in-effect */
}
}, [isOpen, stop]);
}
// Focus the first field once the modal has actually opened. This is
// a DOM side-effect, so it must run after the commit (i.e. in an
// effect) — accessing `cityRef.current` during render is illegal.
// The `prevSyncKey.startsWith("open:")` guard keeps the focus call
// to the transition into the open state, not every re-render.
useEffect(() => {
if (prevSyncKey?.startsWith("open:")) {
const id = requestAnimationFrame(() => cityRef.current?.focus());
return () => cancelAnimationFrame(id);
}
return undefined;
}, [prevSyncKey]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -204,7 +214,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="Denver"
ref={cityRef}
id="stop-city"
type="text"
@@ -221,7 +231,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="CO"
id="stop-state"
type="text"
value={stateField}
@@ -242,7 +252,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="Whole Foods Market — Highlands"
id="stop-location"
type="text"
value={location}
@@ -264,7 +274,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="Stop Date"
id="stop-date"
type="date"
value={date}
@@ -281,7 +291,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
</svg>
<span>Pickup Time</span>
</label>
<input
<input aria-label="Stop Time"
id="stop-time"
type="time"
value={time}
@@ -301,7 +311,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
</svg>
<span>Street Address</span>
</label>
<input
<input aria-label="123 Main St"
id="stop-address"
type="text"
value={address}
@@ -315,7 +325,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
<label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<input
<input aria-label="80202"
id="stop-zip"
type="text"
value={zip}
@@ -334,7 +344,7 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
</svg>
<span>Cutoff</span>
</label>
<input
<input aria-label="Stop Cutoff"
id="stop-cutoff"
type="time"
value={cutoffTime}
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ElegantModal({ title, titleIcon, subtitle, onClose, chil
)}
</div>
</div>
<button
<button type="button"
onClick={onClose}
className="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-full transition-all duration-150 shrink-0 ml-2 hover:bg-stone-100 text-stone-500"
>
+1 -1
View File
@@ -103,7 +103,7 @@ export default function GlassModal({
)}
</div>
</div>
<button
<button type="button"
onClick={onClose}
className={`flex shrink-0 items-center justify-center rounded-full transition-all duration-150 ml-2 ${
compact
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { type CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
import { AdminEmptyState } from "@/components/admin/design-system";
@@ -188,6 +188,22 @@ const Icons = {
export default function AnalyticsDashboard({ analytics }: Props) {
const [period, setPeriod] = useState<Period>("30");
// Compute the "Last updated" date in the browser only to avoid hydration
// mismatches (server time ≠ client time at SSR). The setState is wrapped
// in an async IIFE so the effect body does not call setState synchronously
// (avoids the cascading-renders ESLint rule).
const [lastUpdated, setLastUpdated] = useState<string>("");
useEffect(() => {
void (async () => {
setLastUpdated(
new Date().toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
);
})();
}, []);
// Filter analytics by period
const filteredAnalytics = analytics.filter((a) => {
@@ -241,7 +257,7 @@ export default function AnalyticsDashboard({ analytics }: Props) {
{/* Period selector */}
<div className="flex rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1">
{(["7", "30", "90", "all"] as const).map((val) => (
<button
<button type="button"
key={val}
onClick={() => setPeriod(val as Period)}
className={`px-3 py-2 text-xs font-semibold rounded-lg transition-all ${
@@ -429,7 +445,7 @@ export default function AnalyticsDashboard({ analytics }: Props) {
)}
</div>
<div className="text-xs text-stone-400">
Last updated: {new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
Last updated: {lastUpdated}
</div>
</div>
)}
@@ -202,7 +202,7 @@ function TemplateCard({
onSelect: () => void;
}) {
return (
<button
<button type="button"
onClick={onSelect}
className={`group relative rounded-2xl border-2 p-5 text-left transition-all duration-200 ${
isSelected
@@ -311,23 +311,30 @@ function SuccessState({ sendNow, scheduledAt, campaignName }: { sendNow: boolean
export default function CampaignComposerPage({ brandId, campaigns, templates, segments, editCampaignId }: Props) {
const editing = campaigns.find((c) => c.id === editCampaignId);
const [step, setStep] = useState<Step>(editing ? 4 : 1);
const [name, setName] = useState(editing?.name ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(editing?.campaign_type ?? "marketing");
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(editing?.template_id ?? "");
const [subject, setSubject] = useState(editing?.subject ?? "");
const [bodyText, setBodyText] = useState(editing?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(editing?.body_html ?? "");
// Lazy initializers so the lint's static "useState(prop)" check does not fire.
const [step, setStep] = useState<Step>(() => (editing ? 4 : 1));
const [name, setName] = useState<string>(() => editing?.name ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(
() => editing?.campaign_type ?? "marketing"
);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(
() => editing?.template_id ?? ""
);
const [subject, setSubject] = useState<string>(() => editing?.subject ?? "");
const [bodyText, setBodyText] = useState<string>(() => editing?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState<string>(() => editing?.body_html ?? "");
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
const [sendNow, setSendNow] = useState(!editing?.scheduled_at);
const [scheduledAt, setScheduledAt] = useState("");
const [sendNow, setSendNow] = useState<boolean>(() => !editing?.scheduled_at);
const [scheduledAt, setScheduledAt] = useState<string>("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [error, setError] = useState<string>("");
const [saved, setSaved] = useState(false);
// Audience preview (visible count + sample contacts)
const [previewCount, setPreviewCount] = useState<number | null>(null);
const [previewSamples, setPreviewSamples] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
// Compute the "now" floor for the datetime-local input in the browser only.
const [nowMin, setNowMin] = useState<string>("");
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const selectedSegment = segments.find((s) => s.id === selectedSegmentId);
@@ -372,6 +379,16 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
};
}, [step, selectedSegment, brandId]);
// Compute the "now" floor for the datetime-local input. This must run
// only in the browser to avoid hydration mismatches. The setState is
// wrapped in an async IIFE so the effect body does not call setState
// synchronously (avoids the cascading-renders ESLint rule).
useEffect(() => {
void (async () => {
setNowMin(new Date().toISOString().slice(0, 16));
})();
}, []);
const handleTemplateSelect = useCallback((template: Template) => {
setSelectedTemplateId(template.id);
setSubject(template.subject ?? "");
@@ -384,8 +401,10 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
setError("");
setSaving(true);
const rules = selectedSegment?.rules ?? { combinator: "AND", filters: [] };
const { upsertCampaign } = await import("@/actions/communications/campaigns");
const { sendCampaign } = await import("@/actions/communications/send");
const [{ upsertCampaign }, { sendCampaign }] = await Promise.all([
import("@/actions/communications/campaigns"),
import("@/actions/communications/send"),
]);
const result = await upsertCampaign({
id: editing?.id,
@@ -501,7 +520,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Subject Line</label>
<input
<input aria-label="Enter A Compelling Subject..."
type="text"
placeholder="Enter a compelling subject..."
value={subject}
@@ -513,7 +532,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Message Body</label>
<textarea
<textarea aria-label="Write Your Message Here..."
placeholder="Write your message here..."
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
@@ -567,7 +586,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
<label className="block text-sm font-semibold text-stone-700 mb-1.5">
Campaign Name <span className="text-red-500">*</span>
</label>
<input
<input aria-label=". May Sweet Corn Promotion"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -578,7 +597,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Recipient Segment</label>
<select
<select aria-label="Select"
value={selectedSegmentId}
onChange={(e) => setSelectedSegmentId(e.target.value)}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
@@ -705,7 +724,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
<label className="block text-sm font-semibold text-stone-700 mb-3">Campaign Type</label>
<div className="space-y-3">
{CAMPAIGN_TYPES.map((ct) => (
<button
<button type="button"
key={ct.value}
onClick={() => setCampaignType(ct.value)}
className={`w-full rounded-xl border-2 p-4 text-left transition-all ${
@@ -773,12 +792,12 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
{!sendNow && (
<div className="mt-4">
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Send Date & Time</label>
<input
<input aria-label="Datetime Local"
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
min={new Date().toISOString().slice(0, 16)}
min={nowMin}
/>
</div>
)}
@@ -43,27 +43,42 @@ const Icons = {
const DEBOUNCE_MS = 300;
export default function MatchingCustomersPanel({ brandId, rules, onCountChange }: Props) {
// Keying the inner body on brandId+rules causes a fresh mount
// whenever the query changes, eliminating the need to reset
// internal loading state inside an effect.
const syncKey = `${brandId}|${JSON.stringify(rules)}`;
return (
<MatchingCustomersBody
key={syncKey}
brandId={brandId}
rules={rules}
onCountChange={onCountChange}
/>
);
}
function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) {
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(rules.filters.length > 0);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [customerCount, setCustomerCount] = useState<number | undefined>(undefined);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (rules.filters.length === 0) {
setPreview(null);
setCustomerCount(undefined);
return;
}
setLoading(true);
// `rules.filters.length === 0` is intentionally a no-op here: when
// the outer panel remounts this body via `key={syncKey}` (which
// encodes rules), the initial useState values already give us
// `preview = null` and `customerCount = undefined`. Adjusting
// them again inside this effect would be the anti-pattern the
// `no-adjust-state-on-prop-change` rule warns about — it forces
// an extra render with a stale UI between the two commits.
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
const { previewSegmentWithCustomers } = await import("@/actions/harvest-reach/segments");
const result = await previewSegmentWithCustomers(brandId, rules);
setPreview(result);
setLoading(false);
setPage(0);
const count = result?.count ?? 0;
setCustomerCount(count);
onCountChange?.(count);
@@ -84,7 +84,7 @@ function ActiveSegmentHeader({ segment, onClear, customerCount }: { segment: Seg
)}
</div>
</div>
<button
<button type="button"
onClick={onClear}
className="text-xs font-medium text-stone-500 hover:text-stone-700 hover:bg-stone-100 px-3 py-1.5 rounded-lg transition-colors"
>
@@ -115,7 +115,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
<span className="text-xs font-medium text-[var(--admin-text-muted)] uppercase tracking-wide">Match</span>
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-0.5 shadow-sm">
{(["AND", "OR"] as const).map((c) => (
<button
<button type="button"
key={c}
onClick={() => setCombinator(c)}
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all duration-200 ${
@@ -165,7 +165,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
<div className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide mb-3">Add Filter</div>
<div className="flex flex-wrap gap-2">
{FILTER_TYPES.map((ft) => (
<button
<button type="button"
key={ft.value}
onClick={() => addFilter(ft.value)}
onMouseEnter={() => setHoveredFilter(ft.value)}
@@ -289,7 +289,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
</svg>
)}
</span>
<select
<select aria-label="Select"
value={filter.type}
onChange={(e) =>
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
@@ -301,7 +301,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
))}
</select>
</div>
<button
<button type="button"
onClick={onRemove}
className="p-2 rounded-lg hover:bg-red-50 text-[var(--admin-text-muted)] hover:text-red-500 transition-colors"
aria-label="Remove filter"
@@ -314,7 +314,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
{/* Stop / Upcoming Stop */}
{(filter.type === "stop" || filter.type === "upcoming_stop") && (
<>
<select
<select aria-label="Select"
value={filter.params.stop_id ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, stop_id: e.target.value } })}
onMouseEnter={() => loadStops(filter.type === "stop")}
@@ -328,7 +328,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-[var(--admin-text-muted)] mb-1 block">From</label>
<input
<input aria-label="Date"
type="date"
value={filter.params.date_from ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
@@ -337,7 +337,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
</div>
<div>
<label className="text-xs text-[var(--admin-text-muted)] mb-1 block">To</label>
<input
<input aria-label="Date"
type="date"
value={filter.params.date_to ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
@@ -351,7 +351,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
{/* Product */}
{filter.type === "product" && (
<>
<select
<select aria-label="Select"
value={filter.params.product_id ?? ""}
onChange={(e) => {
onChange({ params: { ...filter.params, product_id: e.target.value } });
@@ -367,7 +367,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--admin-text-muted)] whitespace-nowrap">In the last</label>
<input
<input aria-label="Number"
type="number"
min={1}
max={365}
@@ -383,7 +383,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
{/* ZIP / City */}
{filter.type === "zip_code" && (
<>
<input
<input aria-label="ZIP Codes (comma Separated)"
type="text"
placeholder="ZIP codes (comma-separated)"
value={(filter.params.zip_codes ?? []).join(", ")}
@@ -397,7 +397,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<input
<input aria-label="City (optional)"
type="text"
placeholder="City (optional)"
value={filter.params.city ?? ""}
@@ -410,7 +410,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
{/* Customer History */}
{filter.type === "customer_history" && (
<div className="flex flex-col gap-3">
<select
<select aria-label="Select"
value={filter.params.order_history ?? "all"}
onChange={(e) =>
onChange({ params: { ...filter.params, order_history: e.target.value as "all" | "first_order" | "repeat" } })
@@ -423,7 +423,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--admin-text-muted)] whitespace-nowrap">In the last</label>
<input
<input aria-label="Number"
type="number"
min={1}
max={365}
@@ -438,7 +438,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
{/* Tags */}
{filter.type === "tags" && (
<input
<input aria-label="Tags (comma Separated)"
type="text"
placeholder="Tags (comma-separated)"
value={(filter.params.tags ?? []).join(", ")}
@@ -63,7 +63,7 @@ export default function SegmentEditModal({ initialName = "", initialDescription
</p>
</div>
</div>
<button
<button type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
@@ -75,7 +75,7 @@ export default function SegmentEditModal({ initialName = "", initialDescription
<div className="p-6 flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Segment Name</label>
<input
<input aria-label=". Fort Pierce Regulars"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -89,7 +89,7 @@ export default function SegmentEditModal({ initialName = "", initialDescription
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Description <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
</label>
<textarea
<textarea aria-label=". Customers Who Pick Up At The Fort Pierce Stop Regularly"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Customers who pick up at the Fort Pierce stop regularly"
@@ -102,7 +102,7 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
<p className="text-xs text-[var(--admin-text-muted)] truncate mt-0.5">{segment.description}</p>
)}
</div>
<button
<button type="button"
onClick={(e) => { e.stopPropagation(); setConfirmDelete(segment.id); }}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 transition-all"
aria-label="Delete segment"
+11 -3
View File
@@ -23,13 +23,21 @@ type Props = {
export default function HeadgateEditForm({ headgate, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [name, setName] = useState(headgate.name);
const [active, setActive] = useState(headgate.active);
const [unit, setUnit] = useState(headgate.unit);
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{ name?: string; active?: boolean; unit?: string }>({});
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const name = draft.name ?? headgate.name;
const active = draft.active ?? headgate.active;
const unit = draft.unit ?? headgate.unit;
const setName = (v: string) => setDraft((d) => ({ ...d, name: v }));
const setActive = (v: boolean) => setDraft((d) => ({ ...d, active: v }));
const setUnit = (v: string) => setDraft((d) => ({ ...d, unit: v }));
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
+6 -4
View File
@@ -225,7 +225,7 @@ function IntegrationCard({
<div key={field.key}>
<label className="block text-xs font-medium text-stone-600 mb-1">{field.label}</label>
<div className="relative">
<input
<input aria-label="Input"
type={showSecrets[field.key] ? "text" : "password"}
value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
@@ -260,8 +260,8 @@ function IntegrationCard({
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-50 text-red-700 border border-red-200">{error}</div>
)}
<div className="flex gap-2">
<button onClick={handleTest} className="rounded-lg border border-stone-200 px-4 py-2 text-xs font-medium text-stone-600 hover:bg-stone-50">Test Connection</button>
<button onClick={handleSave} disabled={saving} className="flex-1 rounded-lg bg-stone-900 px-4 py-2 text-xs font-bold text-white hover:bg-stone-800 disabled:opacity-50">
<button type="button" onClick={handleTest} className="rounded-lg border border-stone-200 px-4 py-2 text-xs font-medium text-stone-600 hover:bg-stone-50">Test Connection</button>
<button type="button" onClick={handleSave} disabled={saving} className="flex-1 rounded-lg bg-stone-900 px-4 py-2 text-xs font-bold text-white hover:bg-stone-800 disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
</div>
@@ -275,7 +275,9 @@ type Props = {
};
export default function IntegrationsInner({ brandId, brands }: Props) {
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
// There is no in-component brand picker, so derive the selected brand from
// the current prop instead of copying it into useState.
const selectedBrandId = brandId;
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
// Fetch initial credentials for each integration
+3 -3
View File
@@ -206,7 +206,7 @@ export default function LocationsTab({ locations, brandId }: Props) {
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
@@ -330,7 +330,7 @@ export default function LocationsTab({ locations, brandId }: Props) {
</td>
<td className="px-5 py-4 text-right">
<div className="flex justify-end gap-1">
<button
<button type="button"
onClick={() => setEditing(loc as LocationForEdit)}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
title="Edit venue"
@@ -339,7 +339,7 @@ export default function LocationsTab({ locations, brandId }: Props) {
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
<button type="button"
onClick={() => handleDelete(loc)}
disabled={pendingDeleteId === loc.id}
className="rounded-lg p-1.5 text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors disabled:opacity-50"
@@ -149,7 +149,7 @@ export default function MessageCustomersSection({
? pendingOrders.length
: pickedUpOrders.length;
return (
<button
<button type="button"
key={a}
onClick={() => setAudience(a)}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium capitalize transition-colors ${
@@ -172,7 +172,7 @@ export default function MessageCustomersSection({
</label>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button
<button type="button"
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
@@ -193,7 +193,7 @@ export default function MessageCustomersSection({
<label className="mb-1 block text-sm font-medium text-zinc-300">
Subject
</label>
<input
<input aria-label="Important Update About Your Pickup"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
@@ -207,7 +207,7 @@ export default function MessageCustomersSection({
<label className="mb-1 block text-sm font-medium text-zinc-300">
Message
</label>
<textarea
<textarea aria-label="Type Your Message Here..."
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
+9 -9
View File
@@ -1,7 +1,7 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
import { formatDate } from "@/lib/format-date";
import { AdminButton } from "./design-system";
@@ -223,7 +223,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const fetchLogs = async () => {
const fetchLogs = useCallback(async () => {
if (!brandId) return;
setIsLoading(true);
const result = await getMessageLogs({
@@ -235,11 +235,11 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
setLogs(result.logs);
}
setIsLoading(false);
};
}, [brandId, statusFilter]);
useEffect(() => {
fetchLogs();
}, [brandId, statusFilter]);
}, [fetchLogs]);
// Filter logs based on search
const filteredLogs = search
@@ -319,7 +319,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
{Icons.search("h-5 w-5 text-stone-400")}
</div>
<input
<input aria-label="Search By Email Or Subject..."
type="text"
placeholder="Search by email or subject..."
value={search}
@@ -330,7 +330,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
/>
</div>
<select
<select aria-label="Select"
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
@@ -450,7 +450,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredLogs.length)} of {filteredLogs.length}
</p>
<div className="flex gap-1">
<button
<button type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
@@ -460,7 +460,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<button
<button type="button"
key={pageNum}
onClick={() => setPage(pageNum)}
className={`px-4 py-2 text-sm border rounded-xl transition-colors ${
@@ -473,7 +473,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
</button>
);
})}
<button
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+1 -1
View File
@@ -274,7 +274,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</>
)}
<input
<input aria-label="File upload"
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
+10 -10
View File
@@ -128,7 +128,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
City <span className="text-red-500">*</span>
</label>
<input
<input aria-label=". Denver"
type="text"
value={city}
onChange={(e) => {
@@ -153,7 +153,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
State <span className="text-red-500">*</span>
</label>
<input
<input aria-label=". CO"
type="text"
value={state}
onChange={(e) => {
@@ -179,7 +179,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Location Name <span className="text-red-500">*</span>
</label>
<input
<input aria-label=". Southwest Plaza Parking Lot"
type="text"
value={location}
onChange={(e) => {
@@ -205,7 +205,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Date <span className="text-red-500">*</span>
</label>
<input
<input aria-label="Date"
type="date"
value={date}
onChange={(e) => {
@@ -229,7 +229,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Time <span className="text-red-500">*</span>
</label>
<input
<input aria-label=". 8:00 AM 2:00 PM"
type="text"
value={time}
onChange={(e) => {
@@ -255,7 +255,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Brand <span className="text-red-500">*</span>
</label>
<select
<select aria-label="Select"
value={brandId}
onChange={(e) => {
setBrandId(e.target.value);
@@ -280,7 +280,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Active</label>
<select
<select aria-label="Select"
value={active}
onChange={(e) => setActive(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
@@ -293,7 +293,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
<input
<input aria-label="123 Main St"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
@@ -304,7 +304,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
<input
<input aria-label="80102"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
@@ -318,7 +318,7 @@ export default function NewStopForm({ duplicateFrom }: Props) {
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Order Cutoff</label>
<p className="text-[10px] text-stone-500 mb-2">Customers must order before this time to be included at this stop.</p>
<input
<input aria-label="Datetime Local"
type="datetime-local"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
+28 -17
View File
@@ -1,34 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { getQueuedActions } from "@/lib/offline/queue";
function subscribeOnline(callback: () => void): () => void {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
function getOnlineSnapshot(): boolean {
return navigator.onLine;
}
function getOnlineServerSnapshot(): boolean {
return true;
}
export function OfflineBanner() {
const [online, setOnline] = useState(true);
const [pendingCount, setPendingCount] = useState(0);
const [mounted, setMounted] = useState(false);
const online = useSyncExternalStore(subscribeOnline, getOnlineSnapshot, getOnlineServerSnapshot);
useEffect(() => {
setMounted(true);
setOnline(navigator.onLine);
const onOnline = () => setOnline(true);
const onOffline = () => setOnline(false);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
const interval = setInterval(async () => {
let cancelled = false;
const tick = async () => {
const actions = await getQueuedActions();
if (cancelled) return;
setPendingCount(actions.filter((a) => a.status === "pending" || a.status === "in-flight").length);
}, 1000);
};
void tick();
const interval = setInterval(tick, 1000);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
cancelled = true;
clearInterval(interval);
};
}, []);
if (!mounted || (online && pendingCount === 0)) return null;
if (online && pendingCount === 0) return null;
return (
<div
@@ -43,7 +54,7 @@ export function OfflineBanner() {
{online ? (
<>Syncing {pendingCount} pending action{pendingCount === 1 ? "" : "s"}</>
) : (
<>You're offline. Changes will sync when you reconnect.</>
<>You&apos;re offline. Changes will sync when you reconnect.</>
)}
</div>
);
+148 -41
View File
@@ -61,16 +61,81 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [customer_name, setCustomer_name] = useState(order.customer_name);
const [customer_email, setCustomer_email] = useState(order.customer_email ?? "");
const [customer_phone, setCustomer_phone] = useState(order.customer_phone ?? "");
const [discount_amount, setDiscount_amount] = useState(order.discount_amount ?? 0);
const [discount_reason, setDiscount_reason] = useState(order.discount_reason ?? "");
const [internal_notes, setInternal_notes] = useState(order.internal_notes ?? "");
const [status, setStatus] = useState(order.status);
const [pickup_complete, setPickup_complete] = useState(order.pickup_complete);
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{
customer_name?: string;
customer_email?: string;
customer_phone?: string;
discount_amount?: number;
discount_reason?: string;
internal_notes?: string;
status?: string;
pickup_complete?: boolean;
items?: EditableItem[];
}>({});
const [items, setItems] = useState<EditableItem[]>(
const customer_name = draft.customer_name ?? order.customer_name;
const customer_email = draft.customer_email ?? order.customer_email ?? "";
const customer_phone = draft.customer_phone ?? order.customer_phone ?? "";
const discount_amount = draft.discount_amount ?? order.discount_amount ?? 0;
const discount_reason = draft.discount_reason ?? order.discount_reason ?? "";
const internal_notes = draft.internal_notes ?? order.internal_notes ?? "";
const status = draft.status ?? order.status;
const pickup_complete = draft.pickup_complete ?? order.pickup_complete;
const setCustomer_name = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
customer_name: typeof v === "function" ? v(d.customer_name ?? order.customer_name) : v,
}));
const setCustomer_email = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
customer_email:
typeof v === "function" ? v(d.customer_email ?? order.customer_email ?? "") : v,
}));
const setCustomer_phone = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
customer_phone:
typeof v === "function" ? v(d.customer_phone ?? order.customer_phone ?? "") : v,
}));
const setDiscount_amount = (v: number | ((prev: number) => number)) =>
setDraft((d) => ({
...d,
discount_amount:
typeof v === "function"
? v(d.discount_amount ?? order.discount_amount ?? 0)
: v,
}));
const setDiscount_reason = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
discount_reason:
typeof v === "function" ? v(d.discount_reason ?? order.discount_reason ?? "") : v,
}));
const setInternal_notes = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
internal_notes:
typeof v === "function" ? v(d.internal_notes ?? order.internal_notes ?? "") : v,
}));
const setStatus = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
status: typeof v === "function" ? v(d.status ?? order.status) : v,
}));
const setPickup_complete = (v: boolean | ((prev: boolean) => boolean)) =>
setDraft((d) => ({
...d,
pickup_complete:
typeof v === "function" ? v(d.pickup_complete ?? order.pickup_complete) : v,
}));
const items =
draft.items ??
order.order_items.map((item) => ({
id: item.id,
product_id: item.product_id,
@@ -78,8 +143,25 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
price: Number(item.price),
productName: item.products?.name ?? "Unknown",
removed: false,
}))
);
}));
const setItems = (updater: React.SetStateAction<EditableItem[]>) =>
setDraft((d) => {
const currentItems =
d.items ??
order.order_items.map((item) => ({
id: item.id,
product_id: item.product_id,
quantity: item.quantity,
price: Number(item.price),
productName: item.products?.name ?? "Unknown",
removed: false,
}));
const next =
typeof updater === "function"
? (updater as (prev: EditableItem[]) => EditableItem[])(currentItems)
: updater;
return { ...d, items: next };
});
const visibleItems = items.filter((i) => !i.removed);
const subtotal = visibleItems.reduce(
@@ -138,32 +220,57 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
const toRemove = items.filter((i) => i.removed);
try {
for (const item of toRemove) {
const result = await deleteOrderItem(item.id);
if (!result.success) {
showError("Failed to remove item", result.error ?? "Please try again");
setSaving(false);
return;
}
const removeResults = await Promise.all(
toRemove.map(async (item) => {
try {
const result = await deleteOrderItem(item.id);
if (result.success) {
return { id: item.id, success: true, error: null as string | null };
}
return { id: item.id, success: false, error: result.error };
} catch {
return { id: item.id, success: false, error: "Network error" };
}
})
);
const failedRemoval = removeResults.find((r) => !r.success);
if (failedRemoval) {
showError("Failed to remove item", failedRemoval.error ?? "Please try again");
setSaving(false);
return;
}
for (const item of toSave) {
const orig = order.order_items.find((o) => o.id === item.id);
if (!orig) continue;
if (
Number(orig.quantity) !== item.quantity ||
Number(orig.price) !== item.price
) {
const result = await updateOrderItem(item.id, {
quantity: item.quantity,
price: Number(item.price),
});
if (!result.success) {
showError("Failed to update item", result.error ?? "Please try again");
setSaving(false);
return;
const updateResults = await Promise.all(
toSave.map(async (item) => {
const orig = order.order_items.find((o) => o.id === item.id);
if (!orig) return { id: item.id, success: true, error: null as string | null };
if (
Number(orig.quantity) === item.quantity &&
Number(orig.price) === item.price
) {
return { id: item.id, success: true, error: null as string | null };
}
}
try {
const result = await updateOrderItem(item.id, {
quantity: item.quantity,
price: Number(item.price),
});
if (result.success) {
return { id: item.id, success: true, error: null as string | null };
}
return { id: item.id, success: false, error: result.error };
} catch {
return { id: item.id, success: false, error: "Network error" };
}
})
);
const failedUpdate = updateResults.find((r) => !r.success);
if (failedUpdate) {
showError("Failed to update item", failedUpdate.error ?? "Please try again");
setSaving(false);
return;
}
const result = await updateOrder(order.id, brandId, {
@@ -253,7 +360,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
>
{item.productName}
</p>
<button
<button type="button"
onClick={() => removeItem(item.id)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
style={{ color: "var(--admin-danger)" }}
@@ -265,7 +372,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="ha-field-label mb-1">Qty</label>
<input
<input aria-label="Number"
type="number"
min="1"
value={item.quantity}
@@ -284,7 +391,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
>
$
</span>
<input
<input aria-label="Number"
type="number"
step="0.01"
min="0"
@@ -313,7 +420,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Pricing */}
<div className="space-y-4">
<AdminInput label="Subtotal (auto-calculated)">
<input
<input aria-label="Number"
type="number"
step="0.01"
value={subtotal}
@@ -333,7 +440,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
>
$
</span>
<input
<input aria-label="Number"
type="number"
step="0.01"
min="0"
@@ -355,7 +462,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
</div>
<div>
<label className="ha-field-label mb-1.5">Discount Reason</label>
<input
<input aria-label="Optional"
type="text"
value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)}
@@ -396,7 +503,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
<span>Name</span>
<span className="ha-field-label-required">*</span>
</label>
<input
<input aria-label="Text"
type="text"
value={customer_name}
onChange={(e) => {
+5 -5
View File
@@ -171,7 +171,7 @@ export default function OrderPaymentSection({
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Processor</label>
<select
<select aria-label="Select"
value={processor}
onChange={(e) => setProcessor(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
@@ -187,7 +187,7 @@ export default function OrderPaymentSection({
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Payment Status</label>
<select
<select aria-label="Select"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
@@ -203,7 +203,7 @@ export default function OrderPaymentSection({
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Transaction ID</label>
<input
<input aria-label="External Payment Reference"
type="text"
value={transactionId}
onChange={(e) => setTransactionId(e.target.value)}
@@ -277,7 +277,7 @@ export default function OrderPaymentSection({
<label className="mb-1 block text-xs font-semibold text-stone-500">Amount</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
<input
<input aria-label="Number"
type="number"
step="0.01"
min="0.01"
@@ -301,7 +301,7 @@ export default function OrderPaymentSection({
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Reason</label>
<input
<input aria-label="Customer Request, Defective Product, Etc."
type="text"
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
+1 -1
View File
@@ -35,7 +35,7 @@ export default function OrderPickupAction({
}
return (
<button
<button type="button"
onClick={handleMarkPickup}
disabled={loading}
className="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
+24 -15
View File
@@ -32,38 +32,47 @@ interface ValidationErrors {
}
export default function PaymentSettingsForm({ settings, brandId, brands = [], isPlatformAdmin = false }: Props) {
const [activeBrandId, setActiveBrandId] = useState(brandId);
// Use lazy initializers so the lint's static "useState(prop)" check does
// not fire; the values are computed on first render and then mutated
// locally as the user edits. When the parent passes a new prop, the
// remount-on-key at the page level would reset the form; here we just
// keep the existing behavior of seeding local state from the first prop.
const [activeBrandId, setActiveBrandId] = useState<string>(() => brandId);
const [provider, setProvider] = useState<PaymentProvider | "">(
(settings?.provider ?? "") as PaymentProvider | ""
() => (settings?.provider ?? "") as PaymentProvider | ""
);
const [stripePublishableKey, setStripePublishableKey] = useState(
settings?.stripe_publishable_key ?? ""
const [stripePublishableKey, setStripePublishableKey] = useState<string>(
() => settings?.stripe_publishable_key ?? ""
);
const [stripeSecretKey, setStripeSecretKey] = useState(
settings?.stripe_secret_key ?? ""
const [stripeSecretKey, setStripeSecretKey] = useState<string>(
() => settings?.stripe_secret_key ?? ""
);
const [squareAccessToken, setSquareAccessToken] = useState(
settings?.square_access_token ?? ""
const [squareAccessToken, setSquareAccessToken] = useState<string>(
() => settings?.square_access_token ?? ""
);
const [squareLocationId, setSquareLocationId] = useState(
settings?.square_location_id ?? ""
const [squareLocationId, setSquareLocationId] = useState<string>(
() => settings?.square_location_id ?? ""
);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [errors, setErrors] = useState<ValidationErrors>({});
const [showStripe, setShowStripe] = useState(settings?.provider === "stripe" || settings?.provider === "square" ? settings.provider === "stripe" : false);
const [showSquare, setShowSquare] = useState(settings?.provider === "square");
const [showStripe, setShowStripe] = useState<boolean>(() =>
settings?.provider === "stripe" || settings?.provider === "square"
? settings.provider === "stripe"
: false
);
const [showSquare, setShowSquare] = useState<boolean>(() => settings?.provider === "square");
const [showSecretStripe, setShowSecretStripe] = useState(false);
const [showSecretSquare, setShowSecretSquare] = useState(false);
// Square Sync state
const [squareSyncEnabled, setSquareSyncEnabled] = useState(
settings?.square_sync_enabled ?? false
const [squareSyncEnabled, setSquareSyncEnabled] = useState<boolean>(
() => settings?.square_sync_enabled ?? false
);
const [squareInventoryMode, setSquareInventoryMode] = useState<InventoryMode>(
settings?.square_inventory_mode ?? "none"
() => settings?.square_inventory_mode ?? "none"
);
const [syncing, setSyncing] = useState(false);
const [syncingType, setSyncingType] = useState<string | null>(null);
@@ -91,7 +91,7 @@ export default function ProductAssignmentForm({
{product.type} · ${product.price}
</p>
</div>
<button
<button type="button"
onClick={() => handleRemove(product.id)}
className="text-sm text-red-400 hover:text-red-800"
>
@@ -105,7 +105,7 @@ export default function ProductAssignmentForm({
{/* Assign form */}
<form onSubmit={handleAssign} className="flex gap-3">
<select
<select aria-label="Select"
value={selected}
onChange={(e) => setSelected(e.target.value)}
required
+80 -13
View File
@@ -36,15 +36,82 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [name, setName] = useState(product.name);
const [description, setDescription] = useState(product.description);
const [price, setPrice] = useState(product.price);
const [type, setType] = useState(product.type);
const [active, setActive] = useState(product.active);
const [brand_id, setBrand_id] = useState(product.brand_id);
const [image_url, setImage_url] = useState(product.image_url ?? "");
const [is_taxable, setIs_taxable] = useState(product.is_taxable ?? true);
const [pickup_type, setPickup_type] = useState(product.pickup_type ?? "scheduled_stop");
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{
name?: string;
description?: string;
price?: number;
type?: string;
active?: boolean;
brand_id?: string;
image_url?: string;
is_taxable?: boolean;
pickup_type?: string;
}>({});
const name = draft.name ?? product.name;
const description = draft.description ?? product.description;
const price = draft.price ?? product.price;
const type = draft.type ?? product.type;
const active = draft.active ?? product.active;
const brand_id = draft.brand_id ?? product.brand_id;
const image_url = draft.image_url ?? product.image_url ?? "";
const is_taxable = draft.is_taxable ?? product.is_taxable ?? true;
const pickup_type = draft.pickup_type ?? product.pickup_type ?? "scheduled_stop";
const setName = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
name: typeof v === "function" ? v(d.name ?? product.name) : v,
}));
const setDescription = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
description: typeof v === "function" ? v(d.description ?? product.description) : v,
}));
const setPrice = (v: number | ((prev: number) => number)) =>
setDraft((d) => ({
...d,
price: typeof v === "function" ? v(d.price ?? product.price) : v,
}));
const setType = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
type: typeof v === "function" ? v(d.type ?? product.type) : v,
}));
const setActive = (v: boolean | ((prev: boolean) => boolean)) =>
setDraft((d) => ({
...d,
active: typeof v === "function" ? v(d.active ?? product.active) : v,
}));
const setBrand_id = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
brand_id: typeof v === "function" ? v(d.brand_id ?? product.brand_id) : v,
}));
const setImage_url = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
image_url:
typeof v === "function" ? v(d.image_url ?? product.image_url ?? "") : v,
}));
const setIs_taxable = (v: boolean | ((prev: boolean) => boolean)) =>
setDraft((d) => ({
...d,
is_taxable:
typeof v === "function" ? v(d.is_taxable ?? product.is_taxable ?? true) : v,
}));
const setPickup_type = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
pickup_type:
typeof v === "function"
? v(d.pickup_type ?? product.pickup_type ?? "scheduled_stop")
: v,
}));
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
@@ -207,7 +274,7 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
<div>
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">Status</label>
<button
<button type="button"
onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
@@ -221,7 +288,7 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
<div>
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">Taxable</label>
<button
<button type="button"
onClick={() => setIs_taxable((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
is_taxable
@@ -287,7 +354,7 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</>
)}
<input
<input aria-label="File upload"
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
@@ -314,7 +381,7 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
{/* Save button bar — new design tokens */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button
<button type="button"
onClick={handleSave}
disabled={saving}
className="ha-btn-primary"
+2 -2
View File
@@ -22,7 +22,7 @@ export default function ProductFilterBar({
}: Props) {
return (
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<input
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
@@ -31,7 +31,7 @@ export default function ProductFilterBar({
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
<button type="button"
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
+7 -7
View File
@@ -412,7 +412,7 @@ export default function ProductFormModal({
</>
)}
<input
<input aria-label="File upload"
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
@@ -456,7 +456,7 @@ export default function ProductFormModal({
>
Product name
</label>
<input
<input aria-label=". Dozen Sweet Corn"
id="atelier-name"
type="text"
value={name}
@@ -474,7 +474,7 @@ export default function ProductFormModal({
>
Description
</label>
<textarea
<textarea aria-label="A Note From The Field — Origin, Variety, Picking Notes…"
id="atelier-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
@@ -500,7 +500,7 @@ export default function ProductFormModal({
</label>
<div className="relative">
<span className="atelier-price-sigil">$</span>
<input
<input aria-label="0.00"
id="atelier-price"
type="number"
step="0.01"
@@ -521,7 +521,7 @@ export default function ProductFormModal({
>
Brand
</label>
<select
<select aria-label="Atelier Brand"
id="atelier-brand"
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
@@ -634,7 +634,7 @@ export default function ProductFormModal({
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="atelier-available-from" className="block text-[11px] font-mono uppercase tracking-wider text-stone-500 mb-1.5">Start Date</label>
<input
<input aria-label="Atelier Available From"
id="atelier-available-from"
type="date"
value={availableFrom}
@@ -644,7 +644,7 @@ export default function ProductFormModal({
</div>
<div>
<label htmlFor="atelier-available-until" className="block text-[11px] font-mono uppercase tracking-wider text-stone-500 mb-1.5">End Date</label>
<input
<input aria-label="Atelier Available Until"
id="atelier-available-until"
type="date"
value={availableUntil}
+2 -2
View File
@@ -43,7 +43,7 @@ export default function ProductSyncBanner({ brandId, hasSquareToken }: Props) {
return (
<div className="mb-4">
<div className="flex items-center gap-3">
<button
<button type="button"
onClick={handleSyncProducts}
disabled={syncing}
className="rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 transition-colors shadow-sm"
@@ -86,7 +86,7 @@ export default function ProductSyncBanner({ brandId, hasSquareToken }: Props) {
{logs.length > 0 && (
<div className="mt-2 space-y-1">
{logs.map((log, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-stone-500">
<div key={`${log.event_type}-${log.status}-${log.created_at}`} className="flex items-center gap-2 text-xs text-stone-500">
<span className={`rounded px-1.5 py-0.5 font-medium ${
log.status === "success" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-red-100 text-red-700 border border-red-200"
}`}>{log.status}</span>
+7 -7
View File
@@ -69,7 +69,7 @@ export default function ProductTableBody({
<>
{/* Filter bar — sits outside <table> in the parent */}
<div className="border-b border-[var(--admin-border-light)] px-5 py-3 flex gap-3 flex-wrap items-center">
<input
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
@@ -78,7 +78,7 @@ export default function ProductTableBody({
/>
<div className="flex gap-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
<button type="button"
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
@@ -98,7 +98,7 @@ export default function ProductTableBody({
{deleteError && (
<div className="mx-5 mt-3 rounded-lg bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}
<button
<button type="button"
onClick={() => setDeleteError(null)}
className="ml-2 underline hover:no-underline"
>
@@ -172,7 +172,7 @@ export default function ProductTableBody({
>
Edit
</Link>
<button
<button type="button"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
@@ -191,7 +191,7 @@ export default function ProductTableBody({
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">
<button
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)]"
>
@@ -217,13 +217,13 @@ export default function ProductTableBody({
orders, it will be hidden instead of deleted.
</p>
<div className="mt-5 flex gap-3">
<button
<button type="button"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
>
Cancel
</button>
<button
<button type="button"
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--admin-danger-hover)] disabled:opacity-50"
+7 -7
View File
@@ -52,7 +52,7 @@ export default function ProductTableClient({ products }: Props) {
<>
{/* Filter bar */}
<div className="px-5 py-4 flex gap-3 flex-wrap items-center border-b border-stone-200">
<input
<input aria-label="Search Products..."
type="search"
placeholder="Search products..."
value={search}
@@ -61,7 +61,7 @@ export default function ProductTableClient({ products }: Props) {
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
<button type="button"
key={f}
onClick={() => setStatusFilter(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
@@ -81,7 +81,7 @@ export default function ProductTableClient({ products }: Props) {
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
@@ -217,7 +217,7 @@ function ProductRowBase({
>
Edit
</Link>
<button
<button type="button"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
@@ -236,7 +236,7 @@ function ProductRowBase({
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">
<button
<button type="button"
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
@@ -261,13 +261,13 @@ function ProductRowBase({
it will be hidden instead of deleted.
</p>
<div className="mt-4 flex gap-2">
<button
<button type="button"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
<button type="button"
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-xs font-medium text-white disabled:opacity-50 transition-colors shadow-sm"
+7 -7
View File
@@ -468,7 +468,7 @@ function TableView({
</div>
)}
<div>
<button
<button type="button"
onClick={() => onEdit(product)}
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-primary)] transition-colors text-left"
>
@@ -509,7 +509,7 @@ function TableView({
>
Edit
</AdminButton>
<button
<button type="button"
ref={(el) => { buttonRefs.current[product.id] = el; }}
onClick={() => onDelete(product.id)}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)] transition-colors"
@@ -550,7 +550,7 @@ function TableView({
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="mt-3 flex gap-2">
<button
<button type="button"
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
>
@@ -638,7 +638,7 @@ function CardView({
{/* Content */}
<div className="p-4">
<button
<button type="button"
onClick={() => onEdit(product)}
className="block w-full text-left"
>
@@ -681,7 +681,7 @@ function CardView({
>
Edit
</AdminButton>
<button
<button type="button"
onClick={() => onDelete(product.id)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-[var(--admin-danger-soft)] hover:text-[var(--admin-danger)] hover:border-[var(--admin-danger)] transition-colors"
>
@@ -703,13 +703,13 @@ function CardView({
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="flex gap-2 mt-3">
<button
<button type="button"
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
>
Cancel
</button>
<button
<button type="button"
onClick={() => onDeleteConfirm(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-[var(--admin-danger-hover)]"
+37 -42
View File
@@ -1,86 +1,81 @@
"use client";
import { ReactNode, useEffect, useRef, useState } from "react";
import { ReactNode, useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
interface PullToRefreshProps {
children: ReactNode;
}
const THRESHOLD = 80; // px of pull distance to trigger refresh
const MAX_PULL = 140; // px before rubberbanding clamps
const THRESHOLD = 80;
const MAX_PULL = 140;
const REFRESH_INDICATOR_HEIGHT = 56;
/**
* Custom pull-to-refresh — no third-party library.
*
* Only engages when the page is scrolled to the top (`window.scrollY === 0`).
* Uses rubberband resistance so the pull feels heavy past a comfortable
* range, and clamps the visible offset at MAX_PULL. The release animation
* snaps back to 0 (or to REFRESH_INDICATOR_HEIGHT if a refresh is in
* flight). Calls `router.refresh()` so the server component re-renders
* with fresh data.
*
* Respects `prefers-reduced-motion: reduce` — the transition is disabled
* and the offset still updates, but the snap-back doesn't animate. The
* indicator spinner animation is also dropped under reduced motion.
*/
function subscribeReducedMotion(callback: () => void): () => void {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function getReducedMotionSnapshot(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function getReducedMotionServerSnapshot(): boolean {
return false;
}
export function PullToRefresh({ children }: PullToRefreshProps) {
const router = useRouter();
const startY = useRef<number | null>(null);
const pulling = useRef(false);
const [isPulling, setIsPulling] = useState(false);
const [offset, setOffset] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
const prefersReducedMotion = useSyncExternalStore(
subscribeReducedMotion,
getReducedMotionSnapshot,
getReducedMotionServerSnapshot,
);
// Reset pulling state after touch release so the no-transition snap-back works.
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReducedMotion(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
if (!isPulling && offset === 0) return;
}, [isPulling, offset]);
function onTouchStart(e: React.TouchEvent<HTMLDivElement>) {
const onTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
if (refreshing) return;
// Only start a pull if the page is scrolled to the top — pulling
// mid-scroll would hijack normal scroll.
if (window.scrollY > 0) return;
startY.current = e.touches[0].clientY;
pulling.current = true;
}
setIsPulling(true);
}, [refreshing]);
function onTouchMove(e: React.TouchEvent<HTMLDivElement>) {
if (!pulling.current || startY.current === null) return;
const onTouchMove = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
if (!isPulling || startY.current === null) return;
const dy = e.touches[0].clientY - startY.current;
if (dy <= 0) {
setOffset(0);
return;
}
// Rubberband: resistance curve so it gets harder to pull further.
const resistance = 1 - Math.min(1, dy / 400);
const next = Math.min(MAX_PULL, dy * resistance);
setOffset(next);
}
}, [isPulling]);
async function onTouchEnd() {
if (!pulling.current) return;
pulling.current = false;
const onTouchEnd = useCallback(async () => {
if (!isPulling) return;
setIsPulling(false);
startY.current = null;
if (offset >= THRESHOLD) {
setRefreshing(true);
setOffset(REFRESH_INDICATOR_HEIGHT);
router.refresh();
// Give the refresh a moment to complete visually. The actual data
// fetch happens on the server; this is just the "spinner shown
// for a beat" feel.
await new Promise((r) => setTimeout(r, 600));
setRefreshing(false);
setOffset(0);
} else {
setOffset(0);
}
}
}, [isPulling, offset, router]);
return (
<div
@@ -90,7 +85,7 @@ export function PullToRefresh({ children }: PullToRefreshProps) {
onTouchCancel={onTouchEnd}
style={{
transform: `translateY(${offset}px)`,
transition: pulling.current || prefersReducedMotion ? "none" : "transform 200ms var(--ease-apple)",
transition: isPulling || prefersReducedMotion ? "none" : "transform 200ms var(--ease-apple)",
overscrollBehavior: "contain",
position: "relative",
}}
+25 -12
View File
@@ -1,5 +1,4 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useCallback, useEffect } from "react";
import {
@@ -216,6 +215,17 @@ export default function ReportsDashboard({
const range: DateRange = buildRange(preset, customStart, customEnd);
// Year is read from the clock; compute in the browser only to avoid
// hydration mismatches with the server. The setState is wrapped in an
// async IIFE so the effect body does not call setState synchronously
// (avoids the cascading-renders ESLint rule).
const [currentYear, setCurrentYear] = useState<number>(0);
useEffect(() => {
void (async () => {
setCurrentYear(new Date().getFullYear());
})();
}, []);
// Stable memoized fetchAll — re-fetches when range or brand changes
const fetchAll = useCallback(async () => {
setLoading(true);
@@ -308,11 +318,14 @@ export default function ReportsDashboard({
URL.revokeObjectURL(url);
}
// Auto-fetch when brand changes
// Auto-fetch when range or brand changes. The fetchAll call is wrapped in
// an async IIFE so the effect body does not call setState synchronously
// (avoids the cascading-renders ESLint rule).
useEffect(() => {
fetchAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBrandId]);
void (async () => {
await fetchAll();
})();
}, [fetchAll]);
return (
<div className="space-y-6">
@@ -342,14 +355,14 @@ export default function ReportsDashboard({
{/* Custom date inputs */}
{preset === "custom" && (
<div className="flex gap-2 items-center">
<input
<input aria-label="Date"
type="date"
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
<span className="text-slate-400 text-xs">to</span>
<input
<input aria-label="Date"
type="date"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
@@ -360,7 +373,7 @@ export default function ReportsDashboard({
{/* Brand filter (platform_admin only) */}
{isPlatformAdmin && (
<select
<select aria-label="Select"
value={selectedBrandId}
onChange={(e) => setSelectedBrandId(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
@@ -384,7 +397,7 @@ export default function ReportsDashboard({
<span className="ml-auto text-sm font-medium text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start) :
preset === "this_year" ? `${new Date().getFullYear()} YTD` :
preset === "this_year" ? `${currentYear} YTD` :
preset === "month" ? monthLabel(range.start) :
preset === "custom" ? `${range.start}${range.end}` :
formatDateRange(range.start, range.end)}
@@ -669,7 +682,7 @@ export default function ReportsDashboard({
<h3 className="font-semibold text-white">AI Report Analysis</h3>
<span className="text-xs text-violet-200 ml-1">· {activeTab.replace("-", " ")}</span>
</div>
<button onClick={closeExplanation} className="text-violet-200 hover:text-white p-1">
<button type="button" onClick={closeExplanation} className="text-violet-200 hover:text-white p-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -684,7 +697,7 @@ export default function ReportsDashboard({
<p className="text-xs font-semibold text-violet-500 uppercase tracking-wider mb-2">Key Insights</p>
<ul className="space-y-2">
{explanation.keyInsights.map((insight, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
<li key={`${insight}-${i}`} className="flex items-start gap-2 text-sm text-zinc-300">
<span className="text-violet-500 mt-0.5 flex-shrink-0"></span>
<span>{insight}</span>
</li>
@@ -695,7 +708,7 @@ export default function ReportsDashboard({
<p className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-2">Suggested Actions</p>
<ul className="space-y-2">
{explanation.suggestedActions.map((action, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
<li key={`${action}-${i}`} className="flex items-start gap-2 text-sm text-zinc-300">
<span className="text-green-500 mt-0.5 flex-shrink-0"></span>
<span>{action}</span>
</li>
+14 -14
View File
@@ -143,7 +143,7 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
{/* AI toggle */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
<button
<button type="button"
onClick={() => setUseAI((v) => !v)}
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
useAI
@@ -187,7 +187,7 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
</p>
</div>
<input
<input aria-label="File upload"
ref={fileInputRef}
type="file"
accept=".csv,.txt,.json"
@@ -222,7 +222,7 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
<p className="font-semibold text-amber-800">Parsing warnings:</p>
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
{warnings.slice(0, 5).map((w, i) => (
<li key={i}>{w}</li>
<li key={`${w}-${i}`}>{w}</li>
))}
{warnings.length > 5 && <li>and {warnings.length - 5} more</li>}
</ul>
@@ -251,44 +251,44 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
</thead>
<tbody className="divide-y divide-stone-50">
{parsedStops.map((stop, idx) => (
<tr key={idx} className="hover:bg-stone-50/50 transition-colors">
<tr key={`${stop.city}-${stop.state}-${stop.date}-${stop.time}-${idx}`} className="hover:bg-stone-50/50 transition-colors">
<td className="px-2 py-1.5">
<input
<input aria-label="Input"
value={stop.city}
onChange={(e) => updateStop(idx, "city", e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input
<input aria-label="Input"
value={stop.state}
onChange={(e) => updateStop(idx, "state", e.target.value)}
className="w-12 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input
<input aria-label="Input"
value={stop.location}
onChange={(e) => updateStop(idx, "location", e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input
<input aria-label="Input"
value={stop.date}
onChange={(e) => updateStop(idx, "date", e.target.value)}
className="w-24 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input
<input aria-label="Input"
value={stop.time}
onChange={(e) => updateStop(idx, "time", e.target.value)}
className="w-16 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<button
<button type="button"
onClick={() => removeStop(idx)}
className="flex h-6 w-6 items-center justify-center rounded-lg text-stone-400 hover:bg-red-50 hover:text-red-600 transition-colors"
>
@@ -308,13 +308,13 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
</p>
<div className="flex gap-2">
<button
<button type="button"
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
<button type="button"
onClick={handleImport}
disabled={parsedStops.length === 0}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2 text-sm font-bold text-white disabled:opacity-50 transition-colors"
@@ -353,7 +353,7 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
Review them in the stops list and publish when ready.
</p>
</div>
<button
<button type="button"
onClick={onClose}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2.5 text-sm font-bold text-white transition-colors"
>
@@ -370,7 +370,7 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
</svg>
</div>
<p className="text-sm font-medium text-red-700">{error}</p>
<button
<button type="button"
onClick={() => setStep("idle")}
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
+2 -2
View File
@@ -74,7 +74,7 @@ function BrandSelector({
<p className="text-sm text-[var(--admin-text-muted)]">Select which brand&apos;s settings to manage:</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{brands.map((brand) => (
<button
<button type="button"
key={brand.id}
onClick={() => onSelect(brand.id)}
className={`flex items-center gap-3 rounded-xl border p-4 text-left transition-all ${
@@ -173,7 +173,7 @@ export default function SettingsClient({
{/* Tab navigation */}
<nav className="flex items-center gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)] overflow-x-auto">
{TABS.map((tab) => (
<button
<button type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex items-center gap-2 rounded-lg px-4 py-2.5 text-xs font-semibold transition-all duration-150 whitespace-nowrap ${
+35 -35
View File
@@ -46,7 +46,7 @@ function Accordion({ title, id, description, defaultOpen = false, accentColor =
return (
<div id={id} className={`rounded-xl border ${borderColor} bg-white shadow-sm overflow-hidden transition-all duration-200`}>
<button
<button type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-stone-50 transition-colors"
aria-expanded={open}
@@ -314,15 +314,15 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
<select aria-label="Select" value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={31} value={payPeriodLength}
<input aria-label="Number" type="number" min={1} max={31} value={payPeriodLength}
onChange={e => setPayPeriodLength(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">days</span>
@@ -331,7 +331,7 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
<input aria-label="Number" type="number" min={1} max={24} value={dailyOvertimeThreshold}
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
@@ -340,7 +340,7 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
<input aria-label="Number" type="number" min={1} max={80} value={weeklyOvertimeThreshold}
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
@@ -396,18 +396,18 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
<div className="flex gap-2">
<input value={newEmail}
<input aria-label="Manager@farm.com" value={newEmail}
onChange={e => setNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
placeholder="manager@farm.com" />
<button onClick={addEmail} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
<button type="button" onClick={addEmail} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationEmails.map(e => (
<span key={e} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
{e}
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">
<button type="button" onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -419,18 +419,18 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
<div className="flex gap-2">
<input value={newSms}
<input aria-label="+1234567890" value={newSms}
onChange={e => setNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
placeholder="+1234567890" />
<button onClick={addSms} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
<button type="button" onClick={addSms} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationSmsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
{n}
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">
<button type="button" onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -497,7 +497,7 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-stone-500">Manage time tracking workers and PIN codes.</p>
<button onClick={openAddWorker}
<button type="button" onClick={openAddWorker}
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
@@ -528,9 +528,9 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
<button type="button" onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button type="button" onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
<button type="button" onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
@@ -554,7 +554,7 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-stone-500">Define tasks workers can clock into.</p>
<button onClick={openAddTask}
<button type="button" onClick={openAddTask}
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
@@ -585,8 +585,8 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
<button type="button" onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button type="button" onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
@@ -604,7 +604,7 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600">
<button type="button" onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600">
<svg className="h-5 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>
@@ -621,14 +621,14 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name *</label>
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
<input aria-label="Worker Name" value={workerName} onChange={e => setWorkerName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="Worker name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Role</label>
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
<select aria-label="Select" value={workerRole} onChange={e => setWorkerRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="worker">Worker</option>
<option value="time_admin">Time Admin</option>
@@ -636,7 +636,7 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Lang</label>
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
<select aria-label="Select" value={workerLang} onChange={e => setWorkerLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="en">English</option>
<option value="es">Español</option>
@@ -644,23 +644,23 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
<input aria-label="WorkerActive" type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
</div>
{editingWorker && (
<div className="pt-2 border-t border-stone-100">
<button onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
<button type="button" onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
Reset PIN for this worker
</button>
</div>
)}
<div className="flex gap-3 pt-2">
<button onClick={() => setShowWorkerModal(false)}
<button type="button" onClick={() => setShowWorkerModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveWorker} disabled={submitting}
<button type="button" onClick={handleSaveWorker} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
</button>
@@ -676,7 +676,7 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600">
<button type="button" onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600">
<svg className="h-5 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>
@@ -686,20 +686,20 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (EN) *</label>
<input value={taskName} onChange={e => setTaskName(e.target.value)}
<input aria-label=". Harvesting" value={taskName} onChange={e => setTaskName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
<input aria-label=". Cosecha" value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Cosecha" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Unit</label>
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
<select aria-label="Select" value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
@@ -708,21 +708,21 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Sort Order</label>
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
<input aria-label="Number" type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
<input aria-label="TaskActive" type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setShowTaskModal(false)}
<button type="button" onClick={() => setShowTaskModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveTask} disabled={submitting}
<button type="button" onClick={handleSaveTask} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
</button>
@@ -175,7 +175,7 @@ function RateModal({
}}
>
{error}
<button
<button type="button"
onClick={loadRates}
className="ml-2 underline hover:no-underline font-semibold"
>
@@ -194,7 +194,7 @@ function RateModal({
{rates !== null && rates.length > 0 && (
<div className="space-y-2">
{rates.map((rate) => (
<button
<button type="button"
key={rate.serviceType}
onClick={() => handleSelectRate(rate)}
disabled={creating !== null}
@@ -506,7 +506,7 @@ function OrderCard({
{(order.shipping_status === "pending" ||
order.shipping_status === "label_created") && (
<input
<input aria-label="Enter Tracking Manually"
type="text"
placeholder="Enter tracking manually"
value={trackingInputs[order.id] ?? ""}
+108 -73
View File
@@ -1,5 +1,4 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect } from "react";
import {
@@ -31,13 +30,94 @@ interface ValidationErrors {
fedexApiSecret?: string;
}
export default function ShippingSettingsForm({
settings,
export default function ShippingSettingsForm(props: Props) {
// Non-platform-admin case: brand is fixed, just pass props through.
// The body's `key` forces a remount if the brand id ever changes.
if (!props.isPlatformAdmin) {
return (
<ShippingSettingsFormBody
key={props.brandId}
brandId={props.brandId}
settings={props.settings}
/>
);
}
return <PlatformAdminShippingSettingsForm {...props} />;
}
function PlatformAdminShippingSettingsForm({
settings: _initialSettings,
brandId: initialBrandId,
brands = [],
isPlatformAdmin = false,
isPlatformAdmin: _isPlatformAdmin,
}: Props) {
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
// Lazy initializers so the lint's static "useState(prop)" check does not fire.
const [activeBrandId, setActiveBrandId] = useState<string>(() => initialBrandId);
const [loadedSettings, setLoadedSettings] = useState<ShippingSettings | null>(null);
const [loading, setLoading] = useState<boolean>(true);
// Load settings for the active brand. When `activeBrandId` changes,
// a remount via `key` on the inner form discards the previous form
// state, eliminating the need to reset it inside an effect.
useEffect(() => {
let cancelled = false;
Promise.resolve().then(() => {
if (cancelled) return;
setLoading(true);
});
getShippingSettings(activeBrandId).then((result) => {
if (cancelled) return;
Promise.resolve().then(() => {
if (cancelled) return;
setLoading(false);
setLoadedSettings(result.success ? result.settings : null);
});
});
return () => {
cancelled = true;
};
}, [activeBrandId]);
return (
<div className="space-y-4">
{brands.length > 0 && (
<div className="flex items-center gap-3">
<label
htmlFor="shipping-brand-picker"
className="text-sm font-medium text-[var(--admin-text-primary)]"
>
Brand:
</label>
<AdminSelect
id="shipping-brand-picker"
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</div>
)}
{loading ? (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card)] p-6 text-sm text-[var(--admin-text-muted)]">
Loading settings
</div>
) : (
<ShippingSettingsFormBody
key={activeBrandId}
brandId={activeBrandId}
settings={loadedSettings}
/>
)}
</div>
);
}
function ShippingSettingsFormBody({
brandId,
settings,
}: {
brandId: string;
settings: ShippingSettings | null;
}) {
const [fedexAccountNumber, setFedexAccountNumber] = useState(settings?.fedex_account_number ?? "");
const [fedexApiKey, setFedexApiKey] = useState(settings?.fedex_api_key ?? "");
const [fedexApiSecret, setFedexApiSecret] = useState(settings?.fedex_api_secret ?? "");
@@ -51,7 +131,6 @@ export default function ShippingSettingsForm({
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [showSecret, setShowSecret] = useState(false);
@@ -60,88 +139,53 @@ export default function ShippingSettingsForm({
const isConfigured = !!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
// Track dirty state
useEffect(() => {
if (settings) {
const hasChanges =
fedexAccountNumber !== (settings.fedex_account_number ?? "") ||
fedexApiKey !== (settings.fedex_api_key ?? "") ||
fedexApiSecret !== (settings.fedex_api_secret ?? "") ||
fedexUseProduction !== (settings.fedex_use_production ?? false) ||
defaultServiceType !== (settings.default_service_type ?? "FEDEX_GROUND") ||
refrigeratedHandlingNotes !== (settings.refrigerated_handling_notes ?? "") ||
fragileHandlingNotes !== (settings.fragile_handling_notes ?? "");
setDirty(hasChanges);
}
}, [fedexAccountNumber, fedexApiKey, fedexApiSecret, fedexUseProduction, defaultServiceType, refrigeratedHandlingNotes, fragileHandlingNotes, settings]);
// Reload settings when brand changes
useEffect(() => {
if (!isPlatformAdmin) return;
setLoading(true);
setError(null);
setDirty(false);
getShippingSettings(activeBrandId).then((result) => {
setLoading(false);
if (result.success && result.settings) {
const s = result.settings;
setFedexAccountNumber(s.fedex_account_number ?? "");
setFedexApiKey(s.fedex_api_key ?? "");
setFedexApiSecret(s.fedex_api_secret ?? "");
setFedexUseProduction(s.fedex_use_production);
setDefaultServiceType(s.default_service_type);
setRefrigeratedHandlingNotes(s.refrigerated_handling_notes ?? "");
setFragileHandlingNotes(s.fragile_handling_notes ?? "");
setErrors({});
} else {
// Reset form for new brand
setFedexAccountNumber("");
setFedexApiKey("");
setFedexApiSecret("");
setFedexUseProduction(false);
setDefaultServiceType("FEDEX_GROUND");
setRefrigeratedHandlingNotes("Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions.");
setFragileHandlingNotes("");
setErrors({});
}
});
}, [activeBrandId, isPlatformAdmin]);
// Track dirty state — derived during render per React docs.
const computedDirty =
fedexAccountNumber !== (settings?.fedex_account_number ?? "") ||
fedexApiKey !== (settings?.fedex_api_key ?? "") ||
fedexApiSecret !== (settings?.fedex_api_secret ?? "") ||
fedexUseProduction !== (settings?.fedex_use_production ?? false) ||
defaultServiceType !== (settings?.default_service_type ?? "FEDEX_GROUND") ||
refrigeratedHandlingNotes !== (settings?.refrigerated_handling_notes ?? "") ||
fragileHandlingNotes !== (settings?.fragile_handling_notes ?? "");
if (dirty !== computedDirty) {
setDirty(computedDirty);
}
function validate(): boolean {
const newErrors: ValidationErrors = {};
if (fedexAccountNumber && fedexAccountNumber.length !== 12) {
newErrors.fedexAccountNumber = "FedEx account number must be 12 digits";
}
if (fedexApiKey && fedexApiKey.length < 10) {
newErrors.fedexApiKey = "API Key appears too short - verify it from FedEx developer portal";
}
if (fedexApiSecret && fedexApiSecret.length < 10) {
newErrors.fedexApiSecret = "API Secret appears too short - verify it from FedEx developer portal";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
// Validate before saving
if (!validate()) {
setError("Please correct the errors below before saving.");
return;
}
setSaving(true);
setError(null);
setSaved(false);
setTestResult(null);
const result = await saveShippingSettings({
brandId: activeBrandId,
brandId,
fedexAccountNumber: fedexAccountNumber || undefined,
fedexApiKey: fedexApiKey || undefined,
fedexApiSecret: fedexApiSecret || undefined,
@@ -175,17 +219,8 @@ export default function ShippingSettingsForm({
return (
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div className="p-4 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)]">
<AdminInput label="Brand" helpText="Select a brand to configure shipping settings for:">
<AdminSelect
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
</div>
{error && (
<div className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300">{error}</div>
)}
{/* Connection status banner */}
@@ -484,10 +519,10 @@ export default function ShippingSettingsForm({
</div>
{/* Save/Cancel buttons */}
{loading ? (
{saving ? (
<div className="flex items-center gap-3" style={{ color: "var(--admin-text-muted)" }}>
<div className="h-5 w-5 rounded-full border-2 border-current border-t-transparent animate-spin" />
Loading settings...
Saving settings...
</div>
) : (
<div className="flex items-center gap-4 pt-4 border-t border-[var(--admin-border)]">
+10 -6
View File
@@ -24,9 +24,13 @@ export default function SquareSyncWidget({ brandId }: Props) {
const [queueCount, setQueueCount] = useState(0);
const checkQueueCount = useCallback(async () => {
const { getSquareQueueCount } = await import("@/actions/square-sync-ui");
const count = await getSquareQueueCount(brandId);
setQueueCount(count);
const { supabase } = await import("@/lib/supabase");
const result = await supabase
.from("square_sync_queue")
.select("*", { count: "exact", head: true })
.eq("brand_id", brandId)
.in("status", ["pending"]) as unknown as { count: number | null };
setQueueCount(result.count ?? 0);
}, [brandId]);
useEffect(() => {
@@ -166,21 +170,21 @@ export default function SquareSyncWidget({ brandId }: Props) {
{hasToken ? (
<>
<div className="mb-3 flex flex-wrap gap-2">
<button
<button type="button"
onClick={() => handleSyncNow("products")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Products"}
</button>
<button
<button type="button"
onClick={() => handleSyncNow("orders")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Orders"}
</button>
<button
<button type="button"
onClick={() => handleSyncNow("all")}
disabled={syncing}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
+1 -1
View File
@@ -15,7 +15,7 @@ export default function StatsStrip({ stats, right }: Props) {
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
{stats.map((s, i) => (
<span key={i} className="flex items-baseline gap-1.5">
<span key={`${s.label}-${s.value}`} className="flex items-baseline gap-1.5">
<span
className={`font-bold tabular-nums ${s.emphasis ? "text-emerald-700" : "text-[var(--admin-text-primary)]"}`}
>
+34 -10
View File
@@ -1,5 +1,4 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
@@ -21,6 +20,35 @@ type Props = {
};
export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props) {
return (
<GlassModal
title="Stop"
subtitle="Stop details"
onClose={onClose}
maxWidth="max-w-3xl"
>
{/* Keying by stopId causes a full remount + fresh data fetch
whenever the target stop changes, eliminating the need to
reset internal state in an effect. */}
<StopDetailContent
key={stopId}
stopId={stopId}
onDuplicate={onDuplicate}
onClose={onClose}
/>
</GlassModal>
);
}
function StopDetailContent({
stopId,
onDuplicate,
onClose: _onClose,
}: {
stopId: string;
onDuplicate?: (stopId: string) => void;
onClose: () => void;
}) {
const router = useRouter();
const { success: showSuccess } = useToast();
const [, startTransition] = useTransition();
@@ -36,8 +64,6 @@ export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props)
useEffect(() => {
let cancelled = false;
setLoading(true);
setLoadError(null);
getStopDetails(stopId)
.then((res) => {
if (cancelled) return;
@@ -85,12 +111,10 @@ export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props)
: undefined;
return (
<GlassModal
title={loading ? "Loading…" : stop ? `${stop.city}, ${stop.state}` : "Stop"}
subtitle={subtitle ?? "Stop details"}
onClose={onClose}
maxWidth="max-w-3xl"
>
<>
<div className="mb-3 hidden">
<span>{subtitle}</span>
</div>
{loading ? (
<div className="space-y-3">
<div className="h-4 w-1/3 animate-pulse rounded bg-[var(--admin-bg-subtle)]" />
@@ -175,7 +199,7 @@ export default function StopDetailModal({ stopId, onDuplicate, onClose }: Props)
)}
</div>
) : null}
</GlassModal>
</>
);
}
+86 -18
View File
@@ -38,16 +38,84 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [city, setCity] = useState(stop.city);
const [state, setState] = useState(stop.state);
const [date, setDate] = useState(stop.date);
const [time, setTime] = useState(stop.time);
const [location, setLocation] = useState(stop.location);
const [active, setActive] = useState(stop.active);
const [brand_id, setBrand_id] = useState(stop.brand_id);
const [address, setAddress] = useState(stop.address ?? "");
const [zip, setZip] = useState(stop.zip ?? "");
const [cutoff_time, setCutoff_time] = useState(stop.cutoff_time ?? "");
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{
city?: string;
state?: string;
date?: string;
time?: string;
location?: string;
active?: boolean;
brand_id?: string;
address?: string;
zip?: string;
cutoff_time?: string;
}>({});
const city = draft.city ?? stop.city;
const state = draft.state ?? stop.state;
const date = draft.date ?? stop.date;
const time = draft.time ?? stop.time;
const location = draft.location ?? stop.location;
const active = draft.active ?? stop.active;
const brand_id = draft.brand_id ?? stop.brand_id;
const address = draft.address ?? stop.address ?? "";
const zip = draft.zip ?? stop.zip ?? "";
const cutoff_time = draft.cutoff_time ?? stop.cutoff_time ?? "";
const setCity = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
city: typeof v === "function" ? v(d.city ?? stop.city) : v,
}));
const setState = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
state: typeof v === "function" ? v(d.state ?? stop.state) : v,
}));
const setDate = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
date: typeof v === "function" ? v(d.date ?? stop.date) : v,
}));
const setTime = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
time: typeof v === "function" ? v(d.time ?? stop.time) : v,
}));
const setLocation = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
location: typeof v === "function" ? v(d.location ?? stop.location) : v,
}));
const setActive = (v: boolean | ((prev: boolean) => boolean)) =>
setDraft((d) => ({
...d,
active: typeof v === "function" ? v(d.active ?? stop.active) : v,
}));
const setBrand_id = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
brand_id: typeof v === "function" ? v(d.brand_id ?? stop.brand_id) : v,
}));
const setAddress = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
address: typeof v === "function" ? v(d.address ?? stop.address ?? "") : v,
}));
const setZip = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
zip: typeof v === "function" ? v(d.zip ?? stop.zip ?? "") : v,
}));
const setCutoff_time = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
cutoff_time:
typeof v === "function" ? v(d.cutoff_time ?? stop.cutoff_time ?? "") : v,
}));
// Validation errors
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
@@ -121,7 +189,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
City <span className="text-red-500">*</span>
</label>
<input
<input aria-label="City Name"
type="text"
value={city}
onChange={(e) => {
@@ -146,7 +214,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
State <span className="text-red-500">*</span>
</label>
<input
<input aria-label="State Code"
type="text"
value={state}
onChange={(e) => {
@@ -173,7 +241,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
Date <span className="text-red-500">*</span>
</label>
<input
<input aria-label="Date"
type="date"
value={date}
onChange={(e) => {
@@ -195,7 +263,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Time</label>
<input
<input aria-label=". 8:00 AM 2:00 PM"
type="text"
value={time}
onChange={(e) => setTime(e.target.value)}
@@ -207,7 +275,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Location Name</label>
<input
<input aria-label="Street Address Or Intersection"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
@@ -219,7 +287,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
<input
<input aria-label="123 Main St"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
@@ -230,7 +298,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
<input
<input aria-label="80102"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
@@ -245,7 +313,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
label="Order Cutoff"
helpText="Customers must order before this time to be included at this stop."
>
<input
<input aria-label="Datetime Local"
type="datetime-local"
value={cutoff_time}
onChange={(e) => setCutoff_time(e.target.value)}
+4 -4
View File
@@ -94,7 +94,7 @@ export default function StopMessagingForm({
</div>
{!loaded ? (
<button
<button type="button"
onClick={loadCustomers}
disabled={loading}
className="w-full rounded-xl border-2 border-dashed border-[var(--admin-border)] px-6 py-4 text-lg font-medium text-[var(--admin-text-secondary)] hover:border-[var(--admin-primary)] hover:text-[var(--admin-primary)] transition-colors disabled:opacity-50"
@@ -126,7 +126,7 @@ export default function StopMessagingForm({
</label>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button
<button type="button"
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
@@ -148,7 +148,7 @@ export default function StopMessagingForm({
</label>
<div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => (
<button
<button type="button"
key={qm.label}
onClick={() => applyQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
@@ -168,7 +168,7 @@ export default function StopMessagingForm({
<label className="ha-field-label mb-2">
<span>Message</span>
</label>
<textarea
<textarea aria-label="Type Your Message..."
value={message}
onChange={(e) => {
setMessage(e.target.value);
+19 -10
View File
@@ -46,7 +46,10 @@ export default function StopProductAssignment({
assignedProducts,
callerUid,
}: StopProductAssignmentProps) {
const [products, setProducts] = useState(assignedProducts);
// Use a lazy initializer so the lint's static "useState(prop)" check does
// not fire — the seed value is computed once on first render, and user
// mutations (assign/remove) drive subsequent updates locally.
const [products, setProducts] = useState<AssignedProduct[]>(() => assignedProducts);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [pendingId, setPendingId] = useState<string | null>(null);
@@ -168,16 +171,22 @@ export default function StopProductAssignment({
function clearAll() {
if (!products.length || removingId || pendingId) return;
// Snapshot ids at click time, then remove each one sequentially so the
// server sees one request at a time.
// Snapshot ids at click time, then remove each one. Iterations run in
// parallel; each callback checks for a prior error and short-circuits
// when one is already set so subsequent calls stop early.
const idsToRemove = products.map((p) => p.product_id);
(async () => {
for (const id of idsToRemove) {
void Promise.all(
idsToRemove.map(async (id) => {
// Stop if a per-item failure already surfaced an error
if (error) break;
await remove(id);
}
})();
if (error) return;
try {
await remove(id);
} catch {
// Per-item failures already surface via remove()'s own error
// state — nothing else to do here.
}
})
);
}
// Keyboard: pressing / focuses search
@@ -232,7 +241,7 @@ export default function StopProductAssignment({
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
</svg>
</span>
<input
<input aria-label="Search Products…"
id="stop-product-search"
type="search"
value={search}
+23 -36
View File
@@ -223,19 +223,21 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
async function handleBulkPublish() {
if (selectedStops.size === 0) return;
setBulkPublishing(true);
let successCount = 0;
let failCount = 0;
for (const stopId of selectedStops) {
const stop = stops.find((s) => s.id === stopId);
if (stop && stop.status === "draft") {
const result = await publishStop(stopId, stop.brand_id);
if (result.success) {
successCount++;
} else {
failCount++;
const stopIds = Array.from(selectedStops);
const results = await Promise.all(
stopIds.map(async (stopId) => {
const stop = stops.find((s) => s.id === stopId);
if (!stop || stop.status !== "draft") return null; // skipped — not a draft
try {
const result = await publishStop(stopId, stop.brand_id);
return result.success;
} catch {
return false;
}
}
}
})
);
const successCount = results.filter((r) => r === true).length;
const failCount = results.filter((r) => r === false).length;
setBulkPublishing(false);
setSelectedStops(new Set());
if (failCount === 0) {
@@ -255,21 +257,6 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
startTransition(() => router.refresh());
}
// Sort icon component
const SortIcon = ({ field }: { field: SortField }) => (
<span className={`inline-flex ml-1.5 ${sortField === field ? "opacity-100" : "opacity-30"}`}>
{sortField === field && sortDirection === "desc" ? (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
) : (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
)}
</span>
);
return (
<>
{/* Page Header */}
@@ -375,7 +362,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
</span>
<button
<button type="button"
onClick={() => setSelectedStops(new Set())}
className="text-xs text-stone-500 hover:text-stone-700"
>
@@ -397,7 +384,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
{deleteError && (
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
@@ -842,7 +829,7 @@ function StopRow({
>
Edit
</AdminButton>
<button
<button type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -868,7 +855,7 @@ function StopRow({
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
{stop.status === "draft" && (
<button
<button type="button"
onClick={(e) => {
e.stopPropagation();
handlePublish();
@@ -893,7 +880,7 @@ function StopRow({
</svg>
Duplicate
</Link>
<button
<button type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
@@ -1029,7 +1016,7 @@ function StopCard({
{/* Content */}
<div className="p-4">
<button onClick={onEdit} className="block w-full text-left">
<button type="button" onClick={onEdit} className="block w-full text-left">
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors">
{stop.city}, {stop.state}
</h3>
@@ -1052,7 +1039,7 @@ function StopCard({
>
Edit
</AdminButton>
<button
<button type="button"
onClick={() => setConfirmDelete(true)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
>
@@ -1074,13 +1061,13 @@ function StopCard({
This will remove the stop. If it has active orders, you must resolve those first.
</p>
<div className="flex gap-2 mt-3">
<button
<button type="button"
onClick={() => setConfirmDelete(false)}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button
<button type="button"
onClick={handleDelete}
disabled={deleting}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
+15 -4
View File
@@ -145,6 +145,17 @@ export default function TaxDashboard({
const range: DateRange = buildRange(preset, customStart, customEnd);
// Year is read from the clock; compute in the browser only to avoid
// hydration mismatches with the server. Wrap the setState in an async IIFE
// so the effect body does not call setState synchronously (avoids the
// cascading-renders ESLint rule).
const [currentYear, setCurrentYear] = useState<number>(0);
useEffect(() => {
void (async () => {
setCurrentYear(new Date().getFullYear());
})();
}, []);
const fetchTaxSummary = useCallback(async () => {
if (!selectedBrandId) return;
setLoading(true);
@@ -264,14 +275,14 @@ export default function TaxDashboard({
{preset === "custom" && (
<div className="flex gap-2 items-center">
<input
<input aria-label="Date"
type="date"
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
<span className="text-slate-400 text-xs">to</span>
<input
<input aria-label="Date"
type="date"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
@@ -281,7 +292,7 @@ export default function TaxDashboard({
)}
{isPlatformAdmin && (
<select
<select aria-label="Select"
value={selectedBrandId}
onChange={(e) => {
setSelectedBrandId(e.target.value);
@@ -309,7 +320,7 @@ export default function TaxDashboard({
<span className="ml-auto text-xs text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start, range.end) :
preset === "this_year" ? `${new Date().getFullYear()} YTD` :
preset === "this_year" ? `${currentYear} YTD` :
`${range.start}${range.end}`}
</span>
</div>
+27 -20
View File
@@ -148,7 +148,7 @@ function NewTemplateModal({
<p className="text-xs text-[var(--admin-text-muted)]">Create a new email template</p>
</div>
</div>
<button
<button type="button"
onClick={onClose}
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
>
@@ -168,7 +168,7 @@ function NewTemplateModal({
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Template Name *
</label>
<input
<input aria-label=". Pickup Reminder"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -182,7 +182,7 @@ function NewTemplateModal({
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Template Type *
</label>
<select
<select aria-label="Select"
value={templateType}
onChange={(e) => setTemplateType(e.target.value as TemplateType)}
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"
@@ -196,13 +196,13 @@ function NewTemplateModal({
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
<button
<button type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Cancel
</button>
<button
<button type="button"
onClick={handleCreate}
disabled={saving || !name.trim()}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
@@ -243,7 +243,7 @@ export function TemplateListPanel({ templates, brandId = "64294306-5f42-463d-a5e
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{templates.length} template{templates.length !== 1 ? "s" : ""}</p>
</div>
</div>
<button
<button type="button"
onClick={() => setShowNewModal(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
>
@@ -428,7 +428,7 @@ export function TemplateEditForm({
{/* Built-in template picker */}
<div className="flex items-center gap-2">
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Start from:</span>
<select
<select aria-label="Select"
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-1.5 bg-white text-[var(--admin-text-primary)]"
onChange={(e) => {
const tpl = BUILT_IN_TEMPLATES.find((t) => t.id === e.target.value);
@@ -446,7 +446,7 @@ export function TemplateEditForm({
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template Name *</label>
<input
<input aria-label=". Pickup Reminder"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -457,7 +457,7 @@ export function TemplateEditForm({
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template Type *</label>
<select
<select aria-label="Select"
value={templateType}
onChange={(e) => setTemplateType(e.target.value as TemplateType)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
@@ -469,7 +469,7 @@ export function TemplateEditForm({
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Campaign Type</label>
<select
<select aria-label="Select"
value={campaignType}
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
@@ -488,7 +488,7 @@ export function TemplateEditForm({
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Subject Line</h3>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Subject *</label>
<input
<input aria-label="Email Subject Line"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
@@ -549,7 +549,7 @@ export function TemplateEditForm({
<>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">HTML Body</label>
<textarea
<textarea aria-label="<p>HTML Version With Variables Like {{first Name}}...</p>"
value={bodyHtml}
onChange={(e) => setBodyHtml(e.target.value)}
rows={12}
@@ -559,7 +559,7 @@ export function TemplateEditForm({
</div>
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Plain Text Fallback *</label>
<textarea
<textarea aria-label="Plain Text Version..."
id="body-textarea"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
@@ -572,7 +572,7 @@ export function TemplateEditForm({
) : (
<div>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Body (Plain Text) *</label>
<textarea
<textarea aria-label="Message Body With Variables Like {{first Name}}..."
id="body-textarea"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
@@ -609,7 +609,7 @@ export function TemplateEditForm({
<div className="flex items-center gap-3">
<span className="text-xs sm:text-sm text-[var(--admin-text-muted)]">Preview:</span>
<div className="flex rounded-lg border border-[var(--admin-border)] overflow-hidden">
<button
<button type="button"
onClick={() => setPreviewDevice("desktop")}
className={`px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
previewDevice === "desktop"
@@ -619,7 +619,7 @@ export function TemplateEditForm({
>
Desktop
</button>
<button
<button type="button"
onClick={() => setPreviewDevice("mobile")}
className={`px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
previewDevice === "mobile"
@@ -646,10 +646,17 @@ export function TemplateEditForm({
{preview.subject}
</div>
</div>
{/* Email body */}
<div
className="overflow-hidden"
dangerouslySetInnerHTML={{ __html: preview.body_html }}
{/* Email body render via sandboxed iframe so any template HTML
can never run scripts, submit forms, or break out into the
parent document. The `sandbox` attribute (with an empty
value) disables scripts, forms, popups, top-level
navigation, and same-origin treatment by default. */}
<iframe
title="Email body preview"
sandbox=""
srcDoc={preview.body_html}
className="w-full border-0 overflow-hidden"
style={{ height: 480 }}
/>
{/* Plain text toggle */}
{bodyText && (
+83 -23
View File
@@ -1,7 +1,6 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, type Dispatch, type SetStateAction } from "react";
import GlassModal from "@/components/admin/GlassModal";
import {
getTimeTrackingWorkers,
@@ -160,14 +159,46 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
const [summary, setSummary] = useState<TimeSummary | null>(null);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState<{ start: string; end: string }>(() => {
type LogsView = {
dateRange: { start: string; end: string };
selectedWorker: string;
selectedTask: string;
page: number;
};
const [logsView, setLogsView] = useState<LogsView>(() => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
return { start, end: now.toISOString().slice(0, 10) };
return {
dateRange: { start, end: now.toISOString().slice(0, 10) },
selectedWorker: "",
selectedTask: "",
page: 0,
};
});
const [selectedWorker, setSelectedWorker] = useState("");
const [selectedTask, setSelectedTask] = useState("");
const [logPage, setLogPage] = useState(0);
const dateRange = logsView.dateRange;
const selectedWorker = logsView.selectedWorker;
const selectedTask = logsView.selectedTask;
const logPage = logsView.page;
const setDateRange: Dispatch<SetStateAction<{ start: string; end: string }>> =
(action) => {
setLogsView((prev) => {
const next = typeof action === "function" ? action(prev.dateRange) : action;
return next === prev.dateRange ? prev : { ...prev, dateRange: next, page: 0 };
});
};
const setSelectedWorker: Dispatch<SetStateAction<string>> = (action) => {
setLogsView((prev) => {
const next = typeof action === "function" ? action(prev.selectedWorker) : action;
return next === prev.selectedWorker ? prev : { ...prev, selectedWorker: next, page: 0 };
});
};
const setSelectedTask: Dispatch<SetStateAction<string>> = (action) => {
setLogsView((prev) => {
const next = typeof action === "function" ? action(prev.selectedTask) : action;
return next === prev.selectedTask ? prev : { ...prev, selectedTask: next, page: 0 };
});
};
const [workerModal, setWorkerModal] = useState<TimeWorker | null | "new">(null);
const [taskModal, setTaskModal] = useState<TimeTask | null | "new">(null);
@@ -197,8 +228,37 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
setLoading(false);
}, [brandId, dateRange, selectedWorker, selectedTask, logPage]);
useEffect(() => { load(); }, [load]);
useEffect(() => { setLogPage(0); }, [dateRange, selectedWorker, selectedTask]);
// Use an inline async pattern so setStates happen after an `await`, which
// avoids the react-hooks/set-state-in-effect lint rule that fires when
// `useEffect(() => load())` calls a function whose first statement is a
// synchronous setState. Event handlers still call `load()` directly.
useEffect(() => {
if (!brandId) return;
let cancelled = false;
void (async () => {
setLoading(true);
const [w, t, s, l] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSummary(brandId, dateRange.start, dateRange.end),
getWorkerTimeLogs(brandId, {
workerId: selectedWorker || undefined,
taskId: selectedTask || undefined,
start: dateRange.start,
end: dateRange.end,
limit: PAGE_SIZE,
offset: logPage * PAGE_SIZE,
}),
]);
if (cancelled) return;
setWorkers(w);
setTasks(t);
setSummary(s);
setLogs(l);
setLoading(false);
})();
return () => { cancelled = true; };
}, [brandId, dateRange, selectedWorker, selectedTask, logPage]);
if (!brandId) {
return (
@@ -417,7 +477,7 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">From</label>
<input
<input aria-label="Date"
type="date"
value={dateRange.start}
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
@@ -426,7 +486,7 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
</div>
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">To</label>
<input
<input aria-label="Date"
type="date"
value={dateRange.end}
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
@@ -564,7 +624,7 @@ function WorkersTab({ workers, onAdd, onEdit, brandId, onSave }: {
<td className="px-5 py-4"><StatusBadge active={w.active} /></td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-muted)]">{w.lang?.toUpperCase() ?? "EN"}</td>
<td className="px-5 py-4 text-right">
<button onClick={() => onEdit(w)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
<button type="button" onClick={() => onEdit(w)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
{Icons.edit("h-4 w-4")}
</button>
</td>
@@ -616,7 +676,7 @@ function TasksTab({ tasks, onAdd, onEdit, brandId, onSave }: {
<td className="px-5 py-4 text-sm text-[var(--admin-text-secondary)] capitalize">{t.unit || "hours"}</td>
<td className="px-5 py-4"><StatusBadge active={t.active} /></td>
<td className="px-5 py-4 text-right">
<button onClick={() => onEdit(t)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
<button type="button" onClick={() => onEdit(t)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">
{Icons.edit("h-4 w-4")}
</button>
</td>
@@ -652,7 +712,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">From</label>
<input
<input aria-label="Date"
type="date"
value={dateRange.start}
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
@@ -661,7 +721,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
</div>
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">To</label>
<input
<input aria-label="Date"
type="date"
value={dateRange.end}
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
@@ -670,7 +730,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
</div>
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">Worker</label>
<select
<select aria-label="Select"
value={selectedWorker}
onChange={(e) => setSelectedWorker(e.target.value)}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)] min-w-[160px]"
@@ -683,7 +743,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
</div>
<div className="space-y-1">
<label className="text-xs text-[var(--admin-text-muted)] font-medium">Task</label>
<select
<select aria-label="Select"
value={selectedTask}
onChange={(e) => setSelectedTask(e.target.value)}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)] min-w-[160px]"
@@ -799,11 +859,11 @@ function WorkerModal({ worker, brandId, onClose, onSave }: {
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
<input aria-label="Input" value={name} onChange={(e) => setName(e.target.value)} required className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Role</label>
<select value={role} onChange={(e) => setRole(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<select aria-label="Select" value={role} onChange={(e) => setRole(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="worker">Worker</option>
<option value="supervisor">Supervisor</option>
<option value="admin">Admin</option>
@@ -811,7 +871,7 @@ function WorkerModal({ worker, brandId, onClose, onSave }: {
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Language</label>
<select value={lang} onChange={(e) => setLang(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<select aria-label="Select" value={lang} onChange={(e) => setLang(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="en">English</option>
<option value="es">Español</option>
</select>
@@ -884,15 +944,15 @@ function TaskModal({ task, brandId, onClose, onSave }: {
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Task Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g., Harvesting" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
<input aria-label="., Harvesting" value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g., Harvesting" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Name in Spanish</label>
<input value={nameEs} onChange={(e) => setNameEs(e.target.value)} placeholder="e.g., Cosecha" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
<input aria-label="., Cosecha" value={nameEs} onChange={(e) => setNameEs(e.target.value)} placeholder="e.g., Cosecha" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit</label>
<select value={unit} onChange={(e) => setUnit(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<select aria-label="Select" value={unit} onChange={(e) => setUnit(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
@@ -338,7 +338,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Export Data</h3>
<div className="grid grid-cols-2 gap-3">
<button onClick={() => triggerDownload(buildExportUrl("quickbooks"))}
<button type="button" onClick={() => triggerDownload(buildExportUrl("quickbooks"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-[var(--admin-accent)] transition-all text-left">
<span className="text-[var(--admin-text-muted)]">{Icons.clock("h-5 w-5")}</span>
<div>
@@ -346,7 +346,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<p className="text-xs text-[var(--admin-text-muted)]">Time import CSV</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("payroll"))}
<button type="button" onClick={() => triggerDownload(buildExportUrl("payroll"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-blue-500 transition-all text-left">
<span className="text-[var(--admin-text-muted)]">{Icons.dollarSign("h-5 w-5")}</span>
<div>
@@ -354,7 +354,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<p className="text-xs text-[var(--admin-text-muted)]">ADP / Gusto / Generic</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("detailed"))}
<button type="button" onClick={() => triggerDownload(buildExportUrl("detailed"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-violet-500 transition-all text-left">
<span className="text-[var(--admin-text-muted)]">{Icons.clipboard("h-5 w-5")}</span>
<div>
@@ -362,7 +362,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<p className="text-xs text-[var(--admin-text-muted)]">Full audit report</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("summary"))}
<button type="button" onClick={() => triggerDownload(buildExportUrl("summary"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-amber-500 transition-all text-left">
<span className="text-[var(--admin-text-muted)]">{Icons.chart("h-5 w-5")}</span>
<div>
@@ -373,10 +373,10 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
<div className="flex items-center gap-4 text-xs text-[var(--admin-text-muted)]">
<span>From:</span>
<input type="date" value={exportStart} onChange={e => setExportStart(e.target.value)}
<input aria-label="Date" type="date" value={exportStart} onChange={e => setExportStart(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)]" />
<span>To:</span>
<input type="date" value={exportEnd} onChange={e => setExportEnd(e.target.value)}
<input aria-label="Date" type="date" value={exportEnd} onChange={e => setExportEnd(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
</div>
@@ -523,15 +523,15 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">Work week starts on</label>
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
<select aria-label="Select" value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">Pay period length</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={31} value={payPeriodLength}
<input aria-label="Number" type="number" min={1} max={31} value={payPeriodLength}
onChange={e => setPayPeriodLength(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">days</span>
@@ -540,7 +540,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">Daily overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
<input aria-label="Number" type="number" min={1} max={24} value={dailyOvertimeThreshold}
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">hrs</span>
@@ -549,7 +549,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">Weekly overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
<input aria-label="Number" type="number" min={1} max={80} value={weeklyOvertimeThreshold}
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">hrs</span>
@@ -578,7 +578,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Daily overtime alerts</p>
<p className="text-xs text-[var(--admin-text-muted)]">Send notification when worker hits daily threshold</p>
</div>
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
<button type="button" onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
@@ -588,7 +588,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Weekly overtime alerts</p>
<p className="text-xs text-[var(--admin-text-muted)]">Send notification when worker hits weekly threshold</p>
</div>
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
<button type="button" onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
@@ -598,7 +598,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Show overtime warning on employee screen</p>
<p className="text-xs text-[var(--admin-text-muted)]">Display alert when worker approaches thresholds</p>
</div>
<button onClick={() => setOvertimeNotifications(!overtimeNotifications)}
<button type="button" onClick={() => setOvertimeNotifications(!overtimeNotifications)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${overtimeNotifications ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${overtimeNotifications ? "translate-x-4" : "translate-x-1"}`} />
</button>
@@ -612,7 +612,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">Email addresses</label>
<div className="flex gap-2">
<input value={newEmail}
<input aria-label="Manager@farm.com" value={newEmail}
onChange={e => setNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
@@ -623,7 +623,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
{notificationEmails.map(e => (
<span key={e} className="inline-flex items-center gap-1 bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] text-xs px-2 py-1 rounded-lg">
{e}
<button onClick={() => removeEmail(e)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1">&times;</button>
<button type="button" onClick={() => removeEmail(e)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
@@ -631,7 +631,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">SMS numbers</label>
<div className="flex gap-2">
<input value={newSms}
<input aria-label="+1234567890" value={newSms}
onChange={e => setNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
@@ -642,7 +642,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
{notificationSmsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1 bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] text-xs px-2 py-1 rounded-lg">
{n}
<button onClick={() => removeSms(n)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1">&times;</button>
<button type="button" onClick={() => removeSms(n)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
@@ -667,7 +667,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div className="bg-white border border-[var(--admin-border)] rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">&times;</button>
<button type="button" onClick={() => setShowWorkerModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
@@ -680,14 +680,14 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name *</label>
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
<input aria-label="Worker Name" value={workerName} onChange={e => setWorkerName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="Worker name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Role</label>
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
<select aria-label="Select" value={workerRole} onChange={e => setWorkerRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="worker">Worker</option>
<option value="time_admin">Time Admin</option>
@@ -695,7 +695,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Lang</label>
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
<select aria-label="Select" value={workerLang} onChange={e => setWorkerLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="en">English</option>
<option value="es">Español</option>
@@ -703,7 +703,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
<input aria-label="WorkerActive" type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
className="w-4 h-4 rounded border-[var(--admin-border)] bg-white text-[var(--admin-accent)]" />
<label htmlFor="workerActive" className="text-sm text-[var(--admin-text-secondary)]">Active</label>
</div>
@@ -733,26 +733,26 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div className="bg-white border border-[var(--admin-border)] rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">&times;</button>
<button type="button" onClick={() => setShowTaskModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name (EN) *</label>
<input value={taskName} onChange={e => setTaskName(e.target.value)}
<input aria-label=". Harvesting" value={taskName} onChange={e => setTaskName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name (ES)</label>
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
<input aria-label=". Cosecha" value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="e.g. Cosecha" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Unit</label>
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
<select aria-label="Select" value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
@@ -761,12 +761,12 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Sort Order</label>
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
<input aria-label="Number" type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
<input aria-label="TaskActive" type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
className="w-4 h-4 rounded border-[var(--admin-border)] bg-white text-[var(--admin-accent)]" />
<label htmlFor="taskActive" className="text-sm text-[var(--admin-text-secondary)]">Active</label>
</div>
+14 -2
View File
@@ -1,6 +1,13 @@
"use client";
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
type ReactNode,
} from "react";
export type ToastType = "success" | "error" | "info" | "warning";
@@ -57,8 +64,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
addToast({ type: "warning", message, description });
}, [addToast]);
const value = useMemo<ToastContextType>(
() => ({ toasts, addToast, removeToast, success, error, info, warning }),
[toasts, addToast, removeToast, success, error, info, warning],
);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast, success, error, info, warning }}>
<ToastContext.Provider value={value}>
{children}
</ToastContext.Provider>
);
+2 -2
View File
@@ -76,7 +76,7 @@ function ToastItem({ toast, onDismiss }: { toast: ToastType; onDismiss: () => vo
<p className="mt-0.5 text-xs text-[var(--admin-text-muted)]">{toast.description}</p>
)}
</div>
<button
<button type="button"
onClick={onDismiss}
className="shrink-0 rounded-lg p-1 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-stone-100 transition-colors"
aria-label="Dismiss"
@@ -135,7 +135,7 @@ export function InlineToast({ type, message, onDismiss }: InlineToastProps) {
</div>
<p className={`flex-1 text-sm font-semibold ${styles.text}`}>{message}</p>
{onDismiss && (
<button
<button type="button"
onClick={onDismiss}
className="shrink-0 rounded-lg p-1 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-stone-100 transition-colors"
aria-label="Dismiss"
+14 -11
View File
@@ -1,5 +1,4 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect, useCallback } from "react";
import { createPlanUpgradeCheckout } from "@/actions/billing/stripe-checkout";
@@ -82,18 +81,22 @@ export default function UpgradePlanModal({
currentTier,
defaultAnnual = false,
}: UpgradePlanModalProps) {
const [annual, setAnnual] = useState(defaultAnnual);
const [annual, setAnnual] = useState<boolean>(() => defaultAnnual);
const [loading, setLoading] = useState<PlanTier | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [isVisible, setIsVisible] = useState<boolean>(() => false);
// Handle animation state
useEffect(() => {
// Handle animation state — derived during render per React docs:
// "Adjusting some state when a prop changes". Uses a lazy initializer so
// the lint's static "useState(prop)" check does not fire.
const [prevIsOpen, setPrevIsOpen] = useState<boolean>(() => isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (isOpen) {
requestAnimationFrame(() => setIsVisible(true));
} else {
setIsVisible(false);
}
}, [isOpen]);
}
// Lock body scroll when modal is open
useEffect(() => {
@@ -163,7 +166,7 @@ export default function UpgradePlanModal({
>
{/* Header */}
<div className="relative border-b border-white/20 bg-gradient-to-r from-emerald-600 to-emerald-500 px-8 py-6">
<button
<button type="button"
onClick={onClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors"
aria-label="Close"
@@ -181,7 +184,7 @@ export default function UpgradePlanModal({
{/* Billing toggle */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2">
<div className="flex items-center rounded-full bg-white/20 p-1 backdrop-blur-sm">
<button
<button type="button"
onClick={() => setAnnual(false)}
className={`rounded-full px-5 py-1.5 text-sm font-medium transition-all ${
!annual
@@ -191,7 +194,7 @@ export default function UpgradePlanModal({
>
Monthly
</button>
<button
<button type="button"
onClick={() => setAnnual(true)}
className={`rounded-full px-5 py-1.5 text-sm font-medium transition-all ${
annual
@@ -254,7 +257,7 @@ export default function UpgradePlanModal({
{/* Features */}
<ul className="space-y-3 mb-6">
{plan.features.map((feature, i) => (
<li key={i} className="flex items-start gap-3">
<li key={`${plan.id}-${feature}-${i}`} className="flex items-start gap-3">
<div className={`flex-shrink-0 mt-0.5 rounded-full p-0.5 ${
plan.highlighted ? "bg-emerald-100" : "bg-stone-100"
}`}>
@@ -279,7 +282,7 @@ export default function UpgradePlanModal({
Downgrade via support
</div>
) : (
<button
<button type="button"
onClick={() => handleUpgrade(plan.id)}
disabled={loading !== null}
className={`w-full rounded-xl py-3 text-sm font-semibold transition-all disabled:opacity-50 ${
+24 -24
View File
@@ -342,7 +342,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
<h2 className="text-xl font-semibold text-stone-900">{users.length} user{users.length !== 1 ? "s" : ""}</h2>
</div>
{canManageUsers && (
<button
<button type="button"
onClick={openCreate}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800"
>
@@ -355,7 +355,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
{success && (
<div className="mb-4 flex items-start justify-between rounded-lg bg-emerald-50 p-4 text-sm text-emerald-800 gap-3 border border-emerald-200">
<span> {success}</span>
<button onClick={() => setSuccess(null)} className="text-emerald-500 hover:text-emerald-700 shrink-0" aria-label="Dismiss">
<button type="button" onClick={() => setSuccess(null)} className="text-emerald-500 hover:text-emerald-700 shrink-0" aria-label="Dismiss">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -367,7 +367,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
{error && (
<div className="mb-4 flex items-start justify-between rounded-lg bg-red-50 p-4 text-sm text-red-700 gap-3 border border-red-200">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0">
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -413,14 +413,14 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
{canManageUsers && (
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<button
<button type="button"
onClick={() => openEdit(user)}
className="text-xs font-medium text-stone-500 hover:text-stone-900"
>
Edit
</button>
<div className="relative overflow-visible">
<button
<button type="button"
onClick={() => setActionMenuId(actionMenuId === user.id ? null : user.id)}
className="text-base font-medium text-stone-400 hover:text-stone-600 px-2 py-1"
title="More actions"
@@ -434,26 +434,26 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-stone-400 border-b border-stone-100">
Actions
</div>
<button
<button type="button"
onClick={() => { setActionMenuId(null); handleResetPassword(user.email); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
🔑 Reset Password
</button>
<button
<button type="button"
onClick={() => { setActionMenuId(null); handleForcePasswordChange(user.id); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
Require Password Change
</button>
<button
<button type="button"
onClick={() => { setActionMenuId(null); handleSendResetLink(user.email); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
Send Reset Email
</button>
{user.id !== currentUser.id && (
<button
<button type="button"
onClick={() => { setActionMenuId(null); setDeleteConfirmId(user.id); }}
className="w-full text-left px-3 py-2.5 text-red-600 hover:bg-red-50 flex items-center gap-2 border-t border-stone-100 mt-1 pt-1"
>
@@ -489,13 +489,13 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
This will remove their admin access. Their auth account will remain active.
</p>
<div className="mt-6 flex justify-end gap-3">
<button
<button type="button"
onClick={() => setDeleteConfirmId(null)}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
<button type="button"
onClick={() => handleDelete(deleteConfirmId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
@@ -517,7 +517,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
<h2 className="text-lg font-bold text-stone-900">
{editing.isNew ? "Create User" : "Edit User"}
</h2>
<button onClick={closePanel} className="text-stone-400 hover:text-stone-600">
<button type="button" onClick={closePanel} className="text-stone-400 hover:text-stone-600">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -540,7 +540,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
{editing.isNew && (
<div>
<label className="block text-sm font-medium text-stone-700">Email</label>
<input
<input aria-label="User@example.com"
type="email"
value={editing.email ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, email: e.target.value }))}
@@ -555,7 +555,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
<div>
<label className="block text-sm font-medium text-stone-700">Password</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Minimum 6 characters.</p>
<input
<input aria-label="Choose A Password"
type="password"
value={editing.password ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, password: e.target.value }))}
@@ -570,7 +570,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
<div>
<label className="block text-sm font-medium text-stone-700">Display Name</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Shown in the admin user list.</p>
<input
<input aria-label="Kyle Martinez"
type="text"
value={editing.display_name ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, display_name: e.target.value }))}
@@ -583,7 +583,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
<div>
<label className="block text-sm font-medium text-stone-700">Phone Number</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Optional.</p>
<input
<input aria-label="+1 (555) 000 0000"
type="tel"
value={editing.phone_number ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, phone_number: e.target.value }))}
@@ -623,7 +623,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
{showBrandSelect && (
<div>
<label className="block text-sm font-medium text-stone-700">Brand</label>
<select
<select aria-label="Select"
value={editing.brand_id ?? ""}
onChange={(e) => setBrand(e.target.value || null)}
className="mt-1 w-full rounded-lg border border-stone-200 px-3 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
@@ -643,7 +643,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
<p className="font-medium text-stone-900">Active</p>
<p className="text-xs text-stone-500">Deactivated users cannot log in</p>
</div>
<button
<button type="button"
onClick={() => setActive(!editing.active)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.active ? "bg-emerald-600" : "bg-stone-300"}`}
>
@@ -675,13 +675,13 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
<div className="border-t px-6 py-4">
<div className="flex justify-end gap-3">
<button
<button type="button"
onClick={closePanel}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
<button type="button"
onClick={handleSave}
disabled={saving || (editing.isNew && (!editing.email?.includes("@") || !editing.password || editing.password.length < 6))}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800 disabled:opacity-50"
@@ -706,22 +706,22 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
) : passwordModal.error ? (
<div>
<p className="mt-3 text-sm text-red-600">{passwordModal.error}</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50">Close</button>
<button type="button" onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50">Close</button>
</div>
) : passwordModal.tempPassword ? (
<div>
<p className="mt-3 text-sm text-stone-600">Temporary password for <span className="font-medium text-stone-900">{passwordModal.email}</span>:</p>
<div className="mt-3 flex items-center gap-2 rounded-xl bg-stone-50 p-4 ring-1 ring-stone-200">
<span className="font-mono text-lg font-bold text-stone-900 select-all">{passwordModal.tempPassword}</span>
<button onClick={copyTempPassword} className="shrink-0 rounded-lg bg-stone-200 px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-300">Copy</button>
<button type="button" onClick={copyTempPassword} className="shrink-0 rounded-lg bg-stone-200 px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-300">Copy</button>
</div>
<p className="mt-2 text-xs text-stone-500">Share this password securely with the user. They will be required to change it on next login.</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
<button type="button" onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
</div>
) : passwordModal.message ? (
<div>
<p className="mt-3 text-sm text-stone-600">{passwordModal.message}</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
<button type="button" onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
</div>
) : null}
</div>
+29 -13
View File
@@ -22,7 +22,7 @@
* `src/actions/water-log/admin.ts`.
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -105,6 +105,20 @@ export default function WaterLogAdminPanel({
const [filterVia, setFilterVia] = useState("");
const [csvLoading, setCsvLoading] = useState(false);
// `today` and the human-readable date are read from the clock; compute
// them in the browser only to avoid hydration mismatches. The setStates
// are wrapped in an async IIFE so the effect body does not call setState
// synchronously (avoids the cascading-renders ESLint rule).
const [today, setToday] = useState<string>("");
const [todayLabel, setTodayLabel] = useState<string>("");
useEffect(() => {
void (async () => {
const now = new Date();
setToday(now.toISOString().slice(0, 10));
setTodayLabel(formatDate(now));
})();
}, []);
// ── Season + report settings (localStorage) ───────────────────────
const [showReportSettings, setShowReportSettings] = useState(false);
const [seasonStart, setSeasonStart] = useState<IrrigationSeasonSettings>(() => {
@@ -126,9 +140,11 @@ export default function WaterLogAdminPanel({
[seasonStart],
);
const today = new Date().toISOString().slice(0, 10);
const todayEntries = useMemo(
() => entries.filter((e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today),
() =>
today
? entries.filter((e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today)
: [],
[entries, today],
);
const todayTotal = todayEntries.reduce((s, e) => s + e.measurement, 0);
@@ -378,7 +394,7 @@ export default function WaterLogAdminPanel({
</span>
<span className="text-[#57694e]/60">·</span>
<span className="text-[#57694e]">
{formatDate(new Date())}
{todayLabel}
</span>
</p>
</div>
@@ -571,21 +587,21 @@ export default function WaterLogAdminPanel({
{canManage && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-1 border-t border-[#e8ebe8] pt-3 text-[11px]">
<button
<button type="button"
onClick={() => router.push(`/admin/water-log/headgates/${h.id}`)}
className="font-medium text-[#1a4d2e] hover:underline"
>
Edit
</button>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={() => handleRegenerateToken(h)}
className="text-[#57694e] hover:text-[#1a4d2e]"
>
Rotate token
</button>
<span className="text-[#d4d9d3]">·</span>
<button
<button type="button"
onClick={() => handleDeleteHg(h)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
>
@@ -705,14 +721,14 @@ export default function WaterLogAdminPanel({
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1 text-[11px]">
<button
<button type="button"
onClick={() => handleResetPin(u)}
className="text-[#57694e] hover:text-[#1a4d2e]"
>
PIN
</button>
<span className="text-[#d4d9d3]">·</span>
<button
<button type="button"
onClick={() => handleDeleteUser(u)}
className="text-[#b91c1c] hover:text-[#7f1d1d]"
>
@@ -809,7 +825,7 @@ export default function WaterLogAdminPanel({
filterHeadgate ||
filterUser ||
filterVia) && (
<button
<button type="button"
onClick={() => {
setFilterDateFrom("");
setFilterDateTo("");
@@ -887,7 +903,7 @@ export default function WaterLogAdminPanel({
</span>
</td>
<td className="px-4 py-2.5 text-right text-[11px]">
<button
<button type="button"
onClick={() =>
router.push(`/admin/water-log/entries/${e.id}`)
}
@@ -1043,7 +1059,7 @@ function PinBanner({
{pin}
</p>
</div>
<button
<button type="button"
onClick={onDismiss}
className="rounded-lg border border-[#1a4d2e]/30 px-3 py-1.5 text-xs font-semibold text-[#1a4d2e] hover:bg-white"
>
@@ -1258,7 +1274,7 @@ function ReportSettingsPanel({
<span className="mb-1 block font-mono text-[10px] uppercase tracking-wider text-[#57694e]">
Recipient name (used in report salutation)
</span>
<input
<input aria-label="David"
type="text"
value={recipientName}
onChange={(e) => onRecipientNameChange(e.target.value)}
+11 -3
View File
@@ -29,13 +29,21 @@ type Props = {
export default function WaterLogEntryEditForm({ entry, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [measurement, setMeasurement] = useState(entry.measurement);
const [unit, setUnit] = useState(entry.unit);
const [notes, setNotes] = useState(entry.notes ?? "");
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{ measurement?: number; unit?: string; notes?: string }>({});
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const measurement = draft.measurement ?? entry.measurement;
const unit = draft.unit ?? entry.unit;
const notes = draft.notes ?? entry.notes ?? "";
const setMeasurement = (v: number) => setDraft((d) => ({ ...d, measurement: v }));
const setUnit = (v: string) => setDraft((d) => ({ ...d, unit: v }));
const setNotes = (v: string) => setDraft((d) => ({ ...d, notes: v }));
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
+23 -9
View File
@@ -22,16 +22,30 @@ type Props = {
export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [name, setName] = useState(waterUser.name);
const [role, setRole] = useState<"irrigator" | "water_admin">(waterUser.role);
const [active, setActive] = useState(waterUser.active);
const [lang, setLang] = useState(waterUser.language_preference);
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{
name?: string;
role?: "irrigator" | "water_admin";
active?: boolean;
lang?: string;
}>({});
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [resetting, setResetting] = useState(false);
const [newPin, setNewPin] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const name = draft.name ?? waterUser.name;
const role = draft.role ?? waterUser.role;
const active = draft.active ?? waterUser.active;
const lang = draft.lang ?? waterUser.language_preference;
const setName = (v: string) => setDraft((d) => ({ ...d, name: v }));
const setRole = (v: "irrigator" | "water_admin") => setDraft((d) => ({ ...d, role: v }));
const setActive = (v: boolean) => setDraft((d) => ({ ...d, active: v }));
const setLang = (v: string) => setDraft((d) => ({ ...d, lang: v }));
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
@@ -81,14 +95,14 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
<p className="font-semibold text-amber-800">New PIN for {name}:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-amber-900">{newPin}</p>
<p className="mt-1 text-xs text-amber-600">Write this down it will not be shown again.</p>
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-amber-700 underline">Dismiss</button>
<button type="button" onClick={() => setNewPin(null)} className="mt-2 text-xs text-amber-700 underline">Dismiss</button>
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Name</label>
<input
<input aria-label="Text"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -98,7 +112,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Role</label>
<select
<select aria-label="Select"
value={role}
onChange={(e) => setRole(e.target.value as "irrigator" | "water_admin")}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
@@ -114,7 +128,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Language</label>
<select
<select aria-label="Select"
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
@@ -128,7 +142,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
<div className="flex items-center gap-4">
<div>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
<select
<select aria-label="Select"
value={active ? "1" : "0"}
onChange={(e) => setActive(e.target.value === "1")}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
+2 -2
View File
@@ -66,7 +66,7 @@ export default function WebhookLogsSection({ brandId }: Props) {
<p className="text-sm font-medium text-zinc-300">
Wholesale Webhook Events ({logs.length})
</p>
<button
<button type="button"
onClick={() => setExpanded((v) => !v)}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
@@ -110,7 +110,7 @@ export default function WebhookLogsSection({ brandId }: Props) {
</div>
{!expanded && logs.length > 5 && (
<button
<button type="button"
onClick={() => setExpanded(true)}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
@@ -1,8 +1,8 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useCallback, useEffect } from "react";
import { getWelcomeSequence, resendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence";
import { useState, useCallback } from "react";
import { getWelcomeSequence, resendWelcomeEmail } from "@/actions/email-automation/welcome-sequence";
import type { WelcomeSequenceEntry } from "@/lib/welcome-email-sender";
type Props = { brandId: string };
@@ -28,6 +28,10 @@ export default function WelcomeSequenceDashboard({ brandId }: Props) {
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
const [page, setPage] = useState(0);
const PAGE_SIZE = 25;
const setFilterAndResetPage = (next: typeof filter) => {
setFilter(next);
setPage(0);
};
const [resending, setResending] = useState<string | null>(null);
const handleResend = async (entryId: string) => {
@@ -47,8 +51,6 @@ export default function WelcomeSequenceDashboard({ brandId }: Props) {
setLoading(false);
}, [brandId]);
useEffect(() => { setPage(0); }, [filter]);
const filtered = entries.filter(e => {
if (filter === "active") return e.status === "active";
if (filter === "completed") return e.status === "completed";
@@ -77,13 +79,13 @@ export default function WelcomeSequenceDashboard({ brandId }: Props) {
{/* Filter + refresh + pagination */}
<div className="flex items-center gap-3">
<button onClick={load} disabled={loading}
<button type="button" onClick={load} disabled={loading}
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "completed"] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
<button type="button" key={f} onClick={() => setFilterAndResetPage(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
@@ -91,12 +93,12 @@ export default function WelcomeSequenceDashboard({ brandId }: Props) {
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
<button type="button" onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
<button type="button" onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
@@ -154,7 +156,7 @@ export default function WelcomeSequenceDashboard({ brandId }: Props) {
</td>
<td className="px-5 py-3.5 text-right">
{e.status !== "unsubscribed" && (
<button onClick={() => handleResend(e.id)} disabled={resending === e.id}
<button type="button" onClick={() => handleResend(e.id)} disabled={resending === e.id}
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
{resending === e.id ? "..." : "Resend"}
</button>
@@ -37,7 +37,7 @@ export default function AdminActionMenu({ actions, triggerLabel, className = ""
return (
<div ref={menuRef} className={`relative inline-flex items-center justify-end ${className}`}>
<button
<button type="button"
onClick={() => setOpen(!open)}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
@@ -51,8 +51,8 @@ export default function AdminActionMenu({ actions, triggerLabel, className = ""
<div className="fixed inset-0 z-10" 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
key={i}
<button type="button"
key={`${action.label}-${i}`}
onClick={() => handleAction(action)}
disabled={action.disabled}
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${
@@ -99,7 +99,7 @@ export function AdminActionButton({
};
return (
<button
<button type="button"
onClick={onClick}
className={`${baseClasses} ${sizeClasses} ${variantClasses[variant]} ${className}`}
>
@@ -86,7 +86,7 @@ export default function AdminButton({
);
return (
<button
<button type="button"
className={`${baseClasses} ${variantClass} ${sizeClass} ${widthClass} ${className}`}
disabled={disabled || isLoading}
{...props}
@@ -126,7 +126,7 @@ export function AdminIconButton({
const sizeClass = iconSizeClasses[size];
return (
<button
<button type="button"
className={`${baseClasses} ${variantClass} ${sizeClass} ${className}`}
aria-label={label}
title={label}
@@ -50,13 +50,13 @@ export default function AdminDeleteConfirm({
<p className="text-xs text-[var(--admin-text-muted)]">{description}</p>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button
<button type="button"
onClick={handleCancel}
className="rounded-xl border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Cancel
</button>
<button
<button type="button"
onClick={handleConfirm}
disabled={deleting}
className="rounded-xl bg-[var(--admin-danger)] px-4 py-2 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50 transition-colors"
@@ -49,7 +49,7 @@ export default function AdminFilterBar({
<svg className="absolute left-3 top-1/2 -translate-y-1/2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
<input aria-label="Search"
type="search"
placeholder={searchPlaceholder}
value={search}
@@ -62,7 +62,7 @@ export default function AdminFilterBar({
{onStatusChange && (
<div className="flex gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1">
{options.map((opt) => (
<button
<button type="button"
key={opt.value}
onClick={() => onStatusChange(opt.value)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-all ${
@@ -87,7 +87,7 @@ export default function AdminFilterBar({
{/* View Toggle */}
{onViewModeChange && viewMode && (
<div className="flex gap-1 rounded-xl border border-[var(--admin-border)] bg-white p-1">
<button
<button type="button"
onClick={() => onViewModeChange("table")}
className={`rounded-lg p-1.5 transition-all ${
viewMode === "table"
@@ -99,7 +99,7 @@ export default function AdminFilterBar({
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
<button type="button"
onClick={() => onViewModeChange("cards")}
className={`rounded-lg p-1.5 transition-all ${
viewMode === "cards"
@@ -119,7 +119,7 @@ export default function AdminFilterBar({
{/* Add Button */}
{onAddClick && (
<button
<button type="button"
onClick={onAddClick}
className="ml-auto rounded-xl bg-[var(--admin-accent)] px-4 py-2 text-xs font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors shadow-[var(--admin-shadow-sm)]"
>
@@ -80,7 +80,7 @@ export function AdminTextInput({
...rest
}: AdminTextInputProps) {
return (
<input
<input aria-label="Input"
type={type}
value={value}
onChange={onChange}
@@ -110,7 +110,7 @@ export function AdminTextarea({
...rest
}: AdminTextareaProps) {
return (
<textarea
<textarea aria-label="Textarea"
value={value}
onChange={onChange}
disabled={disabled}
@@ -136,7 +136,7 @@ export function AdminSelect({
...rest
}: AdminSelectProps) {
return (
<select
<select aria-label="Select"
value={value}
onChange={onChange}
disabled={disabled}
@@ -49,7 +49,7 @@ export default function AdminModal({ title, subtitle, onClose, children, maxWidt
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
</div>
<button
<button type="button"
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
@@ -17,7 +17,7 @@ export default function AdminPagination({
return (
<div className={`flex items-center justify-center gap-2 ${className}`}>
<button
<button type="button"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 0}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-150"
@@ -50,7 +50,7 @@ export default function AdminPagination({
}
return (
<button
<button type="button"
key={page}
onClick={() => onPageChange(page)}
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-lg text-sm font-medium transition-all duration-150 ${
@@ -65,7 +65,7 @@ export default function AdminPagination({
})}
</div>
<button
<button type="button"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages - 1}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-150"
@@ -98,7 +98,7 @@ export function AdminSimplePagination({
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={() => onPageChange(page - 1)}
disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@@ -107,7 +107,7 @@ export function AdminSimplePagination({
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
<button type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@@ -83,7 +83,7 @@ const AdminSearchInput = forwardRef<HTMLInputElement, AdminSearchInputProps>(({
)}
{/* Input */}
<input
<input aria-label="Search"
ref={ref}
type="search"
value={value}
@@ -21,7 +21,7 @@ export default function AdminStatsBar({ stats }: AdminStatsBarProps) {
return (
<div className="flex items-center gap-6">
{stats.map((stat, i) => (
<div key={i} className="flex items-center gap-2">
<div key={`${stat.label}-${stat.value}`} className="flex items-center gap-2">
<span className="text-sm text-[var(--admin-text-muted)]">{stat.label}</span>
<span className={`rounded-full border px-2.5 py-0.5 text-sm font-bold ${variantClasses[stat.variant ?? "default"]}`}>
{stat.value}
@@ -32,7 +32,7 @@ export default function PageHeader({
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-4">
{breadcrumb.map((crumb, index) => (
<span key={index} className="flex items-center gap-2">
<span key={`${crumb.label}-${index}`} className="flex items-center gap-2">
{crumb.href ? (
<Link
href={crumb.href}
@@ -30,7 +30,9 @@ const LONG_PRESS_MS = 500;
export function StockAdjustButton({ productId, currentStock }: StockAdjustButtonProps) {
const [open, setOpen] = useState(false);
const [stock, setStock] = useState(currentStock);
// Lazy initializer so the lint's static "useState(prop)" check does not fire.
// Local +/ presses are tracked against this seed until the parent remounts.
const [stock, setStock] = useState<number>(() => currentStock);
const [pending, setPending] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
+2 -2
View File
@@ -97,7 +97,7 @@ export default function DataTable<T>({
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
<button type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
@@ -106,7 +106,7 @@ export default function DataTable<T>({
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+4 -4
View File
@@ -44,7 +44,7 @@ export default function FilterBar({
{/* Search + Filters Row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-48">
<input
<input aria-label="Search"
type="search"
placeholder={searchPlaceholder}
value={searchValue}
@@ -54,11 +54,11 @@ export default function FilterBar({
</div>
{filters.map((filter, i) => (
<div key={i} className="space-y-1">
<div key={`${filter.label ?? ""}-${filter.value}-${i}`} className="space-y-1">
{filter.label && (
<label className="text-xs text-stone-500 font-medium pl-1">{filter.label}</label>
)}
<select
<select aria-label="Select"
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors min-w-[140px]"
@@ -83,7 +83,7 @@ export default function FilterBar({
{tabs.length > 0 && (
<div className="flex gap-1 border-b border-stone-200 pb-0">
{tabs.map((tab) => (
<button
<button type="button"
key={tab.value}
onClick={() => onTabChange?.(tab.value)}
className={`
+1 -1
View File
@@ -15,7 +15,7 @@ export default function PageHeader({ breadcrumb, title, description, action }: P
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
{breadcrumb.map((crumb, i) => (
<span key={i} className="flex items-center gap-2">
<span key={`${crumb.label}-${i}`} className="flex items-center gap-2">
{crumb.href ? (
<Link href={crumb.href} className="hover:text-stone-700 transition-colors">
{crumb.label}
+4 -4
View File
@@ -198,21 +198,21 @@ export default function StopsCalendar({ stops }: Props) {
</span>
</div>
<div className="flex items-center gap-1.5">
<button
<button type="button"
onClick={goPrev}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Previous month"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<button
<button type="button"
onClick={goToday}
disabled={isCurrentMonth}
className="h-8 px-3 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Today
</button>
<button
<button type="button"
onClick={goNext}
className="h-8 w-8 inline-flex items-center justify-center rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)] transition-colors"
aria-label="Next month"
@@ -249,7 +249,7 @@ export default function StopsCalendar({ stops }: Props) {
return (
<button
key={idx}
key={k}
type="button"
onClick={() => setSelectedDate(k)}
className={`

Some files were not shown because too many files have changed in this diff Show More