mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
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:
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }),
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user