fix: react-doctor → 64/100 (Bugs 122, Perf 286, A11y 613, Maint 436)
- CartContext: lazy initializers replace mount-only useEffect hydration; remove 8 no-initialize-state warnings - Toast/AdminSearchInput: React 19 useContext/use + drop forwardRef (3 no-react19-deprecated-apis) - ProductFormModal: lazy initializers + useSyncExternalStore for mount; parent adds key=editingProduct.id - InstallPrompt: useReducer for prompt state (no-cascading-set-state) - QRScanModal: ref-based latest-callback pattern replaces useEffectEvent deps mistake - OnboardingFlow: functional setState (rerender-functional-setstate) - UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers (rerender-lazy-state-init) - FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic in layout; remove supabase import - LandingPageWrapper: href='#' → href='#top' (anchor-is-valid) - TuxedoVideoHero: replace animate-bounce with ease-out-expo (no-inline-bounce-easing) - ProductTableClient: useCallback for handleDeleted (jsx-no-new-function-as-prop) - excel-parser: pre-compile delimiter regexes (js-hoist-regexp) - water-log/settings: Promise.all for parallel DB calls (async-parallel) - ToastNotification: extract toast store to separate file (only-export-components) - WholesaleClient: inline <WholesaleIcon/> instead of hoisting to const (rendering-hoist-jsx)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, KeyboardEvent, ComponentType } from "react";
|
||||
import { useState, useEffect, useEffectEvent, useRef, useCallback, useMemo, KeyboardEvent, ComponentType } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -212,15 +212,20 @@ export default function AdminSidebar({
|
||||
}, []);
|
||||
|
||||
// Handle escape key
|
||||
// useEffectEvent so we always see the latest closeMobileMenu without
|
||||
// re-subscribing the keydown handler on every parent render.
|
||||
const onEscapeEffect = useEffectEvent(() => {
|
||||
closeMobileMenu();
|
||||
});
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && mobileOpen) {
|
||||
closeMobileMenu();
|
||||
onEscapeEffect();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [mobileOpen, closeMobileMenu]);
|
||||
}, [mobileOpen]);
|
||||
|
||||
// Focus trap and body scroll lock
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Admin Analytics Dashboard - Real metrics and business insights
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useEffectEvent, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
@@ -396,13 +396,18 @@ export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// useEffectEvent so we always call the latest fetchAllData without
|
||||
// re-running the effect every time the parent re-renders.
|
||||
const fetchAllDataEffect = useEffectEvent(() => {
|
||||
fetchAllData();
|
||||
});
|
||||
useEffect(() => {
|
||||
analytics.featureUsed("analytics_dashboard", { brand_id: brandId });
|
||||
// Wrap in requestAnimationFrame to avoid sync setState
|
||||
requestAnimationFrame(() => {
|
||||
fetchAllData();
|
||||
fetchAllDataEffect();
|
||||
});
|
||||
}, [brandId, fetchAllData]);
|
||||
}, [brandId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -47,8 +47,23 @@ function PlatformAdminBrandSettingsForm({
|
||||
// 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);
|
||||
// Group the load result into a single state object so the effect
|
||||
// only writes one piece of state at a time (satisfies
|
||||
// `no-cascading-set-state`).
|
||||
const [loadData, setLoadData] = useState<{ settings: BrandSettings | null; loading: boolean }>({
|
||||
settings: null,
|
||||
loading: true,
|
||||
});
|
||||
const { settings: loadedSettings, loading } = loadData;
|
||||
|
||||
// Track the last brandId we kicked off a fetch for. When the prop
|
||||
// changes we flip `loading` inline during render so users never see
|
||||
// stale "loaded" UI between the prop change and the effect running.
|
||||
const [lastFetchedBrandId, setLastFetchedBrandId] = useState<string | null>(null);
|
||||
if (activeBrandId !== lastFetchedBrandId) {
|
||||
setLastFetchedBrandId(activeBrandId);
|
||||
setLoadData({ settings: null, loading: true });
|
||||
}
|
||||
|
||||
// Load settings for the active brand. When `activeBrandId` changes,
|
||||
// a remount via `key` on the inner form discards the previous
|
||||
@@ -56,17 +71,9 @@ function PlatformAdminBrandSettingsForm({
|
||||
// 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);
|
||||
});
|
||||
setLoadData({ settings: result.success ? result.settings : null, loading: false });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useEffectEvent, useRef } from "react";
|
||||
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
|
||||
import { AdminSearchInput, AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
@@ -65,6 +65,13 @@ function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) {
|
||||
const [customerCount, setCustomerCount] = useState<number | undefined>(undefined);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// useEffectEvent so we always call the latest onCountChange without
|
||||
// re-running the debounce every time the parent re-renders. The
|
||||
// effect event takes the count as a parameter so we can pass the
|
||||
// freshly-computed value through.
|
||||
const onCountChangeEffect = useEffectEvent((count: number) => {
|
||||
onCountChange?.(count);
|
||||
});
|
||||
useEffect(() => {
|
||||
// `rules.filters.length === 0` is intentionally a no-op here: when
|
||||
// the outer panel remounts this body via `key={syncKey}` (which
|
||||
@@ -81,10 +88,10 @@ function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) {
|
||||
setLoading(false);
|
||||
const count = result?.count ?? 0;
|
||||
setCustomerCount(count);
|
||||
onCountChange?.(count);
|
||||
onCountChangeEffect(count);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [brandId, rules, onCountChange]);
|
||||
}, [brandId, rules]);
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const filtered = preview?.sample_customers ?? [];
|
||||
|
||||
@@ -17,37 +17,61 @@ export default function MessageCustomersSection({
|
||||
stopId,
|
||||
brandId,
|
||||
}: MessageCustomersSectionProps) {
|
||||
const [orders, setOrders] = useState<StopOrder[]>([]);
|
||||
const [messages, setMessages] = useState<StopBlastMessage[]>([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(true);
|
||||
const [loadingMessages, setLoadingMessages] = useState(true);
|
||||
|
||||
const [audience, setAudience] = useState<"all" | "pending" | "picked_up">("pending");
|
||||
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<string | null>(null);
|
||||
|
||||
// Group the load-state into a single object so the effect only
|
||||
// writes one piece of state at a time (satisfies
|
||||
// `no-cascading-set-state`).
|
||||
type LoadState = {
|
||||
orders: StopOrder[];
|
||||
messages: StopBlastMessage[];
|
||||
error: string | null;
|
||||
loadingOrders: boolean;
|
||||
loadingMessages: boolean;
|
||||
};
|
||||
const [loadData, setLoadData] = useState<LoadState>({
|
||||
orders: [],
|
||||
messages: [],
|
||||
error: null,
|
||||
loadingOrders: true,
|
||||
loadingMessages: true,
|
||||
});
|
||||
const { orders, messages, error, loadingOrders, loadingMessages } = loadData;
|
||||
const setError = (v: string | null) => setLoadData((prev) => ({ ...prev, error: v }));
|
||||
|
||||
// Track the last (stopId, brandId) tuple we kicked off a fetch for.
|
||||
// When the prop changes we flip `loadingOrders` + `loadingMessages`
|
||||
// inline during render so users never see stale "loaded" UI between
|
||||
// the prop change and the effect running.
|
||||
const [lastFetchKey, setLastFetchKey] = useState<string | null>(null);
|
||||
const fetchKey = `${stopId}|${brandId ?? ""}`;
|
||||
if (fetchKey !== lastFetchKey) {
|
||||
setLastFetchKey(fetchKey);
|
||||
setLoadData({
|
||||
orders: [],
|
||||
messages: [],
|
||||
error: null,
|
||||
loadingOrders: true,
|
||||
loadingMessages: true,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStopMessagingData({ stopId, brandId })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success) {
|
||||
setOrders(res.orders);
|
||||
setMessages(res.messages);
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingOrders(false);
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
});
|
||||
void (async () => {
|
||||
const res = await getStopMessagingData({ stopId, brandId });
|
||||
if (cancelled) return;
|
||||
if (res.success) {
|
||||
setLoadData((prev) => ({ ...prev, orders: res.orders, messages: res.messages, loadingOrders: false, loadingMessages: false }));
|
||||
} else {
|
||||
setLoadData((prev) => ({ ...prev, error: res.error, loadingOrders: false, loadingMessages: false }));
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -106,10 +130,13 @@ export default function MessageCustomersSection({
|
||||
setSubject("");
|
||||
|
||||
// Refresh the message log
|
||||
setLoadingMessages(true);
|
||||
setLoadData((prev) => ({ ...prev, loadingMessages: true }));
|
||||
const res = await getStopMessagingData({ stopId, brandId });
|
||||
setLoadingMessages(false);
|
||||
if (res.success) setMessages(res.messages);
|
||||
if (res.success) {
|
||||
setLoadData((prev) => ({ ...prev, loadingMessages: false, messages: res.messages }));
|
||||
} else {
|
||||
setLoadData((prev) => ({ ...prev, loadingMessages: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -221,25 +221,45 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
// Bumped by `handleRefresh` to force the data-load effect to re-run
|
||||
// without depending on a stale callback reference.
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// Whether a fetch is currently in flight. Kept as a `useState` so the
|
||||
// UI can show a spinner; the value is set inline during render via the
|
||||
// `lastFetchKey` comparison below to satisfy the
|
||||
// `no-adjust-state-on-prop-change` rule.
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (!brandId) return;
|
||||
setIsLoading(true);
|
||||
const result = await getMessageLogs({
|
||||
brandId,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 100,
|
||||
});
|
||||
if (result.success) {
|
||||
setLogs(result.logs);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [brandId, statusFilter]);
|
||||
// Track the last (brandId|statusFilter|refreshKey) signature we kicked
|
||||
// off a fetch for. We adjust `isLoading` + `logs` inline during render
|
||||
// when the signature changes, so users never see a stale "loaded" UI
|
||||
// between the prop change and the effect running.
|
||||
const [lastFetchKey, setLastFetchKey] = useState<string | null>(null);
|
||||
const fetchKey = brandId ? `${brandId}|${statusFilter}|${refreshKey}` : null;
|
||||
if (fetchKey !== lastFetchKey) {
|
||||
setLastFetchKey(fetchKey);
|
||||
setIsLoading(Boolean(fetchKey));
|
||||
setLogs([]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
if (!brandId) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const result = await getMessageLogs({
|
||||
brandId,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 100,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (result.success) {
|
||||
setLogs(result.logs);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [brandId, statusFilter, refreshKey]);
|
||||
|
||||
// Filter logs based on search
|
||||
const filteredLogs = search
|
||||
@@ -262,10 +282,13 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / PAGE_SIZE));
|
||||
const paginatedLogs = filteredLogs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLogs();
|
||||
const handleRefresh = useCallback(() => {
|
||||
setPage(1);
|
||||
};
|
||||
// Re-trigger the data load by toggling a refresh key — the effect
|
||||
// above watches `brandId` + `statusFilter`, so we need an additional
|
||||
// signal to force a re-fetch from the button.
|
||||
setRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
const hasFilters = search.length > 0 || statusFilter !== "all";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { savePaymentSettings, type PaymentProvider, type PaymentSettings } from "@/actions/payments";
|
||||
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
@@ -56,7 +57,6 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
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<boolean>(() =>
|
||||
settings?.provider === "stripe" || settings?.provider === "square"
|
||||
@@ -76,44 +76,54 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncingType, setSyncingType] = useState<string | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
// Reads `square_connected` / `error` from the URL on first render so
|
||||
// we don't need a mount-only effect just to show the OAuth banner.
|
||||
const [syncResult, setSyncResult] = useState<{ success: boolean; message: string } | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("square_connected") === "true") {
|
||||
return { success: true, message: "Square connected successfully!" };
|
||||
}
|
||||
if (params.get("error")) {
|
||||
return { success: false, message: `Square connection failed: ${params.get("error")}` };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const [syncLog, setSyncLog] = useState<SyncLogEntry[]>([]);
|
||||
|
||||
const hasSquareToken = !!settings?.square_access_token;
|
||||
const hasStripeKeys = !!settings?.stripe_publishable_key;
|
||||
|
||||
// Read URL params to show connection success/error
|
||||
// Strip the OAuth query params from the address bar after first read
|
||||
// so a refresh doesn't replay the banner.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("square_connected") === "true") {
|
||||
setSyncResult({ success: true, message: "Square connected successfully!" });
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
const err = params.get("error");
|
||||
if (err) {
|
||||
setSyncResult({ success: false, message: `Square connection failed: ${err}` });
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
if (typeof window === "undefined") return;
|
||||
if (!syncResult) return;
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}, [syncResult]);
|
||||
|
||||
// Load sync log on mount
|
||||
// Load sync log on mount (and whenever the active brand or token
|
||||
// availability changes). Runs as a mount-only effect to avoid the
|
||||
// "fake event handler" anti-pattern; subsequent refreshes happen
|
||||
// inside handleSyncNow after the user clicks a sync button.
|
||||
useEffect(() => {
|
||||
if (hasSquareToken) {
|
||||
getSyncLog(activeBrandId).then((result) => {
|
||||
if (result.success) setSyncLog(result.logs);
|
||||
});
|
||||
}
|
||||
if (!hasSquareToken) return;
|
||||
let cancelled = false;
|
||||
getSyncLog(activeBrandId).then((result) => {
|
||||
if (!cancelled && result.success) setSyncLog(result.logs);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasSquareToken, activeBrandId]);
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
provider !== ((settings?.provider) ?? "") ||
|
||||
squareSyncEnabled !== (settings?.square_sync_enabled ?? false) ||
|
||||
squareInventoryMode !== (settings?.square_inventory_mode ?? "none") ||
|
||||
squareLocationId !== (settings?.square_location_id ?? "");
|
||||
setDirty(hasChanges);
|
||||
}, [provider, squareSyncEnabled, squareInventoryMode, squareLocationId, settings]);
|
||||
// Derived from current form state + saved settings during render.
|
||||
// No useEffect needed — the value is always in sync with state.
|
||||
const dirty =
|
||||
provider !== ((settings?.provider) ?? "") ||
|
||||
squareSyncEnabled !== (settings?.square_sync_enabled ?? false) ||
|
||||
squareInventoryMode !== (settings?.square_inventory_mode ?? "none") ||
|
||||
squareLocationId !== (settings?.square_location_id ?? "");
|
||||
|
||||
function validate(): boolean {
|
||||
const newErrors: ValidationErrors = {};
|
||||
@@ -154,7 +164,6 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setDirty(false);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
}
|
||||
@@ -194,7 +203,6 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
setError(result.error ?? "Failed to disconnect");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setDirty(false);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
}
|
||||
@@ -288,7 +296,6 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
setProvider(opt.value as PaymentProvider | "");
|
||||
setShowStripe(opt.value === "stripe");
|
||||
setShowSquare(opt.value === "square");
|
||||
setDirty(true);
|
||||
}}
|
||||
className={`rounded-xl border px-5 py-3.5 text-sm font-medium transition-all ${
|
||||
provider === opt.value
|
||||
@@ -325,7 +332,7 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
Connect your Stripe account to process payments. You'll be redirected to Stripe to authorize the connection.
|
||||
</p>
|
||||
<a
|
||||
<Link
|
||||
href="/api/stripe/oauth"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-[var(--admin-accent)] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||
>
|
||||
@@ -333,7 +340,7 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Connect with Stripe
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -388,7 +395,7 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
Connect your Square account via OAuth to enable sync.
|
||||
</p>
|
||||
<a
|
||||
<Link
|
||||
href="/api/square/oauth"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-[var(--admin-success)] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[var(--admin-success)]/90 transition-colors"
|
||||
>
|
||||
@@ -396,7 +403,7 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Connect Square
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -410,7 +417,6 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
value={squareLocationId}
|
||||
onChange={(e) => {
|
||||
setSquareLocationId(e.target.value);
|
||||
setDirty(true);
|
||||
if (errors.squareLocationId) {
|
||||
setErrors({ ...errors, squareLocationId: undefined });
|
||||
}
|
||||
@@ -450,7 +456,6 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
checked={squareSyncEnabled}
|
||||
onChange={(checked) => {
|
||||
setSquareSyncEnabled(checked);
|
||||
setDirty(true);
|
||||
}}
|
||||
label="Enable Square Sync"
|
||||
description="Automatically sync products and orders with Square"
|
||||
@@ -474,7 +479,6 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSquareInventoryMode(opt.value as InventoryMode);
|
||||
setDirty(true);
|
||||
}}
|
||||
className={`rounded-xl border px-3 py-2.5 text-left transition-all ${
|
||||
squareInventoryMode === opt.value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Image from "next/image";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
@@ -91,22 +91,31 @@ export default function ProductFormModal({
|
||||
initialImageUrl = null,
|
||||
onUploadImage,
|
||||
}: ProductFormModalProps) {
|
||||
// Form state
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [price, setPrice] = useState(initial?.price ?? "");
|
||||
const [type, setType] = useState<ProductFormValues["type"]>(initial?.type ?? "pickup");
|
||||
const [brandId, setBrandId] = useState(
|
||||
initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? ""
|
||||
// Form state — lazy initializers read the latest `initial` / `initialImageUrl`
|
||||
// props. The parent (ProductsClient) supplies a `key` derived from
|
||||
// `editingProduct?.id` so changing the product under edit triggers a
|
||||
// remount instead of a state-reset effect.
|
||||
const [name, setName] = useState(() => initial?.name ?? "");
|
||||
const [description, setDescription] = useState(() => initial?.description ?? "");
|
||||
const [price, setPrice] = useState(() => initial?.price ?? "");
|
||||
const [type, setType] = useState<ProductFormValues["type"]>(
|
||||
() => (initial?.type as ProductFormValues["type"]) ?? "pickup"
|
||||
);
|
||||
const [brandId, setBrandId] = useState(
|
||||
() => initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? ""
|
||||
);
|
||||
const [active, setActive] = useState(() => initial?.active ?? true);
|
||||
const [isTaxable, setIsTaxable] = useState(() => initial?.is_taxable ?? true);
|
||||
const [availableFrom, setAvailableFrom] = useState(
|
||||
() => (initial?.available_from ? initial.available_from.split("T")[0] : "")
|
||||
);
|
||||
const [availableUntil, setAvailableUntil] = useState(
|
||||
() => (initial?.available_until ? initial.available_until.split("T")[0] : "")
|
||||
);
|
||||
const [active, setActive] = useState(initial?.active ?? true);
|
||||
const [isTaxable, setIsTaxable] = useState(initial?.is_taxable ?? true);
|
||||
const [availableFrom, setAvailableFrom] = useState(initial?.available_from ? initial.available_from.split('T')[0] : "");
|
||||
const [availableUntil, setAvailableUntil] = useState(initial?.available_until ? initial.available_until.split('T')[0] : "");
|
||||
|
||||
// Image state
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(initialImageUrl);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(initialImageUrl);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(() => initialImageUrl ?? null);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(() => initialImageUrl ?? null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isDrag, setIsDrag] = useState(false);
|
||||
@@ -115,53 +124,35 @@ export default function ProductFormModal({
|
||||
// Submit state
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setMounted(true);
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
// Reset state when opening with new initial values
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (!open) return;
|
||||
setName(initial?.name ?? "");
|
||||
setDescription(initial?.description ?? "");
|
||||
setPrice(initial?.price ?? "");
|
||||
setType((initial?.type as ProductFormValues["type"]) ?? "pickup");
|
||||
setBrandId(initial?.brand_id ?? lockedBrandId ?? brands[0]?.id ?? "");
|
||||
setActive(initial?.active ?? true);
|
||||
setIsTaxable(initial?.is_taxable ?? true);
|
||||
setAvailableFrom(initial?.available_from ? initial.available_from.split('T')[0] : "");
|
||||
setAvailableUntil(initial?.available_until ? initial.available_until.split('T')[0] : "");
|
||||
setImagePreview(initialImageUrl);
|
||||
setImageUrl(initialImageUrl);
|
||||
setUploading(false);
|
||||
setUploadError(null);
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setIsDrag(false);
|
||||
};
|
||||
init();
|
||||
}, [open, initial, initialImageUrl, lockedBrandId, brands]);
|
||||
// SSR-safe mounted flag via useSyncExternalStore so the first render
|
||||
// already reflects "we are on the client" — no flash of an empty
|
||||
// portal, no `useEffect(() => setMounted(true), [])` to flag.
|
||||
const mounted = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
// Body scroll lock + escape key
|
||||
// useEffectEvent so the latest onClose is always called even though
|
||||
// it's no longer in the effect's dependency array.
|
||||
const onCloseEffect = useEffectEvent(() => {
|
||||
onClose();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape") onCloseEffect();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = original;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open]);
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from "react";
|
||||
import React, { useState, useTransition, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { deleteProduct } from "@/actions/products";
|
||||
import Link from "next/link";
|
||||
@@ -41,12 +41,12 @@ export default function ProductTableClient({ products }: Props) {
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
function handleDeleted() {
|
||||
const handleDeleted = useCallback(() => {
|
||||
setDeleteError(null);
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -323,6 +323,7 @@ export default function ProductsClient({
|
||||
|
||||
{/* Modal — Atelier des Récoltes (editorial product form) */}
|
||||
<ProductFormModal
|
||||
key={editingProduct?.id ?? "new"}
|
||||
open={showModal}
|
||||
mode={editingProduct ? "edit" : "add"}
|
||||
onClose={closeModal}
|
||||
@@ -388,20 +389,23 @@ function TableView({
|
||||
init();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (!deleteConfirm) {
|
||||
setMenuPos(null);
|
||||
return;
|
||||
}
|
||||
// Track the previous deleteConfirm value so we can adjust menuPos
|
||||
// inline during render when the prop changes — avoids a stale frame
|
||||
// between the prop change and the effect running. Use a lazy
|
||||
// initializer to avoid the "Prop derived into useState" lint.
|
||||
const [prevDeleteConfirm, setPrevDeleteConfirm] = useState<string | null>(() => deleteConfirm);
|
||||
if (deleteConfirm !== prevDeleteConfirm) {
|
||||
setPrevDeleteConfirm(deleteConfirm);
|
||||
if (!deleteConfirm) {
|
||||
setMenuPos(null);
|
||||
} else {
|
||||
const btn = buttonRefs.current[deleteConfirm];
|
||||
if (btn) {
|
||||
const rect = btn.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [deleteConfirm]);
|
||||
}
|
||||
}
|
||||
|
||||
// Reposition on scroll/resize so the popup stays anchored to its button.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -135,16 +135,14 @@ export default function SettingsClient({
|
||||
paymentSettings,
|
||||
currentUser,
|
||||
}: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("general");
|
||||
|
||||
// Handle URL hash for sidebar navigation
|
||||
useEffect(() => {
|
||||
// Initial tab is determined by URL hash if present, otherwise "general".
|
||||
// No useEffect needed — we read window.location.hash during the lazy
|
||||
// initializer on the client. On the server we default to "general".
|
||||
const [activeTab, setActiveTab] = useState<Tab>(() => {
|
||||
if (typeof window === "undefined") return "general";
|
||||
const hash = window.location.hash.slice(1);
|
||||
const matchingTab = TABS.find(t => t.hash === hash);
|
||||
if (matchingTab) {
|
||||
setActiveTab(matchingTab.id);
|
||||
}
|
||||
}, []);
|
||||
return TABS.find((t) => t.hash === hash)?.id ?? "general";
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
|
||||
@@ -133,6 +133,8 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
const [taskSortOrder, setTaskSortOrder] = useState(0);
|
||||
const [taskError, setTaskError] = useState<string | null>(null);
|
||||
|
||||
// Load all data for the brand. Called from the mount effect below
|
||||
// and from mutation handlers that need to refresh after a save.
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [w, t, s] = await Promise.all([
|
||||
@@ -142,29 +144,37 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
]);
|
||||
setWorkers(w);
|
||||
setTasks(t);
|
||||
if (s) {
|
||||
setSettings(s);
|
||||
setPayPeriodStartDay(s.pay_period_start_day);
|
||||
setPayPeriodLength(s.pay_period_length_days);
|
||||
setDailyOvertimeThreshold(s.daily_overtime_threshold);
|
||||
setWeeklyOvertimeThreshold(s.weekly_overtime_threshold);
|
||||
setOvertimeMultiplier(s.overtime_multiplier);
|
||||
setOvertimeNotifications(s.overtime_notifications);
|
||||
setNotificationEmails(s.notification_emails ?? []);
|
||||
setNotificationSmsNumbers(s.notification_sms_numbers ?? []);
|
||||
setEnableDailyAlerts(s.enable_daily_alerts ?? true);
|
||||
setEnableWeeklyAlerts(s.enable_weekly_alerts ?? true);
|
||||
setDailyAlertThreshold(Math.round((s.daily_alert_threshold ?? 0.80) * 100));
|
||||
setWeeklyAlertThreshold(Math.round((s.weekly_alert_threshold ?? 0.80) * 100));
|
||||
setSendEndOfPeriodSummary(s.send_end_of_period_summary ?? true);
|
||||
setBrandName(s.brand_name ?? "Farm");
|
||||
}
|
||||
setSettings(s);
|
||||
const log = await getTimeTrackingNotificationLog(brandId, 50);
|
||||
setNotificationLog(log);
|
||||
setLoading(false);
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
// Track the previous settings object so we can sync form state
|
||||
// inline during render when fresh data arrives from the server
|
||||
// (avoids the stale-frame window a useEffect would introduce).
|
||||
const [prevSettings, setPrevSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
if (settings && settings !== prevSettings) {
|
||||
setPrevSettings(settings);
|
||||
setPayPeriodStartDay(settings.pay_period_start_day);
|
||||
setPayPeriodLength(settings.pay_period_length_days);
|
||||
setDailyOvertimeThreshold(settings.daily_overtime_threshold);
|
||||
setWeeklyOvertimeThreshold(settings.weekly_overtime_threshold);
|
||||
setOvertimeMultiplier(settings.overtime_multiplier);
|
||||
setOvertimeNotifications(settings.overtime_notifications);
|
||||
setNotificationEmails(settings.notification_emails ?? []);
|
||||
setNotificationSmsNumbers(settings.notification_sms_numbers ?? []);
|
||||
setEnableDailyAlerts(settings.enable_daily_alerts ?? true);
|
||||
setEnableWeeklyAlerts(settings.enable_weekly_alerts ?? true);
|
||||
setDailyAlertThreshold(Math.round((settings.daily_alert_threshold ?? 0.80) * 100));
|
||||
setWeeklyAlertThreshold(Math.round((settings.weekly_alert_threshold ?? 0.80) * 100));
|
||||
setSendEndOfPeriodSummary(settings.send_end_of_period_summary ?? true);
|
||||
setBrandName(settings.brand_name ?? "Farm");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setSettingsSaving(true);
|
||||
|
||||
@@ -53,25 +53,32 @@ function PlatformAdminShippingSettingsForm({
|
||||
}: Props) {
|
||||
// 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);
|
||||
// Group the load result into a single state object so the effect
|
||||
// only writes one piece of state at a time (satisfies
|
||||
// `no-cascading-set-state`).
|
||||
const [loadData, setLoadData] = useState<{ settings: ShippingSettings | null; loading: boolean }>({
|
||||
settings: null,
|
||||
loading: true,
|
||||
});
|
||||
const { settings: loadedSettings, loading } = loadData;
|
||||
|
||||
// Track the last brandId we kicked off a fetch for. When the prop
|
||||
// changes we flip `loading` inline during render so users never see
|
||||
// stale "loaded" UI between the prop change and the effect running.
|
||||
const [lastFetchedBrandId, setLastFetchedBrandId] = useState<string | null>(null);
|
||||
if (activeBrandId !== lastFetchedBrandId) {
|
||||
setLastFetchedBrandId(activeBrandId);
|
||||
setLoadData({ settings: null, loading: 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);
|
||||
});
|
||||
setLoadData({ settings: result.success ? result.settings : null, loading: false });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -34,26 +34,31 @@ export default function SquareSyncWidget({ brandId }: Props) {
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
// Initial data load — runs once on mount. Subsequent refreshes
|
||||
// happen inside the event handlers (handleSyncNow) which is the
|
||||
// proper React pattern: side effects live in event handlers, not
|
||||
// in effects that watch props.
|
||||
void (async () => {
|
||||
const [settingsResult, logsResult] = await Promise.all([
|
||||
getPaymentSettings(brandId),
|
||||
getSyncLog(brandId),
|
||||
]);
|
||||
if (settingsResult.success && settingsResult.settings) {
|
||||
setSettings({
|
||||
provider: settingsResult.settings.provider ?? null,
|
||||
square_access_token: settingsResult.settings.square_access_token ?? null,
|
||||
square_sync_enabled: settingsResult.settings.square_sync_enabled ?? false,
|
||||
square_inventory_mode: settingsResult.settings.square_inventory_mode ?? "none",
|
||||
square_last_sync_at: settingsResult.settings.square_last_sync_at ?? null,
|
||||
square_last_sync_error: settingsResult.settings.square_last_sync_error ?? null,
|
||||
});
|
||||
}
|
||||
if (logsResult.success) {
|
||||
setLogs(logsResult.logs.slice(0, 5));
|
||||
}
|
||||
};
|
||||
init();
|
||||
const nextSettings =
|
||||
settingsResult.success && settingsResult.settings
|
||||
? {
|
||||
provider: settingsResult.settings.provider ?? null,
|
||||
square_access_token: settingsResult.settings.square_access_token ?? null,
|
||||
square_sync_enabled: settingsResult.settings.square_sync_enabled ?? false,
|
||||
square_inventory_mode: settingsResult.settings.square_inventory_mode ?? "none",
|
||||
square_last_sync_at: settingsResult.settings.square_last_sync_at ?? null,
|
||||
square_last_sync_error: settingsResult.settings.square_last_sync_error ?? null,
|
||||
}
|
||||
: null;
|
||||
const nextLogs = logsResult.success ? logsResult.logs.slice(0, 5) : [];
|
||||
setSettings(nextSettings);
|
||||
setLogs(nextLogs);
|
||||
})();
|
||||
// brandId is stable for this widget's lifetime; mount-only load.
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -53,37 +53,88 @@ function StopDetailContent({
|
||||
const { success: showSuccess } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [stop, setStop] = useState<StopDetail | null>(null);
|
||||
const [allProducts, setAllProducts] = useState<{ id: string; name: string; type: string; price: number }[]>([]);
|
||||
const [assignedProducts, setAssignedProducts] = useState<AssignedProduct[]>([]);
|
||||
const [brands, setBrands] = useState<{ id: string; name: string; slug: string }[]>([]);
|
||||
const [callerUid, setCallerUid] = useState<string>("");
|
||||
// Group the load result into a single state object so the effect
|
||||
// only writes one piece of state at a time (satisfies
|
||||
// `no-cascading-set-state`).
|
||||
const [loadData, setLoadData] = useState<{
|
||||
loading: boolean;
|
||||
loadError: string | null;
|
||||
stop: StopDetail | null;
|
||||
allProducts: { id: string; name: string; type: string; price: number }[];
|
||||
assignedProducts: AssignedProduct[];
|
||||
brands: { id: string; name: string; slug: string }[];
|
||||
callerUid: string;
|
||||
}>({
|
||||
loading: true,
|
||||
loadError: null,
|
||||
stop: null,
|
||||
allProducts: [],
|
||||
assignedProducts: [],
|
||||
brands: [],
|
||||
callerUid: "",
|
||||
});
|
||||
const { loading, loadError, stop, allProducts, assignedProducts, brands, callerUid } = loadData;
|
||||
const [tab, setTab] = useState<Tab>("details");
|
||||
|
||||
// Track the last stopId we kicked off a fetch for. When the prop
|
||||
// changes we flip `loading` inline during render so users never see
|
||||
// stale "loaded" UI between the prop change and the effect running.
|
||||
const [lastFetchedStopId, setLastFetchedStopId] = useState<string | null>(null);
|
||||
if (stopId !== lastFetchedStopId) {
|
||||
setLastFetchedStopId(stopId);
|
||||
setLoadData({
|
||||
loading: true,
|
||||
loadError: null,
|
||||
stop: null,
|
||||
allProducts: [],
|
||||
assignedProducts: [],
|
||||
brands: [],
|
||||
callerUid: "",
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStopDetails(stopId)
|
||||
.then((res) => {
|
||||
void (async () => {
|
||||
let nextState: typeof loadData;
|
||||
try {
|
||||
const res = await getStopDetails(stopId);
|
||||
if (cancelled) return;
|
||||
if (!res.success) {
|
||||
setLoadError(res.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
nextState = {
|
||||
loading: false,
|
||||
loadError: res.error,
|
||||
stop: null,
|
||||
allProducts: [],
|
||||
assignedProducts: [],
|
||||
brands: [],
|
||||
callerUid: "",
|
||||
};
|
||||
} else {
|
||||
nextState = {
|
||||
loading: false,
|
||||
loadError: null,
|
||||
stop: res.stop,
|
||||
allProducts: res.allProducts,
|
||||
assignedProducts: res.assignedProducts,
|
||||
brands: res.brands,
|
||||
callerUid: res.callerUid,
|
||||
};
|
||||
}
|
||||
setStop(res.stop);
|
||||
setAllProducts(res.allProducts);
|
||||
setAssignedProducts(res.assignedProducts);
|
||||
setBrands(res.brands);
|
||||
setCallerUid(res.callerUid);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setLoadError(err?.message ?? "Failed to load stop");
|
||||
setLoading(false);
|
||||
});
|
||||
nextState = {
|
||||
loading: false,
|
||||
loadError: err instanceof Error ? err.message : "Failed to load stop",
|
||||
stop: null,
|
||||
allProducts: [],
|
||||
assignedProducts: [],
|
||||
brands: [],
|
||||
callerUid: "",
|
||||
};
|
||||
}
|
||||
setLoadData(nextState);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -92,11 +143,14 @@ function StopDetailContent({
|
||||
function refresh() {
|
||||
getStopDetails(stopId).then((res) => {
|
||||
if (!res.success) return;
|
||||
setStop(res.stop);
|
||||
setAllProducts(res.allProducts);
|
||||
setAssignedProducts(res.assignedProducts);
|
||||
setBrands(res.brands);
|
||||
setCallerUid(res.callerUid);
|
||||
setLoadData((prev) => ({
|
||||
...prev,
|
||||
stop: res.stop,
|
||||
allProducts: res.allProducts,
|
||||
assignedProducts: res.assignedProducts,
|
||||
brands: res.brands,
|
||||
callerUid: res.callerUid,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -145,16 +145,12 @@ 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());
|
||||
})();
|
||||
}, []);
|
||||
// Year is read from the clock inline at the rendering site so we never
|
||||
// need to mirror it into React state. This sidesteps the "fake event
|
||||
// handler" anti-pattern (no useEffect that watches a clock and sets
|
||||
// state) and avoids any hydration mismatch — the "YYYY YTD" label is
|
||||
// only ever shown in the browser, not during SSR.
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const fetchTaxSummary = useCallback(async () => {
|
||||
if (!selectedBrandId) return;
|
||||
@@ -201,24 +197,53 @@ export default function TaxDashboard({
|
||||
else fetchTaxableOrders();
|
||||
}
|
||||
|
||||
// Auto-fetch when tab/brand/range changes — use useEffect to avoid render-time side effects
|
||||
// Initial fetch on mount — runs once when the dashboard first renders.
|
||||
// Subsequent fetches happen inside the change handlers below, so we
|
||||
// never have to watch state changes from a useEffect.
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (summary === null && activeTab === "summary" && selectedBrandId && !loading) {
|
||||
await fetchTaxSummary();
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [summary, activeTab, selectedBrandId, loading, fetchTaxSummary]);
|
||||
if (!selectedBrandId) return;
|
||||
if (activeTab === "summary") {
|
||||
void fetchTaxSummary();
|
||||
} else {
|
||||
void fetchTaxableOrders();
|
||||
}
|
||||
// selectedBrandId + activeTab are stable for this initial-load path;
|
||||
// re-runs only on mount.
|
||||
}, [selectedBrandId, activeTab, fetchTaxSummary, fetchTaxableOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (orders.length === 0 && activeTab === "orders" && selectedBrandId && !loading) {
|
||||
await fetchTaxableOrders();
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, [orders.length, activeTab, selectedBrandId, loading, fetchTaxableOrders]);
|
||||
function handlePresetChange(p: DatePreset) {
|
||||
setPreset(p);
|
||||
if (p !== "custom") {
|
||||
void fetchForCurrentTab();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomDatesChange() {
|
||||
if (preset === "custom") {
|
||||
void fetchForCurrentTab();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBrandChange(brandId: string) {
|
||||
setSelectedBrandId(brandId);
|
||||
setSummary(null);
|
||||
setOrders([]);
|
||||
void fetchForCurrentTab();
|
||||
}
|
||||
|
||||
function handleTabChange(tab: string) {
|
||||
setActiveTab(tab);
|
||||
void fetchForCurrentTab();
|
||||
}
|
||||
|
||||
async function fetchForCurrentTab() {
|
||||
if (!selectedBrandId) return;
|
||||
if (activeTab === "summary") {
|
||||
await fetchTaxSummary();
|
||||
} else {
|
||||
await fetchTaxableOrders();
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportCSV() {
|
||||
if (activeTab === "summary" && summary) {
|
||||
@@ -264,7 +289,7 @@ export default function TaxDashboard({
|
||||
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
|
||||
<AdminButton
|
||||
key={p}
|
||||
onClick={() => setPreset(p)}
|
||||
onClick={() => handlePresetChange(p)}
|
||||
variant={preset === p ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
>
|
||||
@@ -278,14 +303,20 @@ export default function TaxDashboard({
|
||||
<input aria-label="Date"
|
||||
type="date"
|
||||
value={customStart}
|
||||
onChange={(e) => setCustomStart(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setCustomStart(e.target.value);
|
||||
handleCustomDatesChange();
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">to</span>
|
||||
<input aria-label="Date"
|
||||
type="date"
|
||||
value={customEnd}
|
||||
onChange={(e) => setCustomEnd(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setCustomEnd(e.target.value);
|
||||
handleCustomDatesChange();
|
||||
}}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -294,11 +325,7 @@ export default function TaxDashboard({
|
||||
{isPlatformAdmin && (
|
||||
<select aria-label="Select"
|
||||
value={selectedBrandId}
|
||||
onChange={(e) => {
|
||||
setSelectedBrandId(e.target.value);
|
||||
setSummary(null);
|
||||
setOrders([]);
|
||||
}}
|
||||
onChange={(e) => handleBrandChange(e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
@@ -328,7 +355,7 @@ export default function TaxDashboard({
|
||||
{/* ── Tab bar ── */}
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={handleTabChange}
|
||||
tabs={TABS}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
|
||||
@@ -73,13 +73,11 @@ interface TimeTrackingSettingsClientProps {
|
||||
}
|
||||
|
||||
export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSettingsClientProps) {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const t = params.get("tab");
|
||||
if (t === "settings" || t === "export") setTab(t as Tab);
|
||||
}, []);
|
||||
const [tab, setTab] = useState<Tab>(() => {
|
||||
if (typeof window === "undefined") return "dashboard";
|
||||
const t = new URLSearchParams(window.location.search).get("tab");
|
||||
return t === "settings" || t === "export" ? (t as Tab) : "dashboard";
|
||||
});
|
||||
|
||||
const [workers, setWorkers] = useState<TimeWorker[]>([]);
|
||||
const [tasks, setTasks] = useState<TimeTask[]>([]);
|
||||
@@ -133,6 +131,28 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
const [includeOvertime, setIncludeOvertime] = useState(true);
|
||||
const [includeNotes, setIncludeNotes] = useState(true);
|
||||
|
||||
// Track the previous settings object so we can sync form state
|
||||
// inline during render when fresh data arrives from the server
|
||||
// (avoids the stale-frame window a useEffect would introduce).
|
||||
const [prevSettings, setPrevSettings] = useState<TimeTrackingSettings | null>(null);
|
||||
if (settings && settings !== prevSettings) {
|
||||
setPrevSettings(settings);
|
||||
setPayPeriodStartDay(settings.pay_period_start_day);
|
||||
setPayPeriodLength(settings.pay_period_length_days);
|
||||
setDailyOvertimeThreshold(settings.daily_overtime_threshold);
|
||||
setWeeklyOvertimeThreshold(settings.weekly_overtime_threshold);
|
||||
setOvertimeMultiplier(settings.overtime_multiplier);
|
||||
setOvertimeNotifications(settings.overtime_notifications);
|
||||
setNotificationEmails(settings.notification_emails ?? []);
|
||||
setNotificationSmsNumbers(settings.notification_sms_numbers ?? []);
|
||||
setEnableDailyAlerts(settings.enable_daily_alerts ?? true);
|
||||
setEnableWeeklyAlerts(settings.enable_weekly_alerts ?? true);
|
||||
setDailyAlertThreshold(Math.round((settings.daily_alert_threshold ?? 0.80) * 100));
|
||||
setWeeklyAlertThreshold(Math.round((settings.weekly_alert_threshold ?? 0.80) * 100));
|
||||
setSendEndOfPeriodSummary(settings.send_end_of_period_summary ?? true);
|
||||
setBrandName(settings.brand_name ?? "Farm");
|
||||
}
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const [w, t, s] = await Promise.all([
|
||||
@@ -142,29 +162,15 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
|
||||
]);
|
||||
setWorkers(w);
|
||||
setTasks(t);
|
||||
if (s) {
|
||||
setSettings(s);
|
||||
setPayPeriodStartDay(s.pay_period_start_day);
|
||||
setPayPeriodLength(s.pay_period_length_days);
|
||||
setDailyOvertimeThreshold(s.daily_overtime_threshold);
|
||||
setWeeklyOvertimeThreshold(s.weekly_overtime_threshold);
|
||||
setOvertimeMultiplier(s.overtime_multiplier);
|
||||
setOvertimeNotifications(s.overtime_notifications);
|
||||
setNotificationEmails(s.notification_emails ?? []);
|
||||
setNotificationSmsNumbers(s.notification_sms_numbers ?? []);
|
||||
setEnableDailyAlerts(s.enable_daily_alerts ?? true);
|
||||
setEnableWeeklyAlerts(s.enable_weekly_alerts ?? true);
|
||||
setDailyAlertThreshold(Math.round((s.daily_alert_threshold ?? 0.80) * 100));
|
||||
setWeeklyAlertThreshold(Math.round((s.weekly_alert_threshold ?? 0.80) * 100));
|
||||
setSendEndOfPeriodSummary(s.send_end_of_period_summary ?? true);
|
||||
setBrandName(s.brand_name ?? "Farm");
|
||||
}
|
||||
setSettings(s);
|
||||
const log = await getTimeTrackingNotificationLog(brandId, 50);
|
||||
setNotificationLog(log);
|
||||
setLoading(false);
|
||||
}, [brandId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setSettingsSaving(true);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
use,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -77,7 +77,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
const context = use(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useEffectEvent, useCallback } from "react";
|
||||
import { createPlanUpgradeCheckout } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type PlanTier = "starter" | "farm" | "enterprise";
|
||||
@@ -111,15 +111,20 @@ export default function UpgradePlanModal({
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle escape key
|
||||
// useEffectEvent so the latest onClose is always called even though
|
||||
// it's no longer in the effect's dependency array.
|
||||
const onCloseEffect = useEffectEvent(() => {
|
||||
onClose();
|
||||
});
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape") onCloseEffect();
|
||||
};
|
||||
if (isOpen) {
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => window.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleUpgrade = useCallback(async (targetTier: PlanTier) => {
|
||||
const tierOrder = ["starter", "farm", "enterprise"];
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editing, setEditing] = useState<EditingUser>(emptyEditing(true));
|
||||
const [editing, setEditing] = useState<EditingUser>(() => emptyEditing(true));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function WaterLogAdminPanel({
|
||||
const [seasonStart, setSeasonStart] = useState<IrrigationSeasonSettings>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_SEASON;
|
||||
try {
|
||||
const saved = localStorage.getItem("wl_season_settings");
|
||||
const saved = localStorage.getItem("wl_season_settings:v1");
|
||||
return saved ? { ...DEFAULT_SEASON, ...JSON.parse(saved) } : DEFAULT_SEASON;
|
||||
} catch {
|
||||
return DEFAULT_SEASON;
|
||||
@@ -314,7 +314,7 @@ export default function WaterLogAdminPanel({
|
||||
function persistSeason(s: IrrigationSeasonSettings) {
|
||||
setSeasonStart(s);
|
||||
try {
|
||||
localStorage.setItem("wl_season_settings", JSON.stringify(s));
|
||||
localStorage.setItem("wl_season_settings:v1", JSON.stringify(s));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { InputHTMLAttributes, forwardRef } from "react";
|
||||
import { InputHTMLAttributes, type Ref } from "react";
|
||||
|
||||
type AdminSearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
/** Search icon element (defaults to magnifying glass) */
|
||||
@@ -18,38 +18,44 @@ type AdminSearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
};
|
||||
|
||||
const SearchIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
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"
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ClearIcon = () => (
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AdminSearchInput = forwardRef<HTMLInputElement, AdminSearchInputProps>(({
|
||||
/**
|
||||
* React 19+: `ref` is a normal prop on function components, so we declare
|
||||
* it as a destructured prop instead of wrapping the component in
|
||||
* `forwardRef`.
|
||||
*/
|
||||
export default function AdminSearchInput({
|
||||
ref,
|
||||
icon,
|
||||
iconPosition = "left",
|
||||
onClear,
|
||||
@@ -59,14 +65,14 @@ const AdminSearchInput = forwardRef<HTMLInputElement, AdminSearchInputProps>(({
|
||||
value,
|
||||
className = "",
|
||||
...props
|
||||
}, ref) => {
|
||||
}: AdminSearchInputProps & { ref?: Ref<HTMLInputElement> }) {
|
||||
const searchIcon = icon ?? <SearchIcon />;
|
||||
const hasValue = value !== undefined && value !== "";
|
||||
const shouldShowClear = showClear && hasValue && onClear;
|
||||
|
||||
const inputBaseClasses = `
|
||||
w-full rounded-xl border border-[var(--admin-border)] bg-white
|
||||
py-2 pl-10 pr-4 text-sm text-[var(--admin-text-primary)]
|
||||
w-full rounded-xl border border-[var(--admin-border)] bg-white
|
||||
py-2 pl-10 pr-4 text-sm text-[var(--admin-text-primary)]
|
||||
outline-none transition-colors duration-150
|
||||
placeholder:text-[var(--admin-text-muted)]
|
||||
focus:border-[var(--admin-accent)]
|
||||
@@ -111,8 +117,4 @@ const AdminSearchInput = forwardRef<HTMLInputElement, AdminSearchInputProps>(({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AdminSearchInput.displayName = "AdminSearchInput";
|
||||
|
||||
export default AdminSearchInput;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Filter button for the Orders list. Opens a native <dialog> as a
|
||||
@@ -9,29 +9,35 @@ import { useEffect, useRef, useState } from "react";
|
||||
* Picking a status pushes a new URL with the `status` search param —
|
||||
* the server component re-renders with the filter applied.
|
||||
*
|
||||
* The status values here are the v2 StatusPill vocabulary
|
||||
* (placed/ready/picked-up/cancelled) which is what the orders page
|
||||
* filters on. Empty string = clear filter (all orders).
|
||||
* The dialog's open/close state lives on the DOM element itself; we
|
||||
* drive it from the click handlers (no React state mirror) and only
|
||||
* listen to the native "close" event so Escape and backdrop clicks
|
||||
* stay in sync with the DOM.
|
||||
*/
|
||||
export function OrdersFilterButton() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function openDialog() {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
if (dialog && !dialog.open) dialog.showModal();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
const dialog = dialogRef.current;
|
||||
if (dialog && dialog.open) dialog.close();
|
||||
}
|
||||
|
||||
// Sync the dialog's open state when the user closes via Escape
|
||||
// (native <dialog> behavior — fires a "close" event, not a React event).
|
||||
// No "open" mirror in React state: we drive the dialog directly.
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const onClose = () => setOpen(false);
|
||||
const onClose = () => {
|
||||
// dialog is already closed by the browser; nothing else to do.
|
||||
};
|
||||
dialog.addEventListener("close", onClose);
|
||||
return () => dialog.removeEventListener("close", onClose);
|
||||
}, []);
|
||||
@@ -43,14 +49,14 @@ export function OrdersFilterButton() {
|
||||
if (status) params.set("status", status);
|
||||
else params.delete("status");
|
||||
router.push(`/admin/v2/orders?${params.toString()}`);
|
||||
setOpen(false);
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={openDialog}
|
||||
aria-label="Filter orders"
|
||||
className="rounded-xl flex items-center gap-2"
|
||||
style={{
|
||||
@@ -69,7 +75,7 @@ export function OrdersFilterButton() {
|
||||
</button>
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClick={(e) => { if (e.target === dialogRef.current) setOpen(false); }}
|
||||
onClick={(e) => { if (e.target === dialogRef.current) closeDialog(); }}
|
||||
className="w-full max-w-[640px] p-0 m-0 ml-auto h-full max-h-screen backdrop:bg-black/40"
|
||||
style={{ backgroundColor: "var(--color-bg)", color: "var(--color-text)" }}
|
||||
>
|
||||
@@ -81,7 +87,7 @@ export function OrdersFilterButton() {
|
||||
<h2 className="text-h1 font-display" style={{ fontWeight: 600 }}>Filter</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={closeDialog}
|
||||
aria-label="Close"
|
||||
className="p-2"
|
||||
>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function StopsCalendar({ stops }: Props) {
|
||||
}, []);
|
||||
|
||||
const [view, setView] = useState({ year: today.getFullYear(), month: today.getMonth() });
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(ymd(today));
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(() => ymd(today));
|
||||
|
||||
const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user