Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning

This commit is contained in:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
@@ -3,9 +3,40 @@
import { useState, useEffect, useRef } from "react";
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
// Icon components
const Icons = {
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
spinner: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</path>
</svg>
),
chevronLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
),
chevronRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
),
};
const DEBOUNCE_MS = 300;
@@ -47,11 +78,11 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
const paged = searched.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Matching Customers</h3>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Matching Customers</h3>
{preview && (
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-700">
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
{total.toLocaleString()} total
</span>
)}
@@ -59,52 +90,57 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
{rules.filters.length === 0 ? (
<div className="flex-1 flex items-center justify-center py-12">
<p className="text-sm text-slate-400 text-center">
Add filters to see matching customers
</p>
<div className="text-center">
{Icons.users("w-10 h-10 text-[var(--admin-text-muted)] mx-auto mb-3")}
<p className="text-sm text-[var(--admin-text-muted)]">
Add filters to see matching customers
</p>
</div>
</div>
) : loading ? (
<div className="flex-1 flex items-center justify-center py-12">
<div className="flex items-center gap-2.5 text-slate-400">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<div className="flex items-center gap-2.5 text-[var(--admin-text-muted)]">
{Icons.spinner("h-5 w-5 animate-spin")}
<span className="text-sm">Loading</span>
</div>
</div>
) : (
<>
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="relative">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
</div>
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
{paged.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-6">No customers match these filters.</p>
<p className="text-sm text-[var(--admin-text-muted)] text-center py-6">No customers match these filters.</p>
) : (
<div className="flex-1 overflow-y-auto max-h-[460px] flex flex-col gap-1">
{paged.map((c) => (
<div
key={c.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
>
<div className="w-8 h-8 rounded-full bg-stone-200 flex items-center justify-center text-xs font-medium text-stone-600 flex-shrink-0">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center text-xs font-semibold text-emerald-700 flex-shrink-0">
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{c.name || "(no name)"}</p>
<p className="text-xs text-slate-400 truncate">{c.email}</p>
<p className="text-sm font-medium text-[var(--admin-text-primary)] truncate">{c.name || "(no name)"}</p>
<p className="text-xs text-[var(--admin-text-muted)] truncate">{c.email}</p>
</div>
{c.tags.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{c.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-blue-900/30 text-blue-400 px-2 py-0.5 text-xs"
className="inline-flex items-center rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs"
>
{tag}
</span>
@@ -117,24 +153,26 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
)}
{searched.length > PAGE_SIZE && (
<div className="flex items-center justify-between pt-3 border-t border-slate-100">
<span className="text-xs text-slate-400">
<div className="flex items-center justify-between pt-3 border-t border-[var(--admin-border)]">
<span className="text-xs text-[var(--admin-text-muted)]">
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
>
Prev
{Icons.chevronLeft("h-4 w-4")}
<span>Prev</span>
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= searched.length}
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
>
Next
<span>Next</span>
{Icons.chevronRight("h-4 w-4")}
</button>
</div>
</div>
@@ -143,4 +181,9 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
)}
</div>
);
}
}
type Props = {
brandId: string;
rules: SegmentRuleV2;
};