mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-25 16:03:31 +00:00
feat: polish campaign settings controls
Refresh campaign ordering, ramp, lead-flow, CC/BCC, and ESP matching settings with shared controls, clearer labels, and a more accurate provider coverage view.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// Contact ordering settings — how the campaign picks who to send to next.
|
||||
// On-theme: slate/sky, rounded-md, 12.5px base, house primitives.
|
||||
// On-theme: reuses the shared OptionSelect / Segmented / SettingRow primitives
|
||||
// so it matches the rest of the settings page.
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -19,21 +20,18 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckIcon, ChevronDownIcon, GripVerticalIcon, InfoIcon } from "lucide-react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
|
||||
import { Label, TextInput } from "@/components/ui/field";
|
||||
import useClickOutside from "@/hooks/useClickOutside";
|
||||
import useFlipPlacement from "@/hooks/useFlipPlacement";
|
||||
import { Segmented, SettingRow } from "./components/CampaignPreferenceBoolBox";
|
||||
import { OptionSelect, Segmented, SettingRow } from "./components/CampaignPreferenceBoolBox";
|
||||
|
||||
const ORDER_OPTIONS = [
|
||||
{ value: "created_at", label: "Creation time", description: "Order by when contacts were added" },
|
||||
{ value: "email", label: "Email", description: "Alphabetical by email address" },
|
||||
{ value: "name", label: "Name", description: "Alphabetical by first, then last name" },
|
||||
{ value: "custom_field", label: "Custom field", description: "Order by a custom contact field" },
|
||||
{ value: "manual", label: "Manual", description: "Drag and drop to set custom order" },
|
||||
] as const;
|
||||
const ORDER_OPTIONS: { value: Campaign["contact_order_by"]; label: string; hint: string }[] = [
|
||||
{ value: "created_at", label: "Creation time", hint: "When the contact was added" },
|
||||
{ value: "email", label: "Email", hint: "Alphabetical by email address" },
|
||||
{ value: "name", label: "Name", hint: "Alphabetical by first, then last name" },
|
||||
{ value: "custom_field", label: "Custom field", hint: "Order by a custom contact field" },
|
||||
{ value: "manual", label: "Manual", hint: "Drag and drop to set the order" },
|
||||
];
|
||||
|
||||
interface ContactItem {
|
||||
id: string;
|
||||
@@ -70,9 +68,7 @@ function SortableContact({ contact, index }: { contact: ContactItem; index: numb
|
||||
>
|
||||
<GripVerticalIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums w-5 shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums w-5 shrink-0">{index + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12.5px] font-medium text-slate-900 truncate">
|
||||
{contact.firstName || contact.lastName
|
||||
@@ -108,16 +104,6 @@ export default function CampaignContactOrder({
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const selectedOption =
|
||||
ORDER_OPTIONS.find((o) => o.value === newCampaign.contact_order_by) || ORDER_OPTIONS[0];
|
||||
|
||||
// Order-by dropdown (house framer-motion pattern + flip placement).
|
||||
const [orderOpen, setOrderOpen] = useState(false);
|
||||
const orderRef = useRef<HTMLDivElement>(null);
|
||||
const orderTriggerRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(orderRef, () => setOrderOpen(false));
|
||||
const orderPlacement = useFlipPlacement(orderTriggerRef, orderOpen, 300);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -138,64 +124,17 @@ export default function CampaignContactOrder({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Order By */}
|
||||
<div className="space-y-5">
|
||||
{/* Order by — same card picker as the rest of settings */}
|
||||
<div>
|
||||
<Label>Order contacts by</Label>
|
||||
<div ref={orderRef} className="relative w-full max-w-[280px]">
|
||||
<div ref={orderTriggerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOrderOpen((o) => !o)}
|
||||
className="flex h-8 w-full items-center justify-between rounded-md border border-slate-200 bg-white px-2.5 text-[12.5px] text-slate-800 transition-colors hover:border-slate-300 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
<span>{selectedOption.label}</span>
|
||||
<ChevronDownIcon
|
||||
className={`w-3.5 h-3.5 shrink-0 text-slate-400 transition-transform ${orderOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{orderOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: orderPlacement === "top" ? 4 : -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: orderPlacement === "top" ? 4 : -4 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className={`absolute left-0 right-0 z-30 overflow-hidden rounded-md border border-slate-200 bg-white py-1 shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] ${
|
||||
orderPlacement === "top" ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
{ORDER_OPTIONS.map((option) => {
|
||||
const sel = newCampaign.contact_order_by === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewCampaign((prev) => ({ ...prev, contact_order_by: option.value }));
|
||||
setOrderOpen(false);
|
||||
}}
|
||||
className="flex w-full items-start gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-full border ${
|
||||
sel ? "border-sky-600 bg-sky-600" : "border-slate-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
{sel && <CheckIcon className="w-2 h-2 text-white" />}
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-[12.5px] font-medium text-slate-900">{option.label}</span>
|
||||
<span className="text-[11px] text-slate-400">{option.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<OptionSelect
|
||||
aria-label="Order contacts by"
|
||||
cols={2}
|
||||
value={newCampaign.contact_order_by}
|
||||
onChange={(v) => setNewCampaign((prev) => ({ ...prev, contact_order_by: v }))}
|
||||
options={ORDER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Direction (not for manual) */}
|
||||
@@ -210,9 +149,7 @@ export default function CampaignContactOrder({
|
||||
control={
|
||||
<Segmented
|
||||
value={newCampaign.contact_order_dir}
|
||||
onChange={(v) =>
|
||||
setNewCampaign((prev) => ({ ...prev, contact_order_dir: v }))
|
||||
}
|
||||
onChange={(v) => setNewCampaign((prev) => ({ ...prev, contact_order_dir: v }))}
|
||||
options={[
|
||||
{ value: "asc", label: "Ascending" },
|
||||
{ value: "desc", label: "Descending" },
|
||||
@@ -229,9 +166,7 @@ export default function CampaignContactOrder({
|
||||
<TextInput
|
||||
value={newCampaign.contact_order_field || ""}
|
||||
placeholder="e.g. company_size, priority"
|
||||
onChange={(v) =>
|
||||
setNewCampaign((prev) => ({ ...prev, contact_order_field: v }))
|
||||
}
|
||||
onChange={(v) => setNewCampaign((prev) => ({ ...prev, contact_order_field: v }))}
|
||||
className="w-full max-w-[280px]"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-1.5">
|
||||
@@ -245,11 +180,7 @@ export default function CampaignContactOrder({
|
||||
<div>
|
||||
<Label>Drag to reorder contacts</Label>
|
||||
{orderedContacts.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={orderedContacts.map((c) => c.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
@@ -263,25 +194,12 @@ export default function CampaignContactOrder({
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="text-center py-8 border border-dashed border-slate-200 rounded-md">
|
||||
<p className="text-[12.5px] text-slate-700 font-medium">
|
||||
No contacts in this campaign
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
Add contacts to enable manual ordering.
|
||||
</p>
|
||||
<p className="text-[12.5px] text-slate-700 font-medium">No contacts in this campaign</p>
|
||||
<p className="text-[11px] text-slate-400 mt-1">Add contacts to enable manual ordering.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex items-start gap-2 rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2.5">
|
||||
<InfoIcon className="w-3.5 h-3.5 text-slate-400 mt-0.5 shrink-0" />
|
||||
<p className="text-[11.5px] text-slate-600 leading-relaxed">
|
||||
When processing sends, contacts are selected in this order — it determines who
|
||||
receives emails first while the campaign is running.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// icon and anchor. On-theme: slate/sky, rounded-md, 12.5px base, NumberInput
|
||||
// for every number.
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import type Campaign from "@/lib/api/models/app/campaigns/Campaign";
|
||||
import { Label, NumberInput } from "@/components/ui/field";
|
||||
@@ -13,7 +14,64 @@ import EspCoveragePanel from "./EspCoveragePanel";
|
||||
|
||||
type SetCampaign = React.Dispatch<React.SetStateAction<Campaign>>;
|
||||
|
||||
/** Rotation — distribution mode + per-mailbox daily ramp-up. */
|
||||
/** RampPreview — a small day-by-day projection of the per-mailbox ramp curve
|
||||
* (Day 1 = start, +increment each day, capped at the ceiling). Highlights the
|
||||
* bar nearest today's live cap (ramp_level). */
|
||||
function RampPreview({
|
||||
start,
|
||||
increment,
|
||||
ceiling,
|
||||
level,
|
||||
}: {
|
||||
start: number;
|
||||
increment: number;
|
||||
ceiling: number;
|
||||
level: number;
|
||||
}) {
|
||||
if (start <= 0 || ceiling <= 0 || start > ceiling) return null;
|
||||
const days: number[] = [];
|
||||
let v = start;
|
||||
let guard = 0;
|
||||
while (guard < 60) {
|
||||
const capped = Math.min(v, ceiling);
|
||||
days.push(capped);
|
||||
if (capped >= ceiling) break;
|
||||
v += Math.max(1, increment);
|
||||
guard++;
|
||||
}
|
||||
const MAX_BARS = 14;
|
||||
const shown = days.slice(0, MAX_BARS);
|
||||
const more = days.length - shown.length;
|
||||
const todayIdx = days.findIndex((d) => d >= level);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-1 h-14">
|
||||
{shown.map((val, i) => {
|
||||
const today = i === todayIdx;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
title={`Day ${i + 1}: ${val}/day`}
|
||||
className={`flex-1 min-w-[4px] rounded-t-sm ${today ? "bg-sky-500" : "bg-sky-200"}`}
|
||||
style={{ height: `${Math.max(10, (val / ceiling) * 100)}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5 text-[10px] text-slate-400">
|
||||
<span>
|
||||
Day 1 · {start}/day
|
||||
</span>
|
||||
<span>
|
||||
{more > 0 ? `+${more} more · ` : ""}Day {days.length} · {ceiling}/day, then steady
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inbox rotation — distribution mode + per-mailbox daily ramp-up. */
|
||||
export function RotationRampSection({
|
||||
newCampaign,
|
||||
setNewCampaign,
|
||||
@@ -24,32 +82,48 @@ export function RotationRampSection({
|
||||
const rampInvalid = newCampaign.ramp_enabled && newCampaign.ramp_start > newCampaign.ramp_ceiling;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingRow
|
||||
title="Rotation"
|
||||
description="How sends are distributed across the resolved mailboxes."
|
||||
stack
|
||||
control={
|
||||
<OptionSelect
|
||||
aria-label="Rotation mode"
|
||||
cols={3}
|
||||
value={newCampaign.rotation_mode}
|
||||
onChange={(v) => setNewCampaign((bef) => ({ ...bef, rotation_mode: v }))}
|
||||
options={[
|
||||
{ value: "weighted", label: "Weighted", hint: "Favor mailboxes with more headroom" },
|
||||
{ value: "round_robin", label: "Round-robin", hint: "Cycle evenly through every mailbox" },
|
||||
{
|
||||
value: "least_recently_used",
|
||||
label: "Least-recently-used",
|
||||
hint: "Pick the mailbox idle the longest",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* Inbox rotation */}
|
||||
<div>
|
||||
<SettingRow
|
||||
title="Inbox rotation"
|
||||
description="Send this campaign from several mailboxes so no single inbox sends too much — better deliverability and more total volume."
|
||||
stack
|
||||
control={
|
||||
<OptionSelect
|
||||
aria-label="Inbox rotation"
|
||||
cols={1}
|
||||
value={newCampaign.rotation_mode}
|
||||
onChange={(v) => setNewCampaign((bef) => ({ ...bef, rotation_mode: v }))}
|
||||
options={[
|
||||
{
|
||||
value: "least_recently_used",
|
||||
label: "Even spacing",
|
||||
hint: "Recommended — always picks the mailbox that's been idle longest, for the most natural send pattern.",
|
||||
},
|
||||
{
|
||||
value: "round_robin",
|
||||
label: "Round-robin",
|
||||
hint: "Cycles through your mailboxes in order (A → B → C → A). A simple, even split.",
|
||||
},
|
||||
{
|
||||
value: "weighted",
|
||||
label: "Weighted",
|
||||
hint: "Sends more from your healthiest, higher-limit mailboxes.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-2 leading-relaxed">
|
||||
Each mailbox stays within its own daily limit, and follow-ups always come from the mailbox that
|
||||
sent the first email — so every thread stays consistent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Daily ramp-up */}
|
||||
<SettingRow
|
||||
title="Daily ramp-up"
|
||||
description="Gradually raise the per-mailbox send volume each day instead of starting at full limit."
|
||||
description="Gradually raise each mailbox's daily volume instead of starting at full limit — a smooth growth curve protects deliverability. (Separate from warmup.)"
|
||||
control={
|
||||
<Toggle
|
||||
id="campaign-pref-ramp"
|
||||
@@ -73,13 +147,13 @@ export function RotationRampSection({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Increment</Label>
|
||||
<Label>Increase by</Label>
|
||||
<NumberInput
|
||||
value={newCampaign.ramp_increment}
|
||||
min={1}
|
||||
max={100}
|
||||
onChange={(v) => setNewCampaign((bef) => ({ ...bef, ramp_increment: v }))}
|
||||
suffix="+ / day"
|
||||
suffix="/ day"
|
||||
className="w-36"
|
||||
/>
|
||||
</div>
|
||||
@@ -99,11 +173,18 @@ export function RotationRampSection({
|
||||
<span className="font-mono tabular-nums">{newCampaign.ramp_level}</span>
|
||||
</span>
|
||||
</div>
|
||||
{rampInvalid && (
|
||||
{rampInvalid ? (
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-rose-500">
|
||||
<AlertCircleIcon className="w-3.5 h-3.5 shrink-0" />
|
||||
Start must be less than or equal to the ceiling.
|
||||
</p>
|
||||
) : (
|
||||
<RampPreview
|
||||
start={newCampaign.ramp_start}
|
||||
increment={newCampaign.ramp_increment}
|
||||
ceiling={newCampaign.ramp_ceiling}
|
||||
level={newCampaign.ramp_level}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -161,19 +242,19 @@ export function LeadFlowSection({
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label>New leads per day</Label>
|
||||
<Label>New leads contacted per day</Label>
|
||||
<NumberInput
|
||||
value={newCampaign.max_new_leads_per_day}
|
||||
min={0}
|
||||
max={10000}
|
||||
onChange={(v) => setNewCampaign((bef) => ({ ...bef, max_new_leads_per_day: v }))}
|
||||
suffix="leads / day"
|
||||
suffix={newCampaign.max_new_leads_per_day === 0 ? "= no limit" : "leads / day"}
|
||||
className="w-48"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-1.5">
|
||||
{newCampaign.max_new_leads_per_day === 0
|
||||
? "0 = unlimited new leads per day."
|
||||
: "Caps how many fresh leads enter the campaign each day."}
|
||||
<p className="text-[11px] text-slate-400 mt-1.5 leading-relaxed">
|
||||
Limits how many brand-new leads get their <span className="text-slate-500">very first</span>{" "}
|
||||
email from this campaign each day, so a fresh list goes out as a steady trickle instead of one
|
||||
big blast. Follow-ups to people already in the campaign still send and don't count against this.
|
||||
</p>
|
||||
</div>
|
||||
<SettingRow
|
||||
@@ -202,8 +283,10 @@ export function LeadFlowSection({
|
||||
);
|
||||
}
|
||||
|
||||
/** CC & BCC — extra recipients copied on every send. (Tracking domain is a
|
||||
* per-mailbox setting and lives on the mailbox, not the campaign.) */
|
||||
/** CC & BCC — extra recipients copied on every send. Gmail-style: the fields
|
||||
* stay hidden behind "Add CC / Add BCC" until needed, so the section is calm
|
||||
* by default. (Tracking domain is a per-mailbox setting and lives on the
|
||||
* mailbox, not the campaign.) */
|
||||
export function CcBccSection({
|
||||
newCampaign,
|
||||
setNewCampaign,
|
||||
@@ -211,28 +294,81 @@ export function CcBccSection({
|
||||
newCampaign: Campaign;
|
||||
setNewCampaign: SetCampaign;
|
||||
}) {
|
||||
const [showCc, setShowCc] = useState(newCampaign.cc.length > 0);
|
||||
const [showBcc, setShowBcc] = useState(newCampaign.bcc.length > 0);
|
||||
|
||||
const addBtn =
|
||||
"inline-flex items-center h-7 px-2.5 rounded-md border border-dashed border-slate-300 text-slate-500 hover:border-slate-400 hover:text-slate-700 text-[12px] font-medium transition-colors";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label>CC recipients</Label>
|
||||
<EmailListInput
|
||||
values={newCampaign.cc}
|
||||
onChange={(v) => setNewCampaign((bef) => ({ ...bef, cc: v }))}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-1.5">
|
||||
A copy of each email goes to these addresses (visible to recipients).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>BCC recipients</Label>
|
||||
<EmailListInput
|
||||
values={newCampaign.bcc}
|
||||
onChange={(v) => setNewCampaign((bef) => ({ ...bef, bcc: v }))}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-1.5">
|
||||
A blind copy goes to these addresses (hidden from recipients).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{showCc && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Label className="mb-0">CC</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCc(false);
|
||||
setNewCampaign((bef) => ({ ...bef, cc: [] }));
|
||||
}}
|
||||
className="text-[11px] text-slate-400 hover:text-rose-500 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<EmailListInput
|
||||
values={newCampaign.cc}
|
||||
onChange={(v) => setNewCampaign((bef) => ({ ...bef, cc: v }))}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-1.5">
|
||||
Visible to recipients. Paste or type several — separate with a comma, space, or Enter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBcc && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Label className="mb-0">BCC</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowBcc(false);
|
||||
setNewCampaign((bef) => ({ ...bef, bcc: [] }));
|
||||
}}
|
||||
className="text-[11px] text-slate-400 hover:text-rose-500 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<EmailListInput
|
||||
values={newCampaign.bcc}
|
||||
onChange={(v) => setNewCampaign((bef) => ({ ...bef, bcc: v }))}
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-1.5">Hidden from recipients.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!showCc || !showBcc) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!showCc && (
|
||||
<button type="button" onClick={() => setShowCc(true)} className={addBtn}>
|
||||
+ Add CC
|
||||
</button>
|
||||
)}
|
||||
{!showBcc && (
|
||||
<button type="button" onClick={() => setShowBcc(true)} className={addBtn}>
|
||||
+ Add BCC
|
||||
</button>
|
||||
)}
|
||||
{!showCc && !showBcc && (
|
||||
<span className="text-[11px] text-slate-400">
|
||||
Optionally copy extra addresses on every email this campaign sends.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// EspCoveragePanel — the visual "provider matching" map under the ESP matching
|
||||
// control. A bipartite diagram: recipient providers on the left, this
|
||||
// campaign's mailboxes on the right, with connector lines showing which mailbox
|
||||
// actually serves which recipient — mode-aware, no new API.
|
||||
// EspCoveragePanel — the "provider matching" visual under the ESP matching
|
||||
// control. For each recipient provider it shows which of this campaign's
|
||||
// mailboxes serve it, ranked so the difference between a true same-provider
|
||||
// match and an SMTP wildcard is obvious. No connector lines, no new API.
|
||||
//
|
||||
// Grounded in the scheduler (internal/scheduler/campaign_scheduler.go):
|
||||
// • recipient provider is derived from the domain → "gmail" | "outlook" |
|
||||
// unknown (everything else).
|
||||
// • a mailbox matches a recipient when: mailbox is smtp_imap (wildcard), OR
|
||||
// the recipient is unknown (wildcard), OR provider equality.
|
||||
// • a mailbox matches a recipient when: mailbox is smtp_imap (WILDCARD —
|
||||
// serves any provider, even in strict), OR the recipient is unknown
|
||||
// (wildcard), OR provider equality (gmail↔gmail, outlook↔outlook).
|
||||
// • strict + no matching mailbox → the recipient is DEFERRED (held to the next
|
||||
// slot), never sent cross-provider and never skipped.
|
||||
// • prefer + no matching mailbox → falls back to a cross-provider mailbox.
|
||||
@@ -20,6 +21,7 @@ import useEmails from "@/lib/api/hooks/app/emails/useEmails";
|
||||
import ProviderLogo from "./ProviderLogo";
|
||||
|
||||
type Mode = "off" | "prefer" | "strict";
|
||||
type Status = "ok" | "warn" | "blocked" | "any";
|
||||
|
||||
const LABEL: Record<string, string> = {
|
||||
gmail: "Google",
|
||||
@@ -28,14 +30,6 @@ const LABEL: Record<string, string> = {
|
||||
other: "Other domains",
|
||||
};
|
||||
|
||||
// Fixed node geometry so connector coordinates are deterministic (no measuring).
|
||||
const NODE_H = 52;
|
||||
const GAP = 12;
|
||||
const CENTERS = [NODE_H / 2, NODE_H + GAP + NODE_H / 2, 2 * (NODE_H + GAP) + NODE_H / 2];
|
||||
const TOTAL_H = 3 * NODE_H + 2 * GAP;
|
||||
|
||||
const STROKE = { match: "#10b981", fallback: "#f59e0b", neutral: "#cbd5e1" } as const;
|
||||
|
||||
export default function EspCoveragePanel({
|
||||
mode,
|
||||
emailTags,
|
||||
@@ -90,154 +84,198 @@ export default function EspCoveragePanel({
|
||||
);
|
||||
}
|
||||
|
||||
const mailboxes = [
|
||||
{ key: "gmail", count: counts.gmail },
|
||||
{ key: "outlook", count: counts.outlook },
|
||||
{ key: "smtp_imap", count: counts.smtp },
|
||||
];
|
||||
const recipients = ["gmail", "outlook", "other"];
|
||||
const poolChips = (
|
||||
[
|
||||
{ key: "gmail", count: counts.gmail },
|
||||
{ key: "outlook", count: counts.outlook },
|
||||
{ key: "smtp_imap", count: counts.smtp },
|
||||
] as const
|
||||
).filter((p) => p.count > 0);
|
||||
|
||||
const isPrimary = (r: string, m: string) => r === "other" || m === "smtp_imap" || r === m;
|
||||
|
||||
const edges: { ri: number; mi: number; tone: keyof typeof STROKE; dashed: boolean }[] = [];
|
||||
recipients.forEach((r, ri) => {
|
||||
mailboxes.forEach((m, mi) => {
|
||||
if (m.count === 0) return;
|
||||
const primary = isPrimary(r, m.key);
|
||||
if (mode === "off") edges.push({ ri, mi, tone: "neutral", dashed: false });
|
||||
else if (mode === "strict") {
|
||||
if (primary) edges.push({ ri, mi, tone: "match", dashed: false });
|
||||
} else {
|
||||
edges.push({ ri, mi, tone: primary ? "match" : "fallback", dashed: !primary });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const recStatus = (r: string): "ok" | "warn" | "blocked" => {
|
||||
if (r === "other" || mode === "off") return "ok";
|
||||
const samePrimary = (r === "gmail" ? counts.gmail : counts.outlook) > 0 || counts.smtp > 0;
|
||||
if (samePrimary) return "ok";
|
||||
if (mode === "strict") return "blocked";
|
||||
return counts.total > 0 ? "warn" : "blocked";
|
||||
};
|
||||
const statusLabel = { ok: "Covered", warn: "Fallback", blocked: "Deferred" } as const;
|
||||
const statusText = { ok: "text-emerald-600", warn: "text-amber-600", blocked: "text-rose-600" } as const;
|
||||
const nodeBorder = {
|
||||
ok: "border-slate-200",
|
||||
warn: "border-amber-200 bg-amber-50/40",
|
||||
blocked: "border-rose-200 bg-rose-50/40",
|
||||
} as const;
|
||||
// Pool is all wildcard mailboxes (SMTP only) → even strict can't narrow.
|
||||
const onlyWildcard = counts.smtp > 0 && counts.gmail === 0 && counts.outlook === 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/50 p-4 space-y-3.5">
|
||||
<div className="flex items-center justify-between text-[10px] uppercase tracking-[0.14em] text-slate-400">
|
||||
<span>Recipients</span>
|
||||
<span>Your mailboxes</span>
|
||||
{/* Pool summary + active mode */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400">Your mailboxes</span>
|
||||
<ModePill mode={mode} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap -mt-1">
|
||||
{poolChips.map((p) => (
|
||||
<span
|
||||
key={p.key}
|
||||
className="inline-flex items-center gap-1.5 h-7 pl-1 pr-2 rounded-md border border-slate-200 bg-white"
|
||||
>
|
||||
<ProviderLogo provider={p.key} className="size-5" />
|
||||
<span className="text-[11.5px] text-slate-700">
|
||||
{LABEL[p.key]} <span className="font-semibold tabular-nums">{p.count}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-stretch">
|
||||
{/* Recipients (left) */}
|
||||
<div className="min-w-0 flex flex-col" style={{ gap: GAP }}>
|
||||
{recipients.map((r) => {
|
||||
const st = recStatus(r);
|
||||
return (
|
||||
<div
|
||||
key={r}
|
||||
className={`rounded-lg border bg-white px-2.5 flex items-center gap-2.5 ${nodeBorder[st]}`}
|
||||
style={{ height: NODE_H }}
|
||||
>
|
||||
<ProviderLogo provider={r} className="size-7" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-medium text-slate-800 truncate leading-tight">
|
||||
{LABEL[r]}
|
||||
</div>
|
||||
<div className={`text-[10.5px] leading-tight ${statusText[st]}`}>
|
||||
{statusLabel[st]}
|
||||
</div>
|
||||
{/* Recipient → serving mailboxes, ranked (match vs wildcard vs catch-all) */}
|
||||
<div className="space-y-1.5">
|
||||
{(["gmail", "outlook"] as const).map((r) => {
|
||||
const sameCount = r === "gmail" ? counts.gmail : counts.outlook;
|
||||
const hasSame = sameCount > 0;
|
||||
// SMTP/IMAP is a wildcard for Gmail/Outlook recipients only OUTSIDE
|
||||
// strict — under strict, "same provider" excludes unknown-ESP SMTP.
|
||||
const wildServes = counts.smtp > 0 && mode !== "strict";
|
||||
// prefer falls back cross-provider when no same + no wildcard.
|
||||
const crossCount = (r === "gmail" ? counts.outlook : counts.gmail);
|
||||
|
||||
let status: Status;
|
||||
if (mode === "off") status = "any";
|
||||
else if (hasSame || wildServes) status = "ok";
|
||||
else if (mode === "prefer" && crossCount > 0) status = "warn";
|
||||
else status = "blocked";
|
||||
|
||||
const tint =
|
||||
status === "blocked"
|
||||
? "border-rose-200 bg-rose-50/40"
|
||||
: status === "warn"
|
||||
? "border-amber-200 bg-amber-50/40"
|
||||
: "border-slate-200 bg-white";
|
||||
|
||||
return (
|
||||
<div key={r} className={`flex items-center gap-3 rounded-lg border px-3 py-2.5 ${tint}`}>
|
||||
<ProviderLogo provider={r} className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] font-medium text-slate-800 leading-tight">
|
||||
{LABEL[r]} recipients
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-[0.1em] text-slate-400">via</span>
|
||||
{mode === "off" ? (
|
||||
<AnyMailbox />
|
||||
) : (
|
||||
<>
|
||||
{hasSame && (
|
||||
<Chip provider={r} count={sameCount} variant="match" />
|
||||
)}
|
||||
{wildServes && (
|
||||
<Chip provider="smtp_imap" count={counts.smtp} variant="wildcard" />
|
||||
)}
|
||||
{!hasSame && !wildServes && mode === "prefer" && crossCount > 0 && (
|
||||
<Chip
|
||||
provider={r === "gmail" ? "outlook" : "gmail"}
|
||||
count={crossCount}
|
||||
variant="fallback"
|
||||
/>
|
||||
)}
|
||||
{status === "blocked" && (
|
||||
<span className="text-[11px] text-rose-500 font-medium">
|
||||
No matching mailbox
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Connectors */}
|
||||
<svg
|
||||
className="w-14 sm:w-20"
|
||||
height={TOTAL_H}
|
||||
viewBox={`0 0 100 ${TOTAL_H}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{edges.map((e, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={`M0,${CENTERS[e.ri]} C50,${CENTERS[e.ri]} 50,${CENTERS[e.mi]} 100,${CENTERS[e.mi]}`}
|
||||
fill="none"
|
||||
stroke={STROKE[e.tone]}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeOpacity={e.tone === "neutral" ? 0.6 : 0.9}
|
||||
strokeDasharray={e.dashed ? "4 4" : undefined}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Mailboxes (right) */}
|
||||
<div className="min-w-0 flex flex-col" style={{ gap: GAP }}>
|
||||
{mailboxes.map((m) => {
|
||||
const empty = m.count === 0;
|
||||
return (
|
||||
<div
|
||||
key={m.key}
|
||||
className={`rounded-lg border bg-white px-2.5 flex items-center gap-2.5 ${
|
||||
empty ? "border-slate-200/70" : "border-slate-200"
|
||||
}`}
|
||||
style={{ height: NODE_H }}
|
||||
>
|
||||
<ProviderLogo provider={m.key} className="size-7" muted={empty} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={`text-[12px] font-medium truncate leading-tight ${
|
||||
empty ? "text-slate-400" : "text-slate-800"
|
||||
}`}
|
||||
>
|
||||
{LABEL[m.key]}
|
||||
</div>
|
||||
<div className="text-[10.5px] text-slate-400 leading-tight">
|
||||
{m.count} {m.count === 1 ? "mailbox" : "mailboxes"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Unknown-domain recipients always use any mailbox (wildcard), every mode. */}
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
||||
<ProviderLogo provider="other" className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] font-medium text-slate-800 leading-tight">Other domains</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-[0.1em] text-slate-400">via</span>
|
||||
<AnyMailbox />
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status="any" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-0.5 text-[10px] text-slate-400">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-4 border-t-2 border-emerald-500" /> Match
|
||||
</span>
|
||||
{mode === "prefer" && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-4 border-t-2 border-dashed border-amber-500" /> Fallback
|
||||
</span>
|
||||
)}
|
||||
{mode === "off" && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-4 border-t-2 border-slate-300" /> Any mailbox
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{mode === "strict" && onlyWildcard && (
|
||||
<p className="rounded-md bg-amber-50 border border-amber-200 px-2.5 py-2 text-[11px] text-amber-700 leading-relaxed">
|
||||
All your mailboxes are SMTP/IMAP, which can send to any provider — so Strict can't narrow by
|
||||
provider here and behaves like Off. Connect a Google or Outlook mailbox to truly restrict
|
||||
same-provider sending.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
{mode === "off"
|
||||
? "Provider matching is off — any recipient can be sent from any mailbox in the pool."
|
||||
: mode === "strict"
|
||||
? "Strict only ever sends from a same-provider mailbox; a recipient with no match is held (deferred) until one frees up — never sent cross-provider. SMTP/IMAP mailboxes are a universal wildcard, and non-Google/Outlook domains send from any mailbox."
|
||||
: "Prefer uses a same-provider mailbox when one has capacity, otherwise it falls back to another provider — it never holds a recipient. SMTP/IMAP mailboxes can carry any provider."}
|
||||
? "Strict sends Google and Outlook recipients only from a same-provider mailbox (the sky match) — a recipient with no same-provider mailbox is held (deferred) until one frees up, never sent cross-provider. SMTP/IMAP mailboxes only carry non-Google/Outlook (“other”) domains under strict."
|
||||
: "Prefer uses a same-provider mailbox when one has capacity (the sky match), otherwise it falls back to another provider (the amber chip) — it never holds a recipient. SMTP/IMAP mailboxes can carry any provider."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnyMailbox() {
|
||||
return <span className="text-[11px] text-slate-400">Any mailbox in the pool</span>;
|
||||
}
|
||||
|
||||
function Chip({
|
||||
provider,
|
||||
count,
|
||||
variant,
|
||||
}: {
|
||||
provider: string;
|
||||
count: number;
|
||||
variant: "match" | "wildcard" | "fallback";
|
||||
}) {
|
||||
const cls =
|
||||
variant === "match"
|
||||
? "border-sky-200 bg-sky-50/60"
|
||||
: variant === "fallback"
|
||||
? "border-amber-200 bg-amber-50/60 border-dashed"
|
||||
: "border-slate-200 bg-slate-50";
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 h-6 pl-1 pr-1.5 rounded-md border ${cls}`}>
|
||||
<ProviderLogo provider={provider} className="size-4" muted={variant !== "match"} />
|
||||
<span className={`text-[11px] tabular-nums ${variant === "match" ? "text-slate-700" : "text-slate-500"}`}>
|
||||
×{count}
|
||||
</span>
|
||||
{variant === "wildcard" && (
|
||||
<span className="text-[9px] uppercase tracking-[0.1em] text-slate-400 ml-0.5">any</span>
|
||||
)}
|
||||
{variant === "fallback" && (
|
||||
<span className="text-[9px] uppercase tracking-[0.1em] text-amber-500 ml-0.5">fallback</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: Status }) {
|
||||
const map = {
|
||||
ok: { text: "Covered", cls: "bg-emerald-50 text-emerald-700", dot: "bg-emerald-500" },
|
||||
warn: { text: "Fallback", cls: "bg-amber-50 text-amber-700", dot: "bg-amber-500" },
|
||||
blocked: { text: "Deferred", cls: "bg-rose-50 text-rose-700", dot: "bg-rose-500" },
|
||||
any: { text: "Any", cls: "bg-slate-100 text-slate-500", dot: "bg-slate-400" },
|
||||
}[status];
|
||||
return (
|
||||
<span
|
||||
className={`shrink-0 inline-flex items-center gap-1.5 h-5 px-2 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${map.cls}`}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full ${map.dot}`} />
|
||||
{map.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModePill({ mode }: { mode: Mode }) {
|
||||
const m = {
|
||||
off: { t: "Matching off", c: "bg-slate-100 text-slate-500" },
|
||||
prefer: { t: "Prefer same", c: "bg-sky-50 text-sky-700" },
|
||||
strict: { t: "Strict same", c: "bg-sky-50 text-sky-700" },
|
||||
}[mode];
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center h-5 px-2 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${m.c}`}
|
||||
>
|
||||
{m.t}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// ProviderLogo — small app-icon tiles with the recognizable brand mark for each
|
||||
// email provider we match against (Gmail, Outlook, and a neutral mark for
|
||||
// SMTP/IMAP or any other domain). Brand colours are intentional inline hex —
|
||||
// logos are exempt from the slate/sky chrome palette so they stay recognizable.
|
||||
// ProviderLogo — provider brand marks for the matching visual. Reuses the real,
|
||||
// official Google + Outlook SVGs the app already ships (the same ones the Add
|
||||
// Account flow uses, @/components/svg), so the logos are genuine and consistent
|
||||
// rather than hand-drawn. SMTP/IMAP and unknown domains get a neutral mail mark.
|
||||
|
||||
import { Google, Outlook } from "@/components/svg";
|
||||
|
||||
export default function ProviderLogo({
|
||||
provider,
|
||||
@@ -12,55 +14,35 @@ export default function ProviderLogo({
|
||||
className?: string;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
const tile = `inline-flex items-center justify-center rounded-md shrink-0 ${className} ${
|
||||
muted ? "opacity-40 grayscale" : ""
|
||||
}`;
|
||||
const wrap = `inline-flex items-center justify-center shrink-0 ${muted ? "opacity-40 grayscale" : ""}`;
|
||||
|
||||
if (provider === "gmail") {
|
||||
return (
|
||||
<span className={`${tile} bg-white ring-1 ring-slate-200`}>
|
||||
<svg viewBox="0 0 48 48" className="w-[66%] h-[66%]" aria-hidden="true">
|
||||
<path fill="#4caf50" d="M45 16.2l-5 2.75-5 4.75L35 40h7a3 3 0 0 0 3-3V16.2z" />
|
||||
<path fill="#1e88e5" d="M3 16.2l3.614 1.71L13 23.7V40H6a3 3 0 0 1-3-3V16.2z" />
|
||||
<polygon
|
||||
fill="#e53935"
|
||||
points="35,11.2 24,19.45 13,11.2 12,17 13,23.7 24,31.95 35,23.7 36,17"
|
||||
/>
|
||||
<path fill="#c62828" d="M3 12.298V16.2l10 7.5V11.2L9.876 8.859A2.99 2.99 0 0 0 3 12.298z" />
|
||||
<path fill="#fbc02d" d="M45 12.298V16.2l-10 7.5V11.2l3.124-2.341A2.99 2.99 0 0 1 45 12.298z" />
|
||||
</svg>
|
||||
<span className={wrap}>
|
||||
<Google className={className} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "outlook") {
|
||||
return (
|
||||
<span className={`${tile} ring-1 ring-black/5`} style={{ backgroundColor: "#0F6CBD" }}>
|
||||
<svg viewBox="0 0 24 24" className="w-[64%] h-[64%]" aria-hidden="true">
|
||||
{/* the signature Outlook "O" */}
|
||||
<ellipse cx="9.2" cy="12" rx="4.6" ry="5.4" fill="none" stroke="#fff" strokeWidth="2.5" />
|
||||
{/* envelope card */}
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M15 8.6h5.7c.35 0 .6.26.6.6v5.6c0 .34-.25.6-.6.6H15z"
|
||||
/>
|
||||
<path fill="none" stroke="#0F6CBD" strokeWidth="1.2" d="M15.4 9.7l3 2 3-2" />
|
||||
</svg>
|
||||
<span className={wrap}>
|
||||
<Outlook className={className} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// smtp_imap / other / unknown — neutral mail mark.
|
||||
return (
|
||||
<span className={`${tile} bg-slate-500`}>
|
||||
<span className={wrap}>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
stroke="#64748b"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-[56%] h-[56%]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
|
||||
+51
-22
@@ -236,10 +236,12 @@ export function OptionSelect<T extends string>({
|
||||
* address and press Enter/Tab/comma to add; backspace on an empty field
|
||||
* removes the last chip.
|
||||
*/
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function EmailListInput({
|
||||
values,
|
||||
onChange,
|
||||
placeholder = "name@example.com — Enter to add",
|
||||
placeholder = "name@example.com",
|
||||
}: {
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
@@ -247,38 +249,65 @@ export function EmailListInput({
|
||||
}) {
|
||||
const [draft, setDraft] = React.useState("");
|
||||
|
||||
// Split on whitespace/comma/semicolon so a pasted or typed list of addresses
|
||||
// becomes individual chips, de-duplicated against what's already there.
|
||||
const addTokens = (text: string) => {
|
||||
const tokens = text
|
||||
.split(/[\s,;]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) return;
|
||||
const next = [...values];
|
||||
for (const t of tokens) if (!next.includes(t)) next.push(t);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const v = draft.trim().replace(/,$/, "").trim();
|
||||
if (!v) return;
|
||||
if (!values.includes(v)) onChange([...values, v]);
|
||||
if (!draft.trim()) return;
|
||||
addTokens(draft);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-white min-h-[34px] px-2 py-1.5 flex flex-wrap items-center gap-1 focus-within:border-sky-400 focus-within:ring-2 focus-within:ring-sky-100 transition-colors">
|
||||
{values.map((v, i) => (
|
||||
<span
|
||||
key={`${v}-${i}`}
|
||||
className="inline-flex items-center gap-1 h-5 pl-1.5 pr-1 rounded bg-sky-50 text-sky-700 text-[11px] font-medium max-w-[calc(100%-4rem)]"
|
||||
>
|
||||
<span className="truncate">{v}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
|
||||
aria-label={`Remove ${v}`}
|
||||
className="opacity-70 hover:opacity-100 shrink-0"
|
||||
{values.map((v, i) => {
|
||||
const invalid = !EMAIL_RE.test(v);
|
||||
return (
|
||||
<span
|
||||
key={`${v}-${i}`}
|
||||
title={invalid ? "This doesn't look like a valid email address" : undefined}
|
||||
className={`inline-flex items-center gap-1 h-5 pl-1.5 pr-1 rounded text-[11px] font-medium max-w-[calc(100%-4rem)] ${
|
||||
invalid ? "bg-rose-50 text-rose-600" : "bg-sky-50 text-sky-700"
|
||||
}`}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<span className="truncate">{v}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
|
||||
aria-label={`Remove ${v}`}
|
||||
className="opacity-70 hover:opacity-100 shrink-0"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
value={draft}
|
||||
inputMode="email"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (/[\s,;]/.test(text)) {
|
||||
e.preventDefault();
|
||||
addTokens(text);
|
||||
setDraft("");
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
|
||||
if (e.key === "Enter" || e.key === "Tab" || e.key === "," || e.key === ";") {
|
||||
if (draft.trim()) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
|
||||
Reference in New Issue
Block a user