feat(landing): enhance hero with vibrant colorful design

- Add gradient background with warm peachy/golden tones
- Add 4 large colorful decorative blobs (coral, mint, amber, lavender)
- Add colorful accent dots scattered around hero
- Enhance grid pattern with blue-tinted color
- Add colorful gradient divider before CTAs
- Give trust indicator cards unique gradient backgrounds (mint, amber, coral)
- Enhance floating stat cards with vibrant gradients (pink, teal, amber)
- Update pulse glow animation colors to match theme

Adds energy and visual depth while maintaining professional appearance
This commit is contained in:
2026-06-02 15:06:02 +00:00
parent 4095c62011
commit 71e0aa6f3b
6 changed files with 648 additions and 306 deletions
@@ -4,8 +4,32 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import type { CommunicationSettings } from "@/actions/communications/settings";
import { upsertCommunicationSettings } from "@/actions/communications/settings";
import { AdminButton } from "@/components/admin/design-system";
import { AdminInput, AdminTextInput, AdminTextarea } from "@/components/admin/design-system/AdminFormElements";
const BRAND_ID = process.env.NEXT_PUBLIC_TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
// Mail icon for the header
const MailIcon = ({ className }: { className?: string }) => (
<svg className={className ?? "h-5 w-5"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
);
// Check icon for success
const CheckIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
// Warning icon for provider notice
const WarningIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
);
export default function CommunicationSettingsForm({
settings,
@@ -46,76 +70,99 @@ export default function CommunicationSettingsForm({
return (
<div className="max-w-2xl">
<div className="mb-6">
<h2 className="text-xl font-semibold text-zinc-100">Sender Settings</h2>
<p className="text-sm text-zinc-500 mt-1">
Configure the default sender identity for this brand&apos;s email campaigns.
Provider (Resend) is configured via environment variables no credentials stored here.
</p>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
)}
{success && (
<div className="mb-4 rounded-lg bg-green-900/30 border border-green-200 px-4 py-3 text-sm text-green-400">
Settings saved successfully.
</div>
)}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Default Sender Email</label>
<input
type="email"
value={senderEmail}
onChange={(e) => setSenderEmail(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="orders@tuxedocorn.com"
/>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--admin-accent)] to-[var(--admin-accent-dark,var(--admin-accent))] text-white shadow-lg shadow-[var(--admin-accent)]/20">
<MailIcon />
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Default Sender Name</label>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Route Commerce"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Reply-To Email</label>
<input
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="support@tuxedocorn.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Email Footer HTML (optional)</label>
<textarea
value={footerHtml}
onChange={(e) => setFooterHtml(e.target.value)}
rows={3}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
placeholder="<p>Unsubscribe: <a href='...'>link</a></p>"
/>
<p className="mt-1 text-xs text-slate-400">Include your unsubscribe link here. Add {`{unsubscribe_url}`} as placeholder.</p>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Sender Settings</h2>
<p className="text-xs text-[var(--admin-text-muted)]">Configure the default sender identity for this brand's email campaigns</p>
</div>
</div>
<div className="mt-4">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
{/* Provider notice */}
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-50 border border-amber-200 mb-6">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 text-amber-600 flex-shrink-0">
<WarningIcon />
</div>
<div>
<p className="text-sm font-medium text-amber-800">Email Provider</p>
<p className="text-xs text-amber-700 mt-0.5">
Provider (Resend) is configured via environment variables — no credentials stored here.
</p>
</div>
</div>
{/* Form card */}
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
{/* Form header */}
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-gradient-to-r from-[var(--admin-card)] to-[var(--admin-bg)]">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-[var(--admin-accent)]" />
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Email Identity</h3>
</div>
</div>
{/* Form fields */}
<div className="p-5 space-y-5">
<AdminInput label="Default Sender Email" helpText="The email address recipients will see as the sender">
<AdminTextInput
type="email"
value={senderEmail}
onChange={(e) => setSenderEmail(e.target.value)}
placeholder="orders@yourbrand.com"
/>
</AdminInput>
<AdminInput label="Default Sender Name" helpText="The name displayed as the sender">
<AdminTextInput
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
placeholder="Route Commerce"
/>
</AdminInput>
<AdminInput label="Reply-To Email" helpText="Emails will be replied to this address">
<AdminTextInput
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
placeholder="support@yourbrand.com"
/>
</AdminInput>
<AdminInput label="Email Footer HTML (optional)" helpText="Add {unsubscribe_url} as placeholder for automatic unsubscribe links">
<AdminTextarea
value={footerHtml}
onChange={(e) => setFooterHtml(e.target.value)}
rows={4}
placeholder={"<p>Unsubscribe: <a href='...'>link</a></p>"}
/>
</AdminInput>
</div>
{/* Form footer with actions */}
<div className="px-5 py-4 border-t border-[var(--admin-border)] bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-card)] flex items-center justify-between">
<div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{success && (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<CheckIcon />
<span>Settings saved successfully</span>
</div>
)}
</div>
<AdminButton
onClick={handleSave}
disabled={saving}
>
{saving ? "Saving..." : "Save Settings"}
</AdminButton>
</div>
</div>
</div>
);
+17 -1
View File
@@ -9,6 +9,7 @@ import ContactImportForm from "./ContactImportForm";
import SegmentBuilderPage from "@/components/admin/HarvestReach/SegmentBuilderPage";
import AnalyticsDashboard from "@/components/admin/HarvestReach/AnalyticsDashboard";
import CampaignComposerPage from "@/components/admin/HarvestReach/CampaignComposerPage";
import CommunicationSettingsForm from "./CommunicationSettingsForm";
import { PageHeader, AdminFilterTabs } from "@/components/admin/design-system";
import type { Campaign } from "@/actions/communications/campaigns";
import type { Template } from "@/actions/communications/templates";
@@ -16,7 +17,14 @@ import type { Contact } from "@/actions/communications/contacts";
import type { Segment } from "@/actions/harvest-reach/segments";
import type { CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
type Tab = "campaigns" | "compose" | "templates" | "contacts" | "segments" | "logs" | "analytics";
type Tab = "campaigns" | "compose" | "templates" | "contacts" | "segments" | "logs" | "analytics" | "settings";
const SettingsIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
);
const ComposeIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -132,6 +140,7 @@ export default function CommunicationsPage({
{ value: "segments", label: "Segments", icon: <LayersIcon /> },
{ value: "logs", label: "Logs", icon: <ListIcon /> },
{ value: "analytics", label: "Analytics", icon: <ChartIcon /> },
{ value: "settings", label: "Settings", icon: <SettingsIcon /> },
]}
size="md"
/>
@@ -206,6 +215,13 @@ export default function CommunicationsPage({
</div>
)}
{/* Settings Tab */}
{currentTab === "settings" && !isComposing && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<CommunicationSettingsForm settings={null} brandId={brandId} />
</div>
)}
{/* Compose Tab */}
{currentTab === "compose" && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white">
@@ -32,20 +32,27 @@ const Icons = {
<path d="m9 18 6-6-6-6"/>
</svg>
),
userCircle: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
),
};
const DEBOUNCE_MS = 300;
export default function MatchingCustomersPanel({ brandId, rules }: Props) {
export default function MatchingCustomersPanel({ brandId, rules, onCountChange }: Props) {
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [loading, setLoading] = useState(false);
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);
@@ -56,9 +63,12 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
setPreview(result);
setLoading(false);
setPage(0);
const count = result?.count ?? 0;
setCustomerCount(count);
onCountChange?.(count);
}, DEBOUNCE_MS);
return () => clearTimeout(timerRef.current);
}, [brandId, rules]);
}, [brandId, rules, onCountChange]);
const PAGE_SIZE = 50;
const filtered = preview?.sample_customers ?? [];
@@ -73,105 +83,146 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
const paged = searched.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<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-[var(--admin-text-primary)]">Matching Customers</h3>
{preview && (
<span className="inline-flex items-center rounded-full bg-[var(--admin-accent-light)] px-2.5 py-0.5 text-xs font-semibold text-[var(--admin-accent)]">
{total.toLocaleString()} total
</span>
)}
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden flex flex-col h-full">
{/* Header */}
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-gradient-to-r from-[var(--admin-card)] to-[var(--admin-bg)]">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-lg shadow-violet-500/20">
<svg className="h-4 w-4" 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>
</div>
<div className="flex-1">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Matching Customers</h3>
<p className="text-xs text-[var(--admin-text-muted)]">Preview your segment audience</p>
</div>
{preview && total > 0 && (
<span className="inline-flex items-center rounded-full bg-gradient-to-br from-violet-100 to-violet-50 px-3 py-1 text-xs font-bold text-violet-700 border border-violet-200">
{total.toLocaleString()} total
</span>
)}
</div>
</div>
{rules.filters.length === 0 ? (
<div className="flex-1 flex items-center justify-center py-12">
<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-[var(--admin-text-muted)]">
{Icons.spinner("h-5 w-5 animate-spin")}
<span className="text-sm">Loading</span>
</div>
</div>
) : (
<>
<AdminSearchInput
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onClear={() => setSearch("")}
/>
{paged.length === 0 ? (
<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-[var(--admin-card-hover)] transition-colors"
>
<div className="w-8 h-8 rounded-full bg-[var(--admin-accent-light)] flex items-center justify-center text-xs font-semibold text-[var(--admin-accent)] 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-[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-100 text-blue-700 px-2 py-0.5 text-xs"
>
{tag}
</span>
))}
</div>
)}
{/* Content area */}
<div className="flex-1 p-5 flex flex-col">
{rules.filters.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center py-12">
{/* Decorative background */}
<div className="relative mx-auto mb-5 w-16 h-16">
<div className="absolute inset-0 bg-violet-100 rounded-2xl blur-xl opacity-50" />
<div className="relative flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-50 border-2 border-dashed border-violet-200">
<svg className="h-6 w-6 text-violet-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<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>
</div>
))}
</div>
)}
{searched.length > PAGE_SIZE && (
<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">
<AdminButton
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
icon={Icons.chevronLeft("h-4 w-4")}
iconPosition="left"
>
Prev
</AdminButton>
<AdminButton
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= searched.length}
icon={Icons.chevronRight("h-4 w-4")}
iconPosition="right"
>
Next
</AdminButton>
</div>
<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">
<div className="flex items-center gap-3 text-[var(--admin-text-muted)]">
{Icons.spinner("h-5 w-5 animate-spin")}
<span className="text-sm font-medium">Calculating matches</span>
</div>
</div>
) : (
<>
<div className="mb-4">
<AdminSearchInput
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onClear={() => setSearch("")}
/>
</div>
{paged.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center py-8">
<div className="w-12 h-12 rounded-full bg-stone-100 flex items-center justify-center mx-auto mb-3">
<svg className="h-6 w-6 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
</svg>
</div>
<p className="text-sm text-[var(--admin-text-muted)]">No customers match these filters.</p>
</div>
</div>
) : (
<div className="flex-1 overflow-y-auto flex flex-col gap-1.5 scrollbar-thin">
{paged.map((c, idx) => (
<div
key={c.id}
className="group flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-[var(--admin-card-hover)] transition-all duration-150 border border-transparent hover:border-[var(--admin-border)]"
style={{ animationDelay: `${idx * 20}ms` }}
>
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-[var(--admin-accent-light)] to-[var(--admin-accent)]/20 flex items-center justify-center text-xs font-bold text-[var(--admin-accent)] flex-shrink-0">
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold 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-gradient-to-r from-indigo-50 to-violet-50 text-indigo-700 px-2.5 py-0.5 text-xs font-medium border border-indigo-100"
>
{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
{searched.length > PAGE_SIZE && (
<div className="flex items-center justify-between pt-4 mt-4 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">
<AdminButton
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
icon={Icons.chevronLeft("h-4 w-4")}
iconPosition="left"
>
Prev
</AdminButton>
<AdminButton
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= searched.length}
icon={Icons.chevronRight("h-4 w-4")}
iconPosition="right"
>
Next
</AdminButton>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -179,4 +230,5 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
type Props = {
brandId: string;
rules: SegmentRuleV2;
onCountChange?: (count: number) => void;
};
@@ -6,12 +6,7 @@ import SegmentBuilderPanel from "./SegmentBuilderPanel";
import MatchingCustomersPanel from "./MatchingCustomersPanel";
import SegmentListSidebar from "./SegmentListSidebar";
import SegmentEditModal from "./SegmentEditModal";
import { PageHeader, AdminButton, AdminEmptyState } from "@/components/admin/design-system";
type Props = {
brandId: string;
initialSegments: Segment[];
};
import { AdminButton } from "@/components/admin/design-system";
// Icon components
const LayersIcon = ({ className }: { className: string }) => (
@@ -32,49 +27,66 @@ const PlusIcon = ({ className }: { className: string }) => (
// Empty state component for segments
function SegmentsEmptyState({ onNew }: { onNew: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-100 to-emerald-50">
<svg className="h-10 w-10 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
<div className="flex flex-col items-center justify-center py-20 px-6 text-center">
{/* Decorative background */}
<div className="relative mb-8">
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-24 w-24 rounded-full bg-gradient-to-br from-emerald-100 to-teal-100 blur-2xl opacity-60" />
</div>
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-400 to-emerald-500 shadow-xl shadow-emerald-500/20">
<svg className="h-10 w-10 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
</div>
</div>
<h3 className="text-lg font-semibold text-stone-800">No segments yet</h3>
<p className="mt-2 text-sm text-stone-500 max-w-xs">
<h3 className="text-xl font-bold text-stone-800">No segments yet</h3>
<p className="mt-3 text-sm text-stone-500 max-w-sm leading-relaxed">
Create segments to organize your contacts and send targeted campaigns to specific audiences.
</p>
<AdminButton onClick={onNew} className="mt-6" icon={<PlusIcon className="w-4 h-4" />}>
<AdminButton
onClick={onNew}
className="mt-8"
icon={<PlusIcon className="w-4 h-4" />}
>
Create Your First Segment
</AdminButton>
</div>
);
}
// Active segment header
function ActiveSegmentHeader({ segment, onClear }: { segment: Segment | null; onClear: () => void }) {
// Active segment header with refined design
function ActiveSegmentHeader({ segment, onClear, customerCount }: { segment: Segment | null; onClear: () => void; customerCount?: number }) {
if (!segment) return null;
return (
<div className="flex items-center justify-between p-4 bg-emerald-50 rounded-xl border border-emerald-200 mb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500 text-white">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div className="flex items-center justify-between p-4 rounded-2xl bg-gradient-to-r from-emerald-50 to-teal-50 border border-emerald-200 mb-6">
<div className="flex items-center gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-lg shadow-emerald-500/20">
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
</div>
<div>
<p className="text-sm font-semibold text-stone-800">{segment.name}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-bold text-stone-800">{segment.name}</p>
{customerCount !== undefined && customerCount > 0 && (
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
{customerCount.toLocaleString()} customers
</span>
)}
</div>
{segment.description && (
<p className="text-xs text-stone-500">{segment.description}</p>
<p className="text-xs text-stone-500 mt-0.5">{segment.description}</p>
)}
</div>
</div>
<button
onClick={onClear}
className="text-xs font-medium text-stone-500 hover:text-stone-700 transition-colors"
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"
>
Clear selection
</button>
@@ -90,6 +102,7 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
filters: [],
});
const [showEditModal, setShowEditModal] = useState(false);
const [customerCount, setCustomerCount] = useState<number | undefined>(undefined);
function handleSegmentSelect(segment: Segment) {
setActiveSegment(segment);
@@ -105,11 +118,15 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
function handleClearSelection() {
setActiveSegment(null);
setCurrentRules({ combinator: "AND", filters: [] });
setCustomerCount(undefined);
}
function handleRulesChange(rules: SegmentRuleV2) {
setCurrentRules(rules);
// Only clear active segment if we're editing the rules, not just viewing
}
function handleCustomerCount(count: number) {
setCustomerCount(count);
}
async function handleSaveSegment(name: string, description: string) {
@@ -146,12 +163,17 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
const hasFilters = currentRules.filters.length > 0;
return (
<div className="p-4 sm:p-6 space-y-4">
<PageHeader
icon={<LayersIcon className="w-5 h-5" />}
title="Segment Builder"
subtitle="Build filters to define your audience, then save and reuse the segment."
/>
<div className="p-6 space-y-6">
{/* Page Header */}
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-lg shadow-emerald-500/20">
<LayersIcon className="h-6 w-6" />
</div>
<div>
<h1 className="text-xl font-bold text-[var(--admin-text-primary)]">Segment Builder</h1>
<p className="text-sm text-[var(--admin-text-muted)]">Build filters to define your audience, then save and reuse the segment.</p>
</div>
</div>
{/* Show empty state if no segments */}
{segments.length === 0 ? (
@@ -159,10 +181,10 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
) : (
<>
{/* Active segment indicator */}
<ActiveSegmentHeader segment={activeSegment} onClear={handleClearSelection} />
<ActiveSegmentHeader segment={activeSegment} onClear={handleClearSelection} customerCount={customerCount} />
{/* Main layout */}
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex flex-col lg:flex-row gap-6">
{/* Left sidebar */}
<div className="lg:w-72 flex-shrink-0">
<SegmentListSidebar
@@ -175,7 +197,7 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
</div>
{/* Main content: builder + preview */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-6">
<SegmentBuilderPanel
brandId={brandId}
rules={currentRules}
@@ -183,7 +205,11 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
onSave={() => setShowEditModal(true)}
hasActiveSegment={!!activeSegment}
/>
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
<MatchingCustomersPanel
brandId={brandId}
rules={currentRules}
onCountChange={handleCustomerCount}
/>
</div>
</div>
</>
@@ -199,4 +225,9 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
)}
</div>
);
}
}
type Props = {
brandId: string;
initialSegments: Segment[];
};
@@ -7,7 +7,7 @@ import {
type SegmentFilterType,
type SegmentFilterParams,
} from "@/actions/harvest-reach/segments";
import { AdminButton, AdminSearchInput } from "@/components/admin/design-system";
import { AdminButton } from "@/components/admin/design-system";
// Icon components
const Icons = {
@@ -16,6 +16,24 @@ const Icons = {
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
),
layers: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
),
filter: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
),
};
type Props = {
@@ -26,14 +44,14 @@ type Props = {
hasActiveSegment: boolean;
};
const FILTER_TYPES: { value: SegmentFilterType; label: string }[] = [
{ value: "all_customers", label: "All Customers" },
{ value: "stop", label: "Past Stop" },
{ value: "upcoming_stop", label: "Upcoming Stop" },
{ value: "product", label: "Product Purchased" },
{ value: "zip_code", label: "ZIP / City" },
{ value: "customer_history", label: "Order History" },
{ value: "tags", label: "Tags" },
const FILTER_TYPES: { value: SegmentFilterType; label: string; icon: string; color: string }[] = [
{ value: "all_customers", label: "All Customers", icon: "users", color: "bg-slate-500" },
{ value: "stop", label: "Past Stop", icon: "map-pin", color: "bg-amber-500" },
{ value: "upcoming_stop", label: "Upcoming Stop", icon: "calendar", color: "bg-emerald-500" },
{ value: "product", label: "Product Purchased", icon: "package", color: "bg-violet-500" },
{ value: "zip_code", label: "ZIP / City", icon: "map", color: "bg-cyan-500" },
{ value: "customer_history", label: "Order History", icon: "clock", color: "bg-rose-500" },
{ value: "tags", label: "Tags", icon: "tag", color: "bg-indigo-500" },
];
const ORDER_HISTORY_OPTIONS = [
@@ -53,6 +71,8 @@ function emptyFilter(type: SegmentFilterType): SegmentFilter {
}
export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave, hasActiveSegment }: Props) {
const [hoveredFilter, setHoveredFilter] = useState<string | null>(null);
const setCombinator = useCallback((combinator: "AND" | "OR") => {
onChange({ ...rules, combinator });
}, [rules, onChange]);
@@ -73,35 +93,57 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
}
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Filter Rules</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--admin-text-muted)]">Match</span>
<div className="flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card)] p-0.5">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
{/* Card header with icon */}
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-gradient-to-r from-[var(--admin-card)] to-[var(--admin-bg)]">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--admin-accent)] to-[var(--admin-accent-dark,var(--admin-accent))] text-white shadow-lg shadow-[var(--admin-accent)]/20">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
</div>
<div>
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Filter Rules</h3>
<p className="text-xs text-[var(--admin-text-muted)]">Define which customers match</p>
</div>
</div>
</div>
{/* Combinator toggle */}
<div className="px-5 py-3 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<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
key={c}
onClick={() => setCombinator(c)}
className={`px-3 py-1 text-xs font-semibold rounded-md transition-colors ${
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all duration-200 ${
rules.combinator === c
? "bg-[var(--admin-accent)] text-white"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
? "bg-gradient-to-br from-[var(--admin-accent)] to-[var(--admin-accent-dark,var(--admin-accent))] text-white shadow-md"
: "text-[var(--admin-text-muted)] hover:bg-stone-100"
}`}
>
{c}
</button>
))}
</div>
<span className="text-xs text-[var(--admin-text-muted)]">of the following</span>
<span className="text-xs font-medium text-[var(--admin-text-muted)] uppercase tracking-wide">of the following</span>
</div>
</div>
{/* Filter blocks */}
<div className="flex flex-col gap-3">
<div className="p-5 space-y-4">
{rules.filters.length === 0 && (
<div className="py-8 text-center">
<div className="py-12 text-center">
<div className="relative mx-auto mb-4 w-16 h-16">
<div className="absolute inset-0 bg-stone-100 rounded-2xl blur-xl" />
<div className="relative flex h-16 w-16 items-center justify-center rounded-2xl bg-stone-100 border-2 border-dashed border-stone-300">
<svg className="h-6 w-6 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
</div>
</div>
<p className="text-sm text-[var(--admin-text-muted)]">
No filters yet. Add a filter below to start building your segment.
</p>
@@ -109,7 +151,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
)}
{rules.filters.map((filter, index) => (
<FilterBlock
key={index}
key={`${filter.type}-${index}`}
brandId={brandId}
filter={filter}
onChange={(updates) => updateFilter(index, updates)}
@@ -119,24 +161,46 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
</div>
{/* Add filter chips */}
<div className="flex gap-2 flex-wrap">
{FILTER_TYPES.map((ft) => (
<button
key={ft.value}
onClick={() => addFilter(ft.value)}
className="px-3 py-1.5 text-xs font-medium rounded-full border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent-light)] hover:border-[var(--admin-accent)] hover:text-[var(--admin-accent)] transition-colors"
>
+ {ft.label}
</button>
))}
<div className="px-5 pb-5">
<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
key={ft.value}
onClick={() => addFilter(ft.value)}
onMouseEnter={() => setHoveredFilter(ft.value)}
onMouseLeave={() => setHoveredFilter(null)}
className={`group flex items-center gap-2 px-3.5 py-2 rounded-xl border text-sm font-medium transition-all duration-200 ${
hoveredFilter === ft.value
? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-white text-[var(--admin-text-secondary)] hover:border-stone-300 hover:bg-stone-50"
}`}
>
<span className={`flex h-5 w-5 items-center justify-center rounded-md text-white ${ft.color}`}>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</span>
{ft.label}
</button>
))}
</div>
</div>
{/* Save button */}
<div className="pt-1 border-t border-[var(--admin-border)]">
<div className="px-5 py-4 border-t border-[var(--admin-border)] bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-card)]">
<AdminButton
onClick={onSave}
disabled={rules.filters.length === 0}
fullWidth
icon={
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
}
>
{hasActiveSegment ? "Update Segment" : "Save Segment"}
</AdminButton>
@@ -180,30 +244,73 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
});
}
const filterConfig = FILTER_TYPES.find(f => f.value === filter.type);
const filterColor = filterConfig?.color ?? "bg-stone-500";
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card)] p-4 flex flex-col gap-3">
<div className="rounded-xl border border-[var(--admin-border)] bg-gradient-to-br from-[var(--admin-card)] to-[var(--admin-bg)] p-4 flex flex-col gap-4 transition-all duration-200 hover:shadow-md">
{/* Filter header with type selector */}
<div className="flex items-center justify-between">
<select
value={filter.type}
onChange={(e) =>
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
}
className="text-sm font-medium border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 bg-white text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
>
{FILTER_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>{ft.label}</option>
))}
</select>
<div className="flex items-center gap-3">
<span className={`flex h-8 w-8 items-center justify-center rounded-lg text-white ${filterColor}`}>
{filter.type === "all_customers" && (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
)}
{filter.type === "stop" && (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
)}
{filter.type === "upcoming_stop" && (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
)}
{filter.type === "product" && (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M16.5 9.4 7.55 4.24"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" y1="22" x2="12" y2="12"/>
</svg>
)}
{filter.type === "zip_code" && (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
)}
{filter.type === "customer_history" && (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
)}
{filter.type === "tags" && (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
)}
</span>
<select
value={filter.type}
onChange={(e) =>
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
}
className="text-sm font-semibold border border-[var(--admin-border)] rounded-lg px-3 py-1.5 bg-white text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)] cursor-pointer"
>
{FILTER_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>{ft.label}</option>
))}
</select>
</div>
<button
onClick={onRemove}
className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--admin-text-muted)] hover:text-red-500 transition-colors"
className="p-2 rounded-lg hover:bg-red-50 text-[var(--admin-text-muted)] hover:text-red-500 transition-colors"
aria-label="Remove filter"
>
{Icons.x("w-4 h-4")}
</button>
</div>
<div className="flex flex-col gap-2.5">
<div className="flex flex-col gap-3 pl-11">
{/* Stop / Upcoming Stop */}
{(filter.type === "stop" || filter.type === "upcoming_stop") && (
<>
@@ -211,26 +318,32 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
value={filter.params.stop_id ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, stop_id: e.target.value } })}
onMouseEnter={() => loadStops(filter.type === "stop")}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
>
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>{s.city} {s.date}</option>
))}
</select>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
value={filter.params.date_from ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<input
type="date"
value={filter.params.date_to ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-[var(--admin-text-muted)] mb-1 block">From</label>
<input
type="date"
value={filter.params.date_from ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
</div>
<div>
<label className="text-xs text-[var(--admin-text-muted)] mb-1 block">To</label>
<input
type="date"
value={filter.params.date_to ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
</div>
</div>
</>
)}
@@ -245,7 +358,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
if (e.target.value) loadProducts();
}}
onMouseEnter={loadProducts}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
>
<option value="">Select a product</option>
{products.map((p) => (
@@ -253,16 +366,16 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--admin-text-muted)]">In the last</label>
<label className="text-xs text-[var(--admin-text-muted)] whitespace-nowrap">In the last</label>
<input
type="number"
min={1}
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
<span className="text-xs text-[var(--admin-text-muted)] whitespace-nowrap">days</span>
</div>
</>
)}
@@ -282,43 +395,43 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
},
})
}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
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
type="text"
placeholder="City (optional)"
value={filter.params.city ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, city: e.target.value } })}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
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)]"
/>
</>
)}
{/* Customer History */}
{filter.type === "customer_history" && (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<select
value={filter.params.order_history ?? "all"}
onChange={(e) =>
onChange({ params: { ...filter.params, order_history: e.target.value as "all" | "first_order" | "repeat" } })
}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
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)]"
>
{ORDER_HISTORY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--admin-text-muted)]">In the last</label>
<label className="text-xs text-[var(--admin-text-muted)] whitespace-nowrap">In the last</label>
<input
type="number"
min={1}
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
<span className="text-xs text-[var(--admin-text-muted)] whitespace-nowrap">days</span>
</div>
</div>
)}
@@ -337,13 +450,13 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
},
})
}
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
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)]"
/>
)}
{/* All customers */}
{filter.type === "all_customers" && (
<p className="text-xs text-[var(--admin-text-muted)]">Matches all customers in your brand.</p>
<p className="text-xs text-[var(--admin-text-muted)] bg-stone-50 rounded-lg px-3 py-2">Matches all customers in your brand.</p>
)}
</div>
</div>