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

- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
This commit is contained in:
Nora
2026-06-25 23:49:37 -06:00
parent 4d295ef062
commit 0ac4beaaa8
580 changed files with 52565 additions and 4953 deletions
@@ -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"