From 750af5e9cf122a0d1ac791a75e60357c4075f98b Mon Sep 17 00:00:00 2001 From: William Chi Date: Sun, 30 Aug 2026 00:12:08 -0400 Subject: [PATCH 1/3] feat(web): add email campaign admin UI Builds the campaign list and create/edit panel against the seven endpoints shipped in #365. Layout follows the Figma two-pane design: the list stays visible and shifts left while an inline panel expands beside it, rather than an overlay. - Typed hooks for list, get, create, update, send, and delete - Recipient groups are a union type, so a group the API cannot resolve is a compile error rather than a runtime 400 - Format has no default; picking wrong sends raw tags or unrendered markup, so it must be a deliberate choice - Subject and format are not in the Figma but the API requires both Send, delete, scheduling, and delivery stats are not wired yet. --- .../modules/EmailCampaigns/CampaignCard.tsx | 91 +++++++ .../EmailCampaigns/CampaignFormPanel.tsx | 228 ++++++++++++++++++ .../EmailCampaigns/EmailCampaignsPage.tsx | 124 ++++++++++ .../EmailCampaigns/hooks/useEmailCampaigns.ts | 180 ++++++++++++++ .../_protected/_admin/email-campaigns.tsx | 10 + 5 files changed, 633 insertions(+) create mode 100644 apps/web/src/modules/EmailCampaigns/CampaignCard.tsx create mode 100644 apps/web/src/modules/EmailCampaigns/CampaignFormPanel.tsx create mode 100644 apps/web/src/modules/EmailCampaigns/EmailCampaignsPage.tsx create mode 100644 apps/web/src/modules/EmailCampaigns/hooks/useEmailCampaigns.ts create mode 100644 apps/web/src/routes/_protected/_admin/email-campaigns.tsx diff --git a/apps/web/src/modules/EmailCampaigns/CampaignCard.tsx b/apps/web/src/modules/EmailCampaigns/CampaignCard.tsx new file mode 100644 index 00000000..8bd9e84f --- /dev/null +++ b/apps/web/src/modules/EmailCampaigns/CampaignCard.tsx @@ -0,0 +1,91 @@ +import { Badge } from "@/components/ui/Badge"; +import { Card } from "@/components/ui/Card"; +import { cn } from "@/utils/cn"; +import { format } from "date-fns"; +import type { CampaignStatus, EmailCampaign } from "./hooks/useEmailCampaigns"; + +const STATUS_STYLES: Record = { + draft: "bg-zinc-100 border-zinc-400 text-zinc-600", + scheduled: "bg-[#fef9c2] border-[#d08700] text-[#d08700]", + sending: "bg-blue-100 border-blue-700 text-blue-700", + sent: "bg-[#dcfce7] border-[#016630] text-[#016630]", + failed: "bg-red-100 border-red-700 text-red-700", +}; + +const STATUS_LABELS: Record = { + draft: "Draft", + scheduled: "Scheduled", + sending: "Sending", + sent: "Sent", + failed: "Failed", +}; + +function formatDay(value: string) { + return format(new Date(value), "yyyy-MM-dd"); +} + +interface CampaignCardProps { + campaign: EmailCampaign; + isSelected: boolean; + onSelect: (campaign: EmailCampaign) => void; +} + +export function CampaignCard({ + campaign, + isSelected, + onSelect, +}: CampaignCardProps) { + return ( + onSelect(campaign)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(campaign); + } + }} + className={cn( + "focus-visible:ring-button-primary w-full max-w-none cursor-pointer rounded-[6px] border-[1.5px] border-[#d1d5dc] bg-transparent px-[17px] py-5 shadow-none transition-colors hover:border-zinc-400 focus-visible:ring-2 focus-visible:outline-none sm:max-w-none dark:border-zinc-700 dark:hover:border-zinc-500", + isSelected && "border-[#2b7fff]", + )} + > +
+
+

+ {campaign.title} +

+ + {STATUS_LABELS[campaign.status]} + +
+
+ + {campaign.description && ( +

+ {campaign.description} +

+ )} + +

+ Created {formatDay(campaign.created_at)} + {campaign.status === "scheduled" && campaign.scheduled_at + ? ` · Scheduled for ${formatDay(campaign.scheduled_at)}` + : null} + {campaign.status === "sent" && campaign.sent_at + ? ` · Sent ${formatDay(campaign.sent_at)}` + : null} +

+ + {campaign.status === "failed" && campaign.last_error && ( +

{campaign.last_error}

+ )} +
+ ); +} diff --git a/apps/web/src/modules/EmailCampaigns/CampaignFormPanel.tsx b/apps/web/src/modules/EmailCampaigns/CampaignFormPanel.tsx new file mode 100644 index 00000000..3f539043 --- /dev/null +++ b/apps/web/src/modules/EmailCampaigns/CampaignFormPanel.tsx @@ -0,0 +1,228 @@ +import { Button } from "@/components/ui/Button"; +import { Label } from "@/components/ui/Field"; +import { cn } from "@/utils/cn"; +import { MultiSelect } from "@/components/ui/MultiSelect"; +import { TextField } from "@/components/ui/TextField"; +import TablerChevronLeft from "~icons/tabler/chevron-left"; +import { useState } from "react"; +import { + useCreateEmailCampaign, + useUpdateEmailCampaign, + type CampaignFormat, + type EmailCampaign, + type RecipientType, +} from "./hooks/useEmailCampaigns"; + +/** Only groups the API can actually resolve. Values match the recipient_type enum. */ +const RECIPIENT_OPTIONS: { value: RecipientType; label: string }[] = [ + { value: "admins", label: "Admins" }, + { value: "staff", label: "Staff" }, + { value: "visitors", label: "Visitors" }, + { value: "accepted_applicants", label: "Accepted Applicants" }, + { value: "waitlisted_applicants", label: "Waitlisted Applicants" }, + { value: "rejected_applicants", label: "Rejected Applicants" }, + { value: "interest_subscribers", label: "Interest Subscribers" }, +]; + +interface CampaignFormPanelProps { + hackathonId: string; + campaign: EmailCampaign | null; + onClose: () => void; +} + +export function CampaignFormPanel({ + hackathonId, + campaign, + onClose, +}: CampaignFormPanelProps) { + const isEdit = campaign !== null; + + const [recipients, setRecipients] = useState( + campaign?.recipient_types ?? [], + ); + const [title, setTitle] = useState(campaign?.title ?? ""); + const [subject, setSubject] = useState(campaign?.subject ?? ""); + const [description, setDescription] = useState(campaign?.description ?? ""); + const [body, setBody] = useState(campaign?.body ?? ""); + const [formatValue, setFormatValue] = useState( + campaign?.format ?? null, + ); + + const createCampaign = useCreateEmailCampaign(hackathonId); + const updateCampaign = useUpdateEmailCampaign(hackathonId); + + const isSaving = createCampaign.isPending || updateCampaign.isPending; + const saveError = createCampaign.error ?? updateCampaign.error; + + // The API rejects blank values, so mirror its requirements before sending. + const canSave = + recipients.length > 0 && + formatValue !== null && + title.trim() !== "" && + subject.trim() !== "" && + body.trim() !== ""; + + async function handleSave() { + if (formatValue === null) return; + + const payload = { + title: title.trim(), + subject: subject.trim(), + body, + format: formatValue, + recipientTypes: recipients, + description: description.trim() || undefined, + }; + + if (isEdit) { + await updateCampaign.mutateAsync({ + campaignId: campaign.id, + data: payload, + }); + } else { + await createCampaign.mutateAsync(payload); + } + onClose(); + } + + return ( +
+ + +

+ {isEdit ? "Edit an Email Campaign" : "Create an Email Campaign"} +

+ + + + + + {/* Everything below this line is delivered to recipients. */} +
+ + recipients.includes(o.value))} + onChange={(selected) => + setRecipients(selected.map((o) => o.value as RecipientType)) + } + /> + + + +
+ + {/* Two options only, so a segmented control beats a dropdown. */} +
+ {( + [ + { value: "html", label: "HTML" }, + { value: "text", label: "Plain text" }, + ] as { value: CampaignFormat; label: string }[] + ).map((option) => ( + + ))} +
+
+ +
+ +