From 1f6b407d43efc334e45655228a65c6754f36e6cd Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Mon, 25 May 2026 16:27:19 +0800 Subject: [PATCH] feat(web): scope email editor 'Preview as' to the campaign's segment The 'Preview as' dropdown in the campaign email editor previously listed the first 50 contacts of the entire project, regardless of who the campaign actually targets. For SEGMENT-audience campaigns this is misleading -- you'd preview rendered variables against contacts that will never receive the email. Scope the dropdown to the selected segment's members when the campaign uses CampaignAudienceType.SEGMENT, falling back to all contacts for ALL audiences (and templates, which pass no segmentId -- behaviour unchanged). - add useSegmentContacts() hook backed by the existing GET /segments/:id/contacts endpoint (handles both STATIC and DYNAMIC segments, page=1 pageSize=50) - EmailEditor gains an optional segmentId prop and switches its contact source accordingly; clears the preview selection when the chosen contact leaves the list (e.g. the segment changed) - campaigns/[id] and campaigns/create pass segmentId only when the audience is SEGMENT --- .../components/EmailEditor/EmailEditor.tsx | 24 +++++++++++--- apps/web/src/lib/hooks/useContacts.ts | 31 ++++++++++++++++++- apps/web/src/pages/campaigns/[id].tsx | 5 +++ apps/web/src/pages/campaigns/create.tsx | 6 +++- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/EmailEditor/EmailEditor.tsx b/apps/web/src/components/EmailEditor/EmailEditor.tsx index 4a1ad9dc..e55f85cf 100644 --- a/apps/web/src/components/EmailEditor/EmailEditor.tsx +++ b/apps/web/src/components/EmailEditor/EmailEditor.tsx @@ -11,7 +11,7 @@ import {setAvailableVariables, VariableMention} from './VariableMention'; import {Toolbar} from './Toolbar'; import {ResizableImage} from './ResizableImage'; import {HtmlEditor} from './HtmlEditor'; -import {useContactFields, useContacts} from '../../lib/hooks/useContacts'; +import {useContactFields, useContacts, useSegmentContacts} from '../../lib/hooks/useContacts'; import {useConfig} from '../../lib/hooks/useConfig'; import {useEffect, useRef, useState} from 'react'; import {renderTemplate} from '@plunk/shared'; @@ -42,6 +42,9 @@ interface EmailEditorProps { subject?: string; from?: string; replyTo?: string; + // When set, the "Preview as" dropdown is scoped to this segment's members + // instead of all contacts in the project (used for SEGMENT-audience campaigns) + segmentId?: string; } const commonVariables = [ @@ -52,7 +55,7 @@ const commonVariables = [ {name: 'manageUrl', description: 'Manage link'}, ]; -export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) { +export function EmailEditor({value, onChange, placeholder, subject, from, replyTo, segmentId}: EmailEditorProps) { // Detect if initial value has custom HTML and start in appropriate mode const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual'; @@ -73,8 +76,12 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT // Fetch available contact fields using SWR const {fields: availableFields} = useContactFields(); - // Fetch contacts for preview using SWR - const {contacts} = useContacts({limit: 50}); + // Fetch contacts for preview using SWR. + // When the campaign targets a segment, scope the dropdown to that segment's + // members; otherwise fall back to all contacts in the project. + const {contacts: allContacts} = useContacts({limit: 50}); + const {contacts: segmentContacts} = useSegmentContacts(segmentId); + const contacts = segmentId ? segmentContacts : allContacts; useEffect(() => { if (availableFields.length > 0) { @@ -82,6 +89,15 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT } }, [availableFields]); + // Clear the preview selection when the chosen contact is no longer in the + // available list (e.g. the segment changed). Otherwise the preview stays + // stuck rendering a contact that's no longer selectable in the dropdown. + useEffect(() => { + if (selectedContactId && !contacts.some(c => c.id === selectedContactId)) { + setSelectedContactId(''); + } + }, [contacts, selectedContactId]); + const {data: config} = useConfig(); const canUploadImages = Boolean(config?.features.storage.s3Enabled); diff --git a/apps/web/src/lib/hooks/useContacts.ts b/apps/web/src/lib/hooks/useContacts.ts index d38507fe..4407e06b 100644 --- a/apps/web/src/lib/hooks/useContacts.ts +++ b/apps/web/src/lib/hooks/useContacts.ts @@ -1,5 +1,5 @@ import type {Contact} from '@plunk/db'; -import type {CursorPaginatedResponse} from '@plunk/types'; +import type {CursorPaginatedResponse, PaginatedResponse} from '@plunk/types'; import useSWR from 'swr'; interface UseContactsOptions { @@ -33,6 +33,35 @@ export function useContacts(options: UseContactsOptions = {}) { }; } +/** + * Hook to fetch the contacts belonging to a specific segment. + * + * Backed by `GET /segments/:id/contacts`, which resolves both STATIC + * (via SegmentMembership) and DYNAMIC (via condition) segments. Used by the + * email editor to scope the "Preview as" dropdown to the campaign's selected + * audience instead of every contact in the project. + * + * When `segmentId` is undefined/empty the SWR key is null, so no request is + * made and an empty list is returned (callers fall back to all contacts). + */ +export function useSegmentContacts(segmentId?: string, pageSize = 50) { + const {data, error, mutate, isLoading} = useSWR>( + segmentId ? `/segments/${segmentId}/contacts?page=1&pageSize=${pageSize}` : null, + { + revalidateOnFocus: false, + dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds + }, + ); + + return { + contacts: data?.data ?? [], + total: data?.total ?? 0, + error, + isLoading, + mutate, + }; +} + /** * Hook to fetch available contact fields for variable usage */ diff --git a/apps/web/src/pages/campaigns/[id].tsx b/apps/web/src/pages/campaigns/[id].tsx index 056b9ac1..d3158972 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -686,6 +686,11 @@ export default function CampaignDetailsPage() { setEditedCampaign({...editedCampaign, body}); setHasChanges(true); }} + segmentId={ + (editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT + ? (editedCampaign.segmentId ?? c.segmentId ?? undefined) + : undefined + } /> diff --git a/apps/web/src/pages/campaigns/create.tsx b/apps/web/src/pages/campaigns/create.tsx index 9219d618..3e7bfabf 100644 --- a/apps/web/src/pages/campaigns/create.tsx +++ b/apps/web/src/pages/campaigns/create.tsx @@ -316,7 +316,11 @@ export default function CreateCampaignPage() { Design your email message - +