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:
@@ -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"
|
||||
|
||||
@@ -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">×</button>
|
||||
<button type="button" onClick={() => setSelectedCart(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">×</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>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 "{stop.city}, {stop.state}"?</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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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're offline. Changes will sync when you reconnect.</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)]"
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -74,7 +74,7 @@ function BrandSelector({
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">Select which brand'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 ${
|
||||
|
||||
@@ -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] ?? ""}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)]"}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">×</button>
|
||||
<button type="button" onClick={() => removeEmail(e)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1">×</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">×</button>
|
||||
<button type="button" onClick={() => removeSms(n)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1">×</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">×</button>
|
||||
<button type="button" onClick={() => setShowWorkerModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">×</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">×</button>
|
||||
<button type="button" onClick={() => setShowTaskModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">×</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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={`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user