feat: fix the segment dialog UI and make a contact created inside a segment join it (issue #285): the add-to-campaign picker now shows a humanised, colour-toned campaign status instead of the raw PAUSED_NO_ACCOUNTS enum, its footer wraps instead of clipping the hint mid-sentence and the Add leads button no longer breaks across two lines, campaign status labels move to a shared components/app/campaigns/status module, the Segments, Categories and Segment pages plus every unmapped settings and onboarding route get a document title so they stop reading Page not found, and POST /contacts takes a segments array that pins the new contacts in as include overrides (validated before the write, best-effort after it) which the New contact dialog sends when opened from a segment page

This commit is contained in:
Matthew Meszaros
2026-09-01 02:57:30 -07:00
parent 7b6830daa5
commit 55307b0ad9
13 changed files with 195 additions and 53 deletions
@@ -151,6 +151,7 @@ A JSON array of contact objects (at least one, up to the per-request maximum; an
| `phone` | string | No | Phone number. |
| `campaigns` | string[] | No | Campaign IDs to add the contact to. |
| `categories` | string[] | No | Category IDs to assign. |
| `segments` | string[] | No | Segment IDs to pin the contact into, as a manual include override, so it belongs whether or not the conditions match it. An unknown id is rejected with `400` before any contact is written. |
| `custom_fields` | object | No | String key/value custom fields. Keys may use letters, numbers, underscores, spaces, and dashes. |
| `subscribed` | boolean | No | Marketing-consent flag. Omit it to let a new contact default to subscribed and an existing one keep whatever it already had. |
| `verification_status` | string | No | A verdict you already hold for the address, in Warmbly's vocabulary (`valid`, `risky`, `invalid`, `unknown`) or any known service's (`ok`, `catch-all`, `do_not_mail`, `deliverable`, `ok_for_all`, ...). Stored as an imported verdict that the background check leaves alone. A value no known service writes is rejected with `unknown_verification_status`. |
+3 -1
View File
@@ -42,7 +42,9 @@ Conditions decide membership, and two overrides sit on top of them:
The segment header shows how many contacts are pinned in or out, and a **Pinned contacts** panel below it lists them; **Back to automatic** clears an override so the conditions decide again. A contact's own drawer has a **Segments** section that shows every segment, whether the contact is in it, and the same pin in, pin out and back-to-automatic controls.
Sequences can pin as well: the **Add to segment** and **Remove from segment** action steps apply the override to a contact as it moves through a campaign flow, so a positive reply can drop someone into a "warm" segment automatically. So can an import: the file import wizard's **Add to segments** picker on its Options step pins every contact in the file into the segments you choose, so a fresh list can land straight in the audience it was collected for.
**New contact** on a segment page pins as well: a contact created from inside a segment joins that segment, so it appears in the list you created it from even when the conditions do not describe it yet.
Sequences can pin too: the **Add to segment** and **Remove from segment** action steps apply the override to a contact as it moves through a campaign flow, so a positive reply can drop someone into a "warm" segment automatically. So can an import: the file import wizard's **Add to segments** picker on its Options step pins every contact in the file into the segments you choose, so a fresh list can land straight in the audience it was collected for.
## Using a segment
+75
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
@@ -56,10 +57,18 @@ func (s *contactService) Add(ctx context.Context, userID string, orgID uuid.UUID
return nil, xerr
}
// Resolved before the write so an unknown segment is a 400 rather than a
// contact that exists but never reached the segment it was created in.
pins, xerr := s.segmentPins(ctx, orgID, contacts)
if xerr != nil {
return nil, xerr
}
created, xerr := s.contactRepository.Add(ctx, userID, orgID, contacts)
if xerr != nil {
return nil, xerr
}
s.applySegmentPins(ctx, orgID, pins, created)
s.publishContactsReload(ctx, userID, "contacts:add")
var attached []string
@@ -71,6 +80,72 @@ func (s *contactService) Add(ctx context.Context, userID string, orgID uuid.UUID
return created, nil
}
// segmentPins maps each target segment to the positions in `contacts` that
// asked for it. Existence is checked once for the whole batch, so N contacts
// naming the same segment cost one lookup.
func (s *contactService) segmentPins(ctx context.Context, orgID uuid.UUID, contacts []models.AddContact) (map[uuid.UUID][]int, *errx.Error) {
var raw []string
for i := range contacts {
raw = append(raw, contacts[i].Segments...)
}
if len(raw) == 0 {
return nil, nil
}
valid, xerr := s.parseSegmentIDs(ctx, orgID, raw)
if xerr != nil {
return nil, xerr
}
known := make(map[uuid.UUID]bool, len(valid))
for _, id := range valid {
known[id] = true
}
pins := make(map[uuid.UUID][]int, len(valid))
for i := range contacts {
seen := make(map[uuid.UUID]bool, len(contacts[i].Segments))
for _, r := range contacts[i].Segments {
id, err := uuid.Parse(strings.TrimSpace(r))
if err != nil || !known[id] {
return nil, errx.New(errx.BadRequest, "invalid segment id")
}
if seen[id] {
continue
}
seen[id] = true
pins[id] = append(pins[id], i)
}
}
return pins, nil
}
// applySegmentPins writes the include overrides for a batch that has just been
// created. The repository answers one row per input contact, in order, so
// position i of `created` is the contact that position i of the request asked
// to pin. Best effort like wakeCampaigns: the contacts are already stored.
func (s *contactService) applySegmentPins(ctx context.Context, orgID uuid.UUID, pins map[uuid.UUID][]int, created []models.Contact) {
if len(pins) == 0 || s.segmentLinker == nil {
return
}
for segmentID, positions := range pins {
ids := make([]uuid.UUID, 0, len(positions))
for _, i := range positions {
if i < len(created) {
ids = append(ids, created[i].ID)
}
}
if len(ids) == 0 {
continue
}
if _, xerr := s.segmentLinker.SetMembers(ctx, orgID, segmentID, ids, models.SegmentMemberInclude); xerr != nil {
log.Warn().
Str("organization_id", orgID.String()).
Str("segment_id", segmentID.String()).
Int("contacts", len(ids)).
Msg("could not pin the new contacts into their segment")
}
}
}
func (s *contactService) Search(ctx context.Context, orgID, cursor, category, limit string, filters models.SearchContacts) (*models.ContactsResult, *errx.Error) {
cursorId, err := paging.DecodeCursor(cursor)
if err != nil {
+5
View File
@@ -486,6 +486,11 @@ type AddContact struct {
Campaigns []string `json:"campaigns"`
Categories []string `json:"categories"`
// Segments the new contact joins as an include override, so a contact
// created from inside a segment belongs to it whatever the conditions
// say. Unknown ids are a 400 before anything is written.
Segments []string `json:"segments,omitempty"`
CustomFields map[string]string `json:"custom_fields"`
// VerificationStatus is a verdict the caller already holds for this
+8 -39
View File
@@ -47,6 +47,11 @@ import {
TopbarAction,
} from "@/components/layout/Page";
import { SearchInput } from "@/components/ui/field";
import {
CAMPAIGN_STATUS_LABEL,
campaignStatusBucket as statusBucket,
campaignStatusTone as statusTone,
} from "@/components/app/campaigns/status";
import {
PopoverMenu,
PopoverMenuContent,
@@ -60,47 +65,11 @@ import {
type StatusFilter = "all" | "active" | "paused" | "draft" | "completed";
type SortMode = "newest" | "oldest" | "name";
// Collapse the backend's raw campaign statuses into the buckets the list
// filters/counts by. paused_no_accounts / paused_trial_expired are auto-pause
// variants, so they belong with "paused"; anything unknown reads as draft.
function statusBucket(s?: string): "active" | "paused" | "completed" | "draft" {
if (s === "active") return "active";
if (s === "completed") return "completed";
if (s && s.startsWith("paused")) return "paused";
return "draft";
}
// Per-state label + leading mark for a campaign row. "active" renders the
// animated dot-grid loader; every other state is a 14px lucide icon so the
// fixed-width leading slot keeps each row's name aligned.
const STATUS_LABEL: Record<string, string> = {
active: "running",
paused: "paused",
paused_no_accounts: "no accounts",
paused_trial_expired: "trial expired",
paused_guardrail: "auto-paused",
paused_undeliverable: "needs verification",
completed: "finished",
draft: "draft",
};
// Single source of truth for a status's color — drives BOTH the leading mark
// and the right-side text label so they always agree. emerald = live/done,
// amber = paused/needs-attention, slate = not started.
const STATUS_TONE: Record<string, string> = {
active: "text-emerald-600",
completed: "text-emerald-600",
paused: "text-amber-600",
paused_no_accounts: "text-amber-600",
paused_trial_expired: "text-amber-600",
paused_guardrail: "text-rose-600",
paused_undeliverable: "text-amber-600",
draft: "text-slate-500",
};
function statusTone(status: string): string {
return STATUS_TONE[status] ?? STATUS_TONE.draft;
}
// fixed-width leading slot keeps each row's name aligned. The label/tone maps
// live in components/app/campaigns/status so pickers share them.
const STATUS_LABEL = CAMPAIGN_STATUS_LABEL;
function CampaignStatusMark({ status }: { status: string }) {
const tone = statusTone(status);
+3
View File
@@ -32,6 +32,9 @@ export interface AddContact {
phone: string;
campaigns: string[];
categories?: string[];
// Segments the contact is pinned into on creation (an include override),
// so a contact created from inside a segment lands in it.
segments?: string[];
custom_fields: Record<string, string>;
// First-touch source hint: "campaign" when added from a campaign's
// Leads tab, else "manual". The server decides every other origin.
@@ -0,0 +1,49 @@
// One place every surface reads a campaign's status from, so a list row, a
// picker row and a badge never disagree (and none of them leaks the raw
// backend value like "paused_no_accounts" at the user).
// Collapse the backend's raw campaign statuses into the buckets a list
// filters/counts by. paused_no_accounts / paused_trial_expired are auto-pause
// variants, so they belong with "paused"; anything unknown reads as draft.
export function campaignStatusBucket(s?: string): "active" | "paused" | "completed" | "draft" {
if (s === "active") return "active";
if (s === "completed") return "completed";
if (s && s.startsWith("paused")) return "paused";
return "draft";
}
export const CAMPAIGN_STATUS_LABEL: Record<string, string> = {
active: "running",
paused: "paused",
paused_no_accounts: "no accounts",
paused_trial_expired: "trial expired",
paused_guardrail: "auto-paused",
paused_undeliverable: "needs verification",
completed: "finished",
draft: "draft",
};
// Single source of truth for a status's color — drives BOTH the leading mark
// and the right-side text label so they always agree. emerald = live/done,
// amber = paused/needs-attention, slate = not started.
const CAMPAIGN_STATUS_TONE: Record<string, string> = {
active: "text-emerald-600",
completed: "text-emerald-600",
paused: "text-amber-600",
paused_no_accounts: "text-amber-600",
paused_trial_expired: "text-amber-600",
paused_guardrail: "text-rose-600",
paused_undeliverable: "text-amber-600",
draft: "text-slate-500",
};
// An unmapped status still has to read as words, not an enum: underscores
// become spaces rather than surfacing "PAUSED_NO_ACCOUNTS".
export function campaignStatusLabel(status?: string): string {
if (!status) return CAMPAIGN_STATUS_LABEL.draft;
return CAMPAIGN_STATUS_LABEL[status] ?? status.replace(/_/g, " ");
}
export function campaignStatusTone(status: string): string {
return CAMPAIGN_STATUS_TONE[status] ?? CAMPAIGN_STATUS_TONE.draft;
}
@@ -789,7 +789,7 @@ export default function ContactsTable({
/>
<ContactEdit contacts={contacts ?? []} active={edit} setActive={setEdit} initialTab={editTab} />
<ContactsEditBulk active={bulkEdit} setActive={setBulkEdit} selected={selected} />
<NewContactDialog open={newOpen} onClose={() => setNewOpen(false)} />
<NewContactDialog open={newOpen} onClose={() => setNewOpen(false)} segment={segment} />
{segment && (
<AddFromContactsDialog
open={fromContactsOpen}
@@ -21,9 +21,13 @@ interface Props {
// When set (the campaign Leads tab), the new lead is added straight into
// this campaign.
campaign?: { id: string; name: string };
// When set (a segment's member list), the contact is pinned into that
// segment, so it shows up where it was created even if the segment's
// conditions don't match it.
segment?: { id: string; name: string };
}
export function NewContactDialog({ open, onClose, campaign }: Props) {
export function NewContactDialog({ open, onClose, campaign, segment }: Props) {
const [email, setEmail] = React.useState("");
const [firstName, setFirstName] = React.useState("");
const [lastName, setLastName] = React.useState("");
@@ -61,13 +65,14 @@ export function NewContactDialog({ open, onClose, campaign }: Props) {
phone: phone.trim(),
campaigns: campaign ? [campaign.id] : [],
categories,
segments: segment ? [segment.id] : undefined,
custom_fields: {},
source: campaign ? "campaign" : "manual",
};
try {
await toast.promise(add.mutateAsync([contact]), {
loading: "Adding contact…",
success: "Contact added",
success: segment ? `Contact added to ${segment.name}` : "Contact added",
error: (err: AppError) => buildError(err),
});
onClose();
@@ -108,6 +113,11 @@ export function NewContactDialog({ open, onClose, campaign }: Props) {
<span className="text-[12.5px] text-slate-900 font-medium">
Contact
</span>
{segment && (
<span className="hidden sm:inline-flex items-center h-5 px-1.5 rounded bg-sky-50 text-sky-700 text-[10px] font-medium max-w-[160px] truncate">
{segment.name}
</span>
)}
<button
type="button"
onClick={onClose}
@@ -7,6 +7,7 @@ import { Loader2Icon, MegaphoneIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import { SearchInput } from "@/components/ui/field";
import { campaignStatusLabel, campaignStatusTone } from "@/components/app/campaigns/status";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import { useAddSegmentToCampaign } from "@/lib/api/hooks/app/segments";
import type Segment from "@/lib/api/models/app/segments/Segment";
@@ -149,7 +150,14 @@ export default function AddSegmentToCampaignDialog({
{on && <span className="size-1.5 rounded-full bg-white" />}
</span>
<span className="text-[12.5px] text-slate-900 font-medium truncate">{c.name}</span>
<span className="ml-auto text-[10.5px] uppercase tracking-[0.1em] text-slate-400">{c.status}</span>
<span
className={cn(
"ml-auto shrink-0 text-[10.5px] uppercase tracking-[0.1em]",
campaignStatusTone(c.status),
)}
>
{campaignStatusLabel(c.status)}
</span>
</button>
</li>
);
@@ -170,15 +178,17 @@ export default function AddSegmentToCampaignDialog({
</ul>
)}
</div>
<footer className="px-3 h-12 border-t border-slate-200 flex items-center gap-2 shrink-0 bg-slate-50/30">
<span className="text-[11px] text-slate-400 min-w-0 truncate">
{/* Wraps instead of clipping: the hint takes its own row when the
buttons need the width, so neither is ever cut mid-word. */}
<footer className="px-3 py-2 min-h-12 border-t border-slate-200 flex flex-wrap items-center gap-x-2 gap-y-1.5 shrink-0 bg-slate-50/30">
<span className="text-[11px] text-slate-400 leading-snug basis-full sm:basis-0 sm:flex-1 sm:min-w-[160px]">
Adds today's members. Contacts already in the campaign are skipped.
</span>
<button
type="button"
onClick={requestClose}
disabled={busy}
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
className="ml-auto shrink-0 h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
Cancel
</button>
@@ -186,7 +196,7 @@ export default function AddSegmentToCampaignDialog({
type="button"
onClick={submit}
disabled={busy || !picked}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
className="shrink-0 whitespace-nowrap h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
{busy ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <MegaphoneIcon className="w-3 h-3" />}
Add leads
@@ -226,15 +226,15 @@ export default function CampaignSegmentsDialog({
</ul>
)}
</div>
<footer className="px-3 h-12 border-t border-slate-200 flex items-center gap-2 shrink-0 bg-slate-50/30">
<span className="text-[11px] text-slate-400 min-w-0 truncate">
<footer className="px-3 py-2 min-h-12 border-t border-slate-200 flex flex-wrap items-center gap-x-2 gap-y-1.5 shrink-0 bg-slate-50/30">
<span className="text-[11px] text-slate-400 leading-snug basis-full sm:basis-0 sm:flex-1 sm:min-w-[120px]">
{picked.size === 0 ? "No segments linked" : `${picked.size} segment${picked.size === 1 ? "" : "s"} linked`}
</span>
<button
type="button"
onClick={requestClose}
disabled={busy}
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
className="ml-auto shrink-0 h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors disabled:opacity-50"
>
Cancel
</button>
@@ -242,7 +242,7 @@ export default function CampaignSegmentsDialog({
type="button"
onClick={submit}
disabled={busy || !seeded || !dirty}
className="h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
className="shrink-0 whitespace-nowrap h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
{busy ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <LayersIcon className="w-3 h-3" />}
Save
+15
View File
@@ -30,14 +30,21 @@ const ROUTE_TITLES: Record<string, string> = {
"/auth/register/confirm": "Confirm your email",
"/auth/reset-password": "Reset your password",
"/auth/reset-password/confirm": "Set a new password",
"/auth/sso": "Signing you in",
// Onboarding / workspace selection
"/onboarding": "Welcome",
"/select-org": "Select workspace",
"/invite": "Join workspace",
"/setup": "Set up Warmbly",
"/oauth/authorize": "Authorize app",
"/cloud-oauth/done": "Mailbox connected",
// App
"/app/emails": "Mailboxes",
"/app/contacts": "Contacts",
"/app/contacts/segments": "Segments",
"/app/contacts/categories": "Categories",
"/app/campaigns": "Campaigns",
"/app/analytics": "Analytics",
"/app/deliverability": "Deliverability",
@@ -61,10 +68,17 @@ const ROUTE_TITLES: Record<string, string> = {
"/app/settings/members": "Members",
"/app/settings/teams": "Teams",
"/app/settings/workspace": "Workspace",
"/app/settings/sending": "Sending",
"/app/settings/tracking": "Website tracking",
"/app/settings/ai-skills": "AI skills",
"/app/settings/billing": "Billing",
"/app/settings/referral": "Refer & earn",
"/app/settings/limits": "Plan & limits",
"/app/settings/roles": "Roles",
"/app/settings/oauth-apps": "OAuth apps",
"/app/settings/webhooks": "Webhooks",
"/app/settings/connections": "Connections",
"/app/settings/data": "Data",
"/app/settings/danger": "Danger zone",
};
@@ -72,6 +86,7 @@ const ROUTE_TITLES: Record<string, string> = {
// Parameterised routes: [regex, label]. Ordered most-specific first so a
// nested path matches its own entry before the shorter parent pattern.
const PARAM_ROUTES: ReadonlyArray<readonly [RegExp, string]> = [
[/^\/app\/contacts\/segments\/[^/]+$/, "Segment"],
[/^\/app\/campaigns\/[^/]+\/leads$/, "Campaign leads"],
[/^\/app\/campaigns\/[^/]+\/preferences$/, "Campaign settings"],
[/^\/app\/campaigns\/[^/]+\/schedule$/, "Campaign schedule"],
@@ -10,10 +10,13 @@ export default function useAddContacts() {
onSuccess: () => {
// The campaign Leads tab is a ["contacts","list"] search scoped to one
// campaign, so a lead created there needs the list refetched, and
// ["campaigns"] carries the counts that just moved.
// ["campaigns"] carries the counts that just moved. A contact created
// inside a segment is pinned into it, which moves that segment's
// count and its pinned-contacts panel.
return Promise.all([
queryClient.invalidateQueries({ queryKey: ["contacts", "list"] }),
queryClient.invalidateQueries({ queryKey: ["campaigns"] }),
queryClient.invalidateQueries({ queryKey: ["segments"] }),
])
}
})