Campaign builder
Seven campaign templates share an audience, schedule, and channel model. The schema is a discriminated union, so each template has its own fields and messages.
Try the complete flow
Switch between the loaded draft and a new campaign. Change the template to see the definition resolve a different section. Paid templates also show amount, currency, and recurring interval fields.
What this form demonstrates
- A discriminated union with seven template variants. One section shows the fields of each variant.
- A nested payment union for free, fixed or flexible, and recurring payments.
- A starting draft for each template. Each select and checkbox has a value when its section appears.
- Context-provided audience options.
- Separate create and update mutations outside form state.
form.api.watch()for the live campaign preview.- Transformed submit output with a derived channel list.
- Preserved values when a conditional section becomes hidden. The output contains only the selected template.
Copy the source
"use client"
import {
,
,
,
,
} from "@tanstack/react-query"
import { , type , type } from "form-please"
import { } from "form-please/default-slots"
import { } from "form-please/native-controls"
import { } from "react"
import { } from "zod"
// Fields that only some templates have. Each template selects its own entries.
const = {
: .({
: .().(1, "Write the email subject").(""),
: .().(1, "Write the preheader").(""),
}),
: .({
: .().(1, "Name the product").(""),
: .().(1, "Enter the catalog code").(""),
: .({ : "Set the opening stock" }).().(0),
: .(["limited", "general", "preorder"]),
}),
: .({
: .().(1, "Name the event").(""),
: .().(1, "Enter the venue").(""),
: .({ : "Set capacity" }).().(1),
: .(),
}),
: .({
: .().(1, "Describe the cause").(""),
: .({ : "Set the funding goal" }).(1),
: .().(1).(),
}),
: .({
: .().(1, "Name the course").(""),
: .({ : "Set the seat limit" }).().(1),
: .(),
}),
: .({
: .().(1, "Describe the update topic").(""),
: .().(1, "Name the moderator").(""),
:
.({ : "Set the response window" })
.()
.(1),
}),
: .({
: .().(1, "Write the feedback question").(""),
: .({ : "Set the response limit" }).().(1),
: .(),
}),
: .("mode", [
.({ : .("free") }),
.({
: .(["fixed", "flexible"]),
:
.({ : "Set an amount for this payment model" })
.(1),
: .(["USD", "EUR", "GBP"], { : "Choose a currency" }),
}),
.({
: .("recurring"),
:
.({ : "Set an amount for this payment model" })
.(1),
: .(["USD", "EUR", "GBP"], { : "Choose a currency" }),
: .(["monthly", "annual"], { : "Choose an interval" }),
}),
]),
}
const = .({
: .().(),
: .().(4, "Name this campaign"),
: .({
: .().(1, "Choose an audience"),
: .(["immediate", "scheduled", "rolling"]),
: .({
: .(),
: .(),
: .(),
}),
}),
: .({
: .().(1, "Choose a start date"),
: .().(),
}),
})
const =
.("template", [
.({
: .("newsletter"),
: .,
}),
.({
: .("product-launch"),
: .,
: .,
}),
.({
: .("event-invite"),
: .,
}),
.({
: .("fundraiser"),
: .,
: .,
}),
.({
: .("course-drop"),
: .,
: .,
}),
.({
: .("community-update"),
: .,
}),
.({
: .("feedback-pulse"),
: .,
}),
])
.(() => .(..).(), {
: "Choose at least one delivery channel",
: ["audience", "channels"],
})
.(
({ , }) =>
. === "immediate" ||
. === ||
. >= .,
{
: "The end date must follow the start date",
: ["schedule", "endsOn"],
},
)
.(() => ({
...,
: .(..)
.(([, ]) => )
.(([]) => ),
}))
type = <typeof >
type = <typeof >
type = {
readonly : readonly {
readonly : string
readonly : string
}[]
}
// A starting draft for every template. Selects and checkboxes need a value
// when their section appears. The schema output keeps only the selected
// template.
const = {
: {},
: { : "general" },
: { : true },
: {},
: { : true },
: {},
: { : true },
: { : "free" },
} satisfies {
readonly [ in keyof typeof ]: <
.<(typeof )[]>
>
}
const = {
...,
: ,
: "New community campaign",
: "newsletter",
: {
: "active-members",
: "scheduled",
: { : true, : false, : true },
},
: { : "2027-03-10", : "2027-03-21" },
: {
: "What we are making this month",
: "Three new ways to take part",
},
} satisfies
const = {
...,
: "campaign-204",
: "Spring material fund",
: "fundraiser",
: {
: "Fund free access to the shared material library",
: 18_000,
: 35,
},
: { : "flexible", : 10, : "USD" },
} satisfies
const = ({
: (),
: (),
})
const = .<>()
const = .(, () => [
.("campaign", {
: "Campaign foundation",
: 2,
: [
.("name", {
: "text",
: "Campaign name",
: "full",
}),
.("template", {
: "select",
: "Campaign template",
: [
{ : "newsletter", : "Newsletter" },
{ : "product-launch", : "Product launch" },
{ : "event-invite", : "Event invitation" },
{ : "fundraiser", : "Fundraiser" },
{ : "course-drop", : "Course release" },
{ : "community-update", : "Community update" },
{ : "feedback-pulse", : "Feedback pulse" },
],
}),
.("audience.segmentId", {
: "select",
: "Audience segment",
: ({ }) => .,
}),
.("audience.deliveryMode", {
: "select",
: "Delivery model",
: [
{ : "immediate", : "Immediate" },
{ : "scheduled", : "Scheduled window" },
{ : "rolling", : "Rolling audience entry" },
],
}),
.("audience.channels.email", {
: "checkbox",
: "Email",
}),
.("audience.channels.push", {
: "checkbox",
: "Push",
}),
.("audience.channels.web", {
: "checkbox",
: "Web inbox",
}),
.("schedule.startsOn", {
: "date",
: "Starts on",
}),
.("schedule.endsOn", {
: "date",
: "Ends on",
: () => .. !== "immediate",
}),
],
}),
.("newsletter", {
: "Newsletter content",
: 2,
: ({ }) => === "newsletter",
: [
.("newsletter.subject", {
: "text",
: "Subject",
}),
.("newsletter.preheader", {
: "text",
: "Preheader",
}),
],
}),
.("product-launch", {
: "Product launch",
: 2,
: ({ }) => === "product-launch",
: [
.("productLaunch.productName", {
: "text",
: "Product name",
}),
.("productLaunch.sku", {
: "text",
: "Catalog code",
}),
.("productLaunch.initialStock", {
: "number",
: "Opening stock",
: { : 0, : 1 },
}),
.("productLaunch.releaseKind", {
: "select",
: "Release kind",
: [
{ : "limited", : "Limited edition" },
{ : "general", : "General release" },
{ : "preorder", : "Preorder" },
],
}),
],
}),
.("event-invite", {
: "Event invitation",
: 2,
: ({ }) => === "event-invite",
: [
.("eventInvite.eventName", {
: "text",
: "Event name",
}),
.("eventInvite.venue", {
: "text",
: "Venue",
}),
.("eventInvite.capacity", {
: "number",
: "Capacity",
: { : 1, : 1 },
}),
.("eventInvite.requiresRegistration", {
: "checkbox",
: "Registration required",
}),
],
}),
.("fundraiser", {
: "Fundraiser",
: 2,
: ({ }) => === "fundraiser",
: [
.("fundraiser.cause", {
: "textarea",
: "Cause",
: "full",
: { : 3 },
}),
.("fundraiser.goalAmount", {
: "number",
: "Goal amount",
: { : 1, : 100 },
}),
.("fundraiser.suggestedContribution", {
: "number",
: "Suggested contribution",
: { : 1, : 5 },
}),
],
}),
.("course-drop", {
: "Course release",
: 2,
: ({ }) => === "course-drop",
: [
.("courseDrop.courseTitle", {
: "text",
: "Course title",
}),
.("courseDrop.seatLimit", {
: "number",
: "Seat limit",
: { : 1, : 1 },
}),
.("courseDrop.certificateIncluded", {
: "checkbox",
: "Include certificate",
}),
],
}),
.("community-update", {
: "Community update",
: 2,
: ({ }) => === "community-update",
: [
.("communityUpdate.topic", {
: "textarea",
: "Update topic",
: "full",
: { : 3 },
}),
.("communityUpdate.moderator", {
: "text",
: "Moderator",
}),
.("communityUpdate.responseWindowDays", {
: "number",
: "Response window in days",
: { : 1, : 1 },
}),
],
}),
.("feedback-pulse", {
: "Feedback pulse",
: 2,
: ({ }) => === "feedback-pulse",
: [
.("feedbackPulse.question", {
: "textarea",
: "Question",
: "full",
: { : 3 },
}),
.("feedbackPulse.responseLimit", {
: "number",
: "Response limit",
: { : 1, : 1 },
}),
.("feedbackPulse.anonymous", {
: "checkbox",
: "Allow anonymous responses",
}),
],
}),
.("payment", {
: "Payment model",
: 2,
: ({ }) => (),
: [
.("payment.mode", {
: "select",
: "Payment mode",
: [
{ : "free", : "Free" },
{ : "fixed", : "Fixed" },
{ : "flexible", : "Flexible contribution" },
{ : "recurring", : "Recurring" },
],
}),
.("payment.amount", {
: "number",
: "Amount",
: () => () !== "free",
: { : 1, : 1 },
}),
.("payment.currency", {
: "select",
: "Currency",
: () => () !== "free",
: { : { : "Choose a currency", : true } },
: [
{ : "USD", : "USD" },
{ : "EUR", : "EUR" },
{ : "GBP", : "GBP" },
],
}),
.("payment.interval", {
: "select",
: "Recurring interval",
: () => () === "recurring",
: { : { : "Choose an interval", : true } },
: [
{ : "monthly", : "Monthly" },
{ : "annual", : "Annual" },
],
}),
],
}),
])
export function () {
const [] = (
() => new ({ : { : { : false } } }),
)
return (
< ={}>
< />
</>
)
}
function () {
const [, ] = <"create" | "edit">("edit")
const = ({
: ["campaign-draft", "campaign-204"],
: () => (, 390),
})
const = ({
: ["campaign-segments"],
: () =>
(
[
{ : "active-members", : "Active members" },
{ : "new-readers", : "New readers" },
{ : "past-participants", : "Past participants" },
],
470,
),
})
const = ({
: (: ) =>
({ : `campaign-${.. + 700}` }, 430),
})
const = ({
: (: ) =>
({ : . ?? "campaign-missing" }, 360),
})
const [, ] = ("Loaded an editable campaign draft.")
async function (
: ,
: "create" | "edit",
) {
try {
if ( === "edit") {
const = await .()
(
`Updated ${.} with ${..} channel(s).`,
)
return
}
const = await .()
(
`Created ${.} with ${..} channel(s).`,
)
} catch {
(
"The campaign API did not respond. The editor remains available.",
)
}
}
const : = { : . ?? [] }
const = .(, {
: ,
,
: ({ }) => (, "create"),
})
const = .(, {
: ,
,
: ({ }) => (, "edit"),
})
if (. || .)
return (
< ="form-please-complex">
Loading campaign builder…
</>
)
if (. || .)
return (
< ="form-please-complex">
Could not load campaign resources.
</>
)
let =
let = "Create campaign"
if ( === "edit") {
=
= "Update campaign"
}
let =
if (. || .) {
= "Saving campaign…"
}
const = ..()
const = .(..)
.(([, ]) => )
.(([]) => )
return (
<
="Campaign builder example"
="form-please-complex"
>
< ="form-please-complex__kicker">
Seven-template campaign builder
</>
< ="form-please-complex__summary">
One shared audience and schedule model drives seven distinct payload
branches, conditional quantities, payment variants, and create/edit
mutations.
</>
< ="form-please-complex__mode">
<>Editor mode</>
<
={ === "edit"}
={() => ("edit")}
="button"
>
Edit loaded draft
</>
<
={ === "create"}
={() => ("create")}
="button"
>
Start new campaign
</>
</>
<.
="form-please-complex__form"
={}
={}
>
< ="form-please-complex__actions">
<. ="form-please-complex__primary">
{}
</.>
< ="polite">{}</>
</>
</.>
<
="Campaign preview"
="form-please-complex__preview"
>
<>{.}</>
<>
{.} · {.(", ") || "no channels"}
</>
</>
</>
)
}
function (: ["template"]): boolean {
return (
=== "fundraiser" ||
=== "course-drop" ||
=== "product-launch"
)
}
function (: ) {
if ("payment" in ) return ..
return "free"
}
function <>(: , : number): <> {
return new (() =>
.(() => (), ),
)
}Compare the other workflows in Examples.