Skip to content
Form, Please

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.

Loading campaign builder…

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.