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
52 changes: 52 additions & 0 deletions console/src/oapi/management.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3039,6 +3039,7 @@ export interface components {
* @example 52f3f921-1343-48af-b795-87c0fd3b44aa
*/
campaign_id: string;
variant?: components["schemas"]["VariantSelector"];
};
/** @description Data for action step - execute WASM action */
ActionStepData: {
Expand Down Expand Up @@ -3330,6 +3331,7 @@ export interface components {
/** @example false */
transactional?: boolean;
variables?: components["schemas"]["CampaignVariable"][];
variants?: components["schemas"]["CampaignVariants"];
};
CampaignVariable: {
/**
Expand All @@ -3343,12 +3345,54 @@ export interface components {
*/
default?: string;
};
CampaignVariant: {
/**
* @description The value a send resolves against to pick this variant's templates. Lowercase letters, digits, dashes and underscores, starting with a letter or digit. The empty key is the default variant and is never declared here.
* @example acme
*/
key: string;
/**
* @description Human readable name shown in the console
* @example Acme Corp
*/
label?: string;
};
/** @description Decides which template variant a send uses. The same shape appears on a campaign, on a journey campaign step and on a broadcast; the most specific layer wins. A static selector pins one variant for every recipient and is rejected when the campaign does not declare its key. An expression selector resolves a variant per recipient and can only be judged at send time, so one that resolves to an unknown variant falls back to the default variant. */
VariantSelector: {
/**
* @description Whether the variant is pinned or resolved per recipient
* @example expression
* @enum {string}
*/
type: "static" | "expression";
/**
* @description Variant key, used when type is static. An empty key pins the default variant, which is how one send is forced back to house branding past a campaign that resolves a client brand per recipient. That differs from omitting the selector entirely, which defers to the campaign.
* @example acme
*/
key?: string;
/**
* @description Liquid expression. Required when type is expression.
* @example {{ user.data.tenant }}
*/
expression?: string;
};
/** @description A campaign's declared variants together with the rule that picks between them when a send does not pick one itself. */
CampaignVariants: {
selector?: components["schemas"]["VariantSelector"];
/** @description The variants this campaign declares. The default variant is always available and is not listed here. */
options?: components["schemas"]["CampaignVariant"][];
};
CreateTemplate: {
/**
* @description The locale/language code for the template
* @example en
*/
locale: string;
/**
* @description The variant this template belongs to. Empty selects the default variant, which is the branding every campaign starts with.
* @example acme
*/
variant?: string;
/** @description Template-specific data based on type. Structure varies by template type. */
data?: {
[key: string]: unknown;
Expand Down Expand Up @@ -3413,6 +3457,7 @@ export interface components {
transactional: boolean;
templates: components["schemas"]["Template"][];
variables?: components["schemas"]["CampaignVariable"][];
variants?: components["schemas"]["CampaignVariants"];
delivery: components["schemas"]["Delivery"];
/**
* @description Whether the campaign has been archived
Expand Down Expand Up @@ -3691,6 +3736,11 @@ export interface components {
data: components["schemas"]["EmailTemplateData"] | components["schemas"]["SmsTemplateData"] | components["schemas"]["PushTemplateData"];
/** @example en */
locale: string;
/**
* @description The variant this template belongs to. Empty is the default variant.
* @example acme
*/
variant: string;
/**
* Format: uuid
* @description The ID of the sender identity to use for this template
Expand Down Expand Up @@ -5433,6 +5483,7 @@ export interface components {
* @description Optional scheduled send time. If provided, the broadcast is created in 'scheduled' state.
*/
scheduled_at?: string;
variant?: components["schemas"]["VariantSelector"];
};
UpdateBroadcast: {
/**
Expand All @@ -5454,6 +5505,7 @@ export interface components {
list_name: string;
/** @description Snapshot of the list type at broadcast creation time */
list_type: string;
variant?: components["schemas"]["VariantSelector"];
state: components["schemas"]["BroadcastState"];
/**
* @description Total number of users in the audience at send time
Expand Down
27 changes: 25 additions & 2 deletions console/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,28 @@ export interface CampaignVariable {
default?: string
}

export interface CampaignVariant {
key: string
label?: string
}

export type VariantSelectorType = "static" | "expression"

/**
* Decides which template variant a send uses. The same shape appears on a
* campaign, a journey campaign step and a broadcast; the most specific wins.
*/
export interface VariantSelector {
type: VariantSelectorType
key?: string
expression?: string
}

export interface CampaignVariants {
selector?: VariantSelector
options?: CampaignVariant[]
}

export interface Campaign {
id: UUID
project_id: UUID
Expand All @@ -752,6 +774,7 @@ export interface Campaign {
transactional?: boolean
templates: Template[]
variables: CampaignVariable[]
variants: CampaignVariants
created_at: string
updated_at: string
}
Expand Down Expand Up @@ -861,6 +884,7 @@ export type Template<
campaign_id: UUID
type: ChannelType
locale: string
variant: string
sender_identity_id: UUID | null
data: DataObjectType
screenshot_url: string
Expand All @@ -885,9 +909,8 @@ export type Template<
}
)

export type TemplateCreateParams = Pick<Template, "data" | "locale">
export type TemplateCreateParams = Pick<Template, "data" | "locale"> & { variant?: string }
export type TemplateUpdateParams = Pick<Template, "data" | "sender_identity_id">
export type VariantUpdateParams = { id?: UUID }

export interface TemplatePreviewParams {
user: Record<string, unknown>
Expand Down
7 changes: 7 additions & 0 deletions console/src/validation/broadcast/broadcast-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export const broadcastResponseSchema = z.object({
list_id: z.string(),
list_name: z.string(),
list_type: z.enum(["static", "dynamic"]),
variant: z
.object({
type: z.enum(["static", "expression"]),
key: z.string().optional(),
expression: z.string().optional(),
})
.nullish(),
state: z.enum(["scheduled", "pending", "sending", "completed", "failed", "cancelled"]),
total: z.number(),
sent: z.number().optional().default(0),
Expand Down
7 changes: 7 additions & 0 deletions console/src/validation/broadcast/create-broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ export const createBroadcastSchema = z
list_id: z.string().min(1, "List is required"),
is_scheduled: z.boolean(),
scheduled_at: z.string().optional(),
variant: z
.object({
type: z.enum(["static", "expression"]),
key: z.string().optional(),
expression: z.string().optional(),
})
.optional(),
})
.refine((data) => !data.is_scheduled || data.scheduled_at, {
message: "Scheduled time is required",
Expand Down
36 changes: 35 additions & 1 deletion console/src/views/broadcast/CreateBroadcastDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const channelIcons: Record<ChannelType, typeof Mail> = {
inbox: Inbox,
}

import { VariantSelectorInput } from "@/views/campaign/VariantSelectorInput"
import { isEnterprise } from "@/config/enterprise"

interface CreateBroadcastDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
Expand Down Expand Up @@ -150,6 +153,7 @@ export function CreateBroadcastDialog({
...(values.is_scheduled && values.scheduled_at
? { scheduled_at: new Date(values.scheduled_at).toISOString() }
: {}),
...(values.variant ? { variant: values.variant } : {}),
Comment thread
jeroenrinzema marked this conversation as resolved.
},
},
)
Expand Down Expand Up @@ -199,7 +203,15 @@ export function CreateBroadcastDialog({
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
onValueChange={(value) => {
// Variant keys are declared per
// campaign, so one left over from the
// previous pick either fails the save
// or, worse, matches a key another
// client happens to use.
form.setValue("variant", undefined)
field.onChange(value)
}}
disabled={!!preselectedCampaignId}
>
<SelectTrigger>
Expand Down Expand Up @@ -246,6 +258,28 @@ export function CreateBroadcastDialog({
)}
</div>

{/* Variant Selector - only for campaigns that declare variants */}
{isEnterprise && (selectedCampaign?.variants?.options?.length ?? 0) > 0 && (
<div className="grid gap-2">
<Label>{t("campaign.variants.title", "Variant")}</Label>
<Controller
control={form.control}
name="variant"
render={({ field }) => (
<VariantSelectorInput
value={field.value}
options={selectedCampaign?.variants?.options ?? []}
onChange={field.onChange}
emptyLabel={t(
"broadcast.variant_inherit",
"Whatever the campaign decides",
)}
/>
)}
/>
</div>
)}

{/* List Selector */}
<div className="grid gap-2">
<Label>{t("list", "List")}</Label>
Expand Down
29 changes: 29 additions & 0 deletions console/src/views/campaign/CampaignDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useResolver } from "@/hooks"

import { channels } from "./template/channels"
import { CampaignVariables } from "./CampaignVariables"
import { CampaignVariants } from "./CampaignVariants"

import { CampaignVariableProvider } from "./CampaignVariableContext"
import { Tabs, TabsContent } from "@/components/ui/tabs"
Expand Down Expand Up @@ -39,6 +40,7 @@ function CampaignReview({ campaign, template }: { campaign: Campaign; template:
const [isBroadcastOpen, setIsBroadcastOpen] = useState(false)
const [isTransactional, setTransactional] = useState(campaign.transactional ?? false)
const [subscriptionId, setSubscriptionId] = useState<string>(campaign.subscription_id ?? "")
const [variants, setVariants] = useState(campaign.variants ?? {})

const [subscriptions] = useResolver(
useCallback(async (): Promise<Subscription[]> => {
Expand Down Expand Up @@ -89,6 +91,12 @@ function CampaignReview({ campaign, template }: { campaign: Campaign; template:
transactional: isTransactional,
subscription_id: isTransactional ? undefined : effectiveSubscriptionId || undefined,
variables: data.variables.filter((v) => v.name),
...(isEnterprise && {
variants: {
...variants,
options: (variants.options ?? []).filter((v) => v.key),
},
}),
})

setCampaign(updatedCampaign)
Expand Down Expand Up @@ -159,6 +167,27 @@ function CampaignReview({ campaign, template }: { campaign: Campaign; template:
/>
</FieldGroup>

{isEnterprise && (
<FieldGroup>
<Field className="gap-2">
<FieldLabel>
{t("campaign.variants.label", "Variants")}
</FieldLabel>
<FieldDescription>
{t(
"campaign.variants.description",
"Give a client this campaign in their own design or wording. Each variant gets its own templates.",
)}
</FieldDescription>
<CampaignVariants
value={variants}
templates={campaign.templates ?? []}
onChange={setVariants}
/>
</Field>
</FieldGroup>
)}

<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label htmlFor="transactional-toggle">
Expand Down
Loading
Loading