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,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"
|
||||
|
||||
Reference in New Issue
Block a user