diff --git a/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx b/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx index 1afb5eef2..831757348 100644 --- a/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx @@ -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 > - - {index + 1} - + {index + 1}

{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(null); - const orderTriggerRef = useRef(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 ( -

- {/* Order By */} +
+ {/* Order by — same card picker as the rest of settings */}
-
-
- -
- - {orderOpen && ( - - {ORDER_OPTIONS.map((option) => { - const sel = newCampaign.contact_order_by === option.value; - return ( - - ); - })} - - )} - -
+ setNewCampaign((prev) => ({ ...prev, contact_order_by: v }))} + options={ORDER_OPTIONS} + />
{/* Direction (not for manual) */} @@ -210,9 +149,7 @@ export default function CampaignContactOrder({ control={ - 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({ - setNewCampaign((prev) => ({ ...prev, contact_order_field: v })) - } + onChange={(v) => setNewCampaign((prev) => ({ ...prev, contact_order_field: v }))} className="w-full max-w-[280px]" />

@@ -245,11 +180,7 @@ export default function CampaignContactOrder({

{orderedContacts.length > 0 ? ( - + c.id)} strategy={verticalListSortingStrategy} @@ -263,25 +194,12 @@ export default function CampaignContactOrder({ ) : (
-

- No contacts in this campaign -

-

- Add contacts to enable manual ordering. -

+

No contacts in this campaign

+

Add contacts to enable manual ordering.

)}
)} - - {/* Info */} -
- -

- When processing sends, contacts are selected in this order — it determines who - receives emails first while the campaign is running. -

-
); } diff --git a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx index 8a5fa0ddc..fdf5a570a 100644 --- a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx @@ -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>; -/** 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 ( +
+
+ {shown.map((val, i) => { + const today = i === todayIdx; + return ( +
+ ); + })} +
+
+ + Day 1 · {start}/day + + + {more > 0 ? `+${more} more · ` : ""}Day {days.length} · {ceiling}/day, then steady + +
+
+ ); +} + +/** 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 (
- 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 */} +
+ 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.", + }, + ]} + /> + } + /> +

+ 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. +

+
+ {/* Daily ramp-up */}
- + setNewCampaign((bef) => ({ ...bef, ramp_increment: v }))} - suffix="+ / day" + suffix="/ day" className="w-36" />
@@ -99,11 +173,18 @@ export function RotationRampSection({ {newCampaign.ramp_level}
- {rampInvalid && ( + {rampInvalid ? (

Start must be less than or equal to the ceiling.

+ ) : ( + )}
)} @@ -161,19 +242,19 @@ export function LeadFlowSection({ return (
- + 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" /> -

- {newCampaign.max_new_leads_per_day === 0 - ? "0 = unlimited new leads per day." - : "Caps how many fresh leads enter the campaign each day."} +

+ Limits how many brand-new leads get their very first{" "} + 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.