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