"use client"; /* eslint-disable react-hooks/set-state-in-effect */ 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"; // Icon components const Icons = { users: (className: string) => ( ), spinner: (className: string) => ( ), chevronLeft: (className: string) => ( ), chevronRight: (className: string) => ( ), userCircle: (className: string) => ( ), }; 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 ( ); } function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) { const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(rules.filters.length > 0); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); const customerCountRef = useRef(undefined); const timerRef = useRef>(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 // encodes rules), the initial useState values already give us // `preview = null` and `customerCountRef.current = 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); const count = result?.count ?? 0; customerCountRef.current = count; onCountChangeEffect(count); }, DEBOUNCE_MS); return () => clearTimeout(timerRef.current); }, [brandId, rules]); const PAGE_SIZE = 50; const filtered = preview?.sample_customers ?? []; const searched = search ? filtered.filter( (c) => (c.fullName ?? "").toLowerCase().includes(search.toLowerCase()) || c.email.toLowerCase().includes(search.toLowerCase()) ) : filtered; const total = preview?.count ?? 0; const paged = searched.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); return ( {/* Header */} Matching Customers Preview your segment audience {preview && total > 0 && ( {total.toLocaleString()} total )} {/* Content area */} {rules.filters.length === 0 ? ( {/* Decorative background */} Add filters to see matching customers ) : loading ? ( {Icons.spinner("h-5 w-5 animate-spin")} Calculating matches… ) : ( <> { setSearch(e.target.value); setPage(0); }} onClear={() => setSearch("")} /> {paged.length === 0 ? ( No customers match these filters. ) : ( {paged.map((c, idx) => ( {c.fullName ? c.fullName.slice(0, 2).toUpperCase() : "??"} {c.fullName || "(no name)"} {c.email} {c.tags.length > 0 && ( {c.tags.slice(0, 2).map((tag) => ( {tag} ))} )} ))} )} {searched.length > PAGE_SIZE && ( Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length} setPage((p) => Math.max(0, p - 1))} disabled={page === 0} icon={Icons.chevronLeft("h-4 w-4")} iconPosition="left" > Prev setPage((p) => p + 1)} disabled={(page + 1) * PAGE_SIZE >= searched.length} icon={Icons.chevronRight("h-4 w-4")} iconPosition="right" > Next )} > )} ); } type Props = { brandId: string; rules: SegmentRuleV2; onCountChange?: (count: number) => void; };
Preview your segment audience
Add filters to see matching customers
No customers match these filters.
{c.fullName || "(no name)"}
{c.email}