Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions apps/web/src/components/EmailEditor/EmailEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = [
Expand All @@ -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';

Expand All @@ -73,15 +76,28 @@ 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) {
setAvailableVariables(availableFields);
}
}, [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);

Expand Down
31 changes: 30 additions & 1 deletion apps/web/src/lib/hooks/useContacts.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<PaginatedResponse<Contact>>(
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
*/
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/pages/campaigns/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
/>
</CardContent>
</Card>
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/pages/campaigns/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,11 @@ export default function CreateCampaignPage() {
<CardDescription>Design your email message</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor value={body} onChange={setBody} />
<EmailEditor
value={body}
onChange={setBody}
segmentId={audienceType === CampaignAudienceType.SEGMENT ? segmentId || undefined : undefined}
/>
</CardContent>
</Card>

Expand Down
Loading