Form Self-Contained Refactor with Dedicated Convex Mutations
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Refactor all form components to be self-contained with dedicated Convex mutations per form type. Each form gets its own mutation with typed Zod schema validation.
Architecture: Each form will call its own dedicated mutation (submitArtistProposal, submitPrivateEvents, etc.) instead of a generic submitFormData with string-based form type discrimination. This provides type-safety end-to-end.
Tech Stack: React Hook Form, Zod, Convex mutations with Zod validation, Turnstile CAPTCHA
File Inventory
Backend (Convex)
| File | Action | Reason |
|---|---|---|
packages/backend/convex/domains/forms.ts | MODIFY | Add dedicated mutations per form type |
packages/backend/convex/schema.ts | MODIFY | Add inquirySessions table if needed |
Frontend (Forms)
| File | Action | Reason |
|---|---|---|
hooks/forms/use-artist-proposal-form.ts | DELETE | Unnecessary indirection |
hooks/forms/use-contact-form.ts | DELETE | Unnecessary indirection |
components/forms/proposal-form-components/base-inquiry-form.tsx | DELETE | Wrapper adds indirection |
components/forms/artist-proposal-form.tsx | MODIFY | Use dedicated mutation |
components/forms/private-events-form.tsx | MODIFY | Use dedicated mutation |
components/forms/venue-rental-form.tsx | MODIFY | Use dedicated mutation |
components/forms/workshop-proposal-form.tsx | MODIFY | Use dedicated mutation |
components/forms/host-an-event-form.tsx | MODIFY | Use dedicated mutation |
components/forms/contact-form.tsx | KEEP | Already has dedicated mutation |
Keep (Reusable Components)
| File | Reason |
|---|---|
components/forms/proposal-form-components/contact-form-success.tsx | Reusable success state |
components/forms/proposal-form-components/form-fields.tsx | Valid reusable field components |
components/forms/proposal-form-components/message-textarea.tsx | Valid reusable field component |
Backend: Add Dedicated Mutations
File: packages/backend/convex/domains/forms.ts
New mutation signatures:
// Artist Proposal
export const submitArtistProposal = zMutation({
args: {
fullName: z.string(),
email: z.string().email(),
phone: z.string(),
subject: z.string(),
artistName: z.string(),
performanceType: z.enum([
"MUSIC",
"COMEDY",
"MAGIC",
"THEATER",
"DANCE",
"OTHER",
]),
performanceLength: z.number().min(5).max(120),
stageRequirements: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
/* ... */
},
});
// Private Events
export const submitPrivateEvents = zMutation({
args: {
fullName: z.string(),
email: z.string().email(),
phone: z.string(),
subject: z.string(),
eventType: z.string(),
preferredDate: z.string().optional(),
guestCount: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
/* ... */
},
});
// Venue Rental
export const submitVenueRental = zMutation({
args: {
fullName: z.string(),
email: z.string().email(),
phone: z.string(),
subject: z.string(),
projectType: z.string(),
eventDate: z.string().optional(),
estimatedParticipants: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
/* ... */
},
});
// Workshop
export const submitWorkshop = zMutation({
args: {
fullName: z.string(),
email: z.string().email(),
phone: z.string(),
subject: z.string(),
workshopType: z.string(),
preferredDate: z.string().optional(),
estimatedParticipants: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
/* ... */
},
});
// Host An Event
export const submitHostAnEvent = zMutation({
args: {
fullName: z.string(),
email: z.string().email(),
phone: z.string(),
subject: z.string(),
eventType: z.string(),
eventDate: z.string().optional(),
guestCount: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
/* ... */
},
});Task 1: Add Dedicated Convex Mutations
Files:
-
Modify:
packages/backend/convex/domains/forms.ts -
Step 1: Read current forms.ts structure
head -140 packages/backend/convex/domains/forms.ts- Step 2: Add dedicated mutations after submitProposalForm (around line 574)
Add these mutations after the submitProposalForm function:
// ============================================================================
// Dedicated Form Mutations (Type-Safe)
// ============================================================================
// Submit artist proposal with typed schema
export const submitArtistProposal = zMutation({
args: {
fullName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1),
subject: z.string().min(1),
artistName: z.string().min(1),
performanceType: z.enum([
"MUSIC",
"COMEDY",
"MAGIC",
"THEATER",
"DANCE",
"OTHER",
]),
performanceLength: z.number().min(5).max(120),
stageRequirements: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
const { turnstileToken, ...data } = args;
if (turnstileToken) {
const { valid, error } = await verifyTurnstileToken(
turnstileToken,
"ARTIST_PROPOSAL",
);
if (!valid) throw new Error(`Turnstile verification failed: ${error}`);
}
const now = Date.now();
const sessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const id = await ctx.db.insert("formSessions", {
sessionId,
formType: "ARTIST_PROPOSAL",
data: JSON.stringify(data),
submitted: true,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("inquirySessions", {
formSessionId: id,
formType: "ARTIST_PROPOSAL",
status: "NEW",
createdAt: now,
updatedAt: now,
});
return { success: true, id };
},
});
// Submit private events with typed schema
export const submitPrivateEvents = zMutation({
args: {
fullName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1),
subject: z.string().min(1),
eventType: z.string().min(1),
preferredDate: z.string().optional(),
guestCount: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
const { turnstileToken, ...data } = args;
if (turnstileToken) {
const { valid, error } = await verifyTurnstileToken(
turnstileToken,
"PRIVATE_EVENTS",
);
if (!valid) throw new Error(`Turnstile verification failed: ${error}`);
}
const now = Date.now();
const sessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const id = await ctx.db.insert("formSessions", {
sessionId,
formType: "PRIVATE_EVENTS",
data: JSON.stringify(data),
submitted: true,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("inquirySessions", {
formSessionId: id,
formType: "PRIVATE_EVENTS",
status: "NEW",
createdAt: now,
updatedAt: now,
});
return { success: true, id };
},
});
// Submit venue rental with typed schema
export const submitVenueRental = zMutation({
args: {
fullName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1),
subject: z.string().min(1),
projectType: z.string().min(1),
eventDate: z.string().optional(),
estimatedParticipants: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
const { turnstileToken, ...data } = args;
if (turnstileToken) {
const { valid, error } = await verifyTurnstileToken(
turnstileToken,
"VENUE_RENTAL",
);
if (!valid) throw new Error(`Turnstile verification failed: ${error}`);
}
const now = Date.now();
const sessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const id = await ctx.db.insert("formSessions", {
sessionId,
formType: "VENUE_RENTAL",
data: JSON.stringify(data),
submitted: true,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("inquirySessions", {
formSessionId: id,
formType: "VENUE_RENTAL",
status: "NEW",
createdAt: now,
updatedAt: now,
});
return { success: true, id };
},
});
// Submit workshop with typed schema
export const submitWorkshop = zMutation({
args: {
fullName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1),
subject: z.string().min(1),
workshopType: z.string().min(1),
preferredDate: z.string().optional(),
estimatedParticipants: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
const { turnstileToken, ...data } = args;
if (turnstileToken) {
const { valid, error } = await verifyTurnstileToken(
turnstileToken,
"WORKSHOPS",
);
if (!valid) throw new Error(`Turnstile verification failed: ${error}`);
}
const now = Date.now();
const sessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const id = await ctx.db.insert("formSessions", {
sessionId,
formType: "WORKSHOPS",
data: JSON.stringify(data),
submitted: true,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("inquirySessions", {
formSessionId: id,
formType: "WORKSHOPS",
status: "NEW",
createdAt: now,
updatedAt: now,
});
return { success: true, id };
},
});
// Submit host an event with typed schema
export const submitHostAnEvent = zMutation({
args: {
fullName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(1),
subject: z.string().min(1),
eventType: z.string().min(1),
eventDate: z.string().optional(),
guestCount: z.string().optional(),
message: z.string().max(500),
turnstileToken: z.string().optional(),
},
handler: async (ctx, args) => {
const { turnstileToken, ...data } = args;
if (turnstileToken) {
const { valid, error } = await verifyTurnstileToken(
turnstileToken,
"HOST_AN_EVENT",
);
if (!valid) throw new Error(`Turnstile verification failed: ${error}`);
}
const now = Date.now();
const sessionId = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const id = await ctx.db.insert("formSessions", {
sessionId,
formType: "HOST_AN_EVENT",
data: JSON.stringify(data),
submitted: true,
expiresAt: now + FORM_SESSION_TTL,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("inquirySessions", {
formSessionId: id,
formType: "HOST_AN_EVENT",
status: "NEW",
createdAt: now,
updatedAt: now,
});
return { success: true, id };
},
});- Step 3: Verify Convex types generate correctly
cd packages/backend && npx convex codegen 2>&1 | head -20Expected: Success with no errors
- Step 4: Commit
git add packages/backend/convex/domains/forms.ts
git commit -m "feat(forms): add dedicated mutations per form type"Task 2: Delete Unnecessary Hooks and Wrapper
Files:
-
Delete:
apps/frontend/hooks/forms/use-artist-proposal-form.ts -
Delete:
apps/frontend/hooks/forms/use-contact-form.ts -
Delete:
apps/frontend/components/forms/proposal-form-components/base-inquiry-form.tsx -
Step 1: Delete files
rm apps/frontend/hooks/forms/use-artist-proposal-form.ts
rm apps/frontend/hooks/forms/use-contact-form.ts
rm apps/frontend/components/forms/proposal-form-components/base-inquiry-form.tsx- Step 2: Verify no TypeScript errors from deleted files
cd apps/frontend && npx tsc --noEmit 2>&1 | grep -E "(use-artist-proposal|use-contact|base-inquiry)" | head -10Expected: No errors related to deleted files
- Step 3: Commit
git add -A
git commit -m "refactor: delete unnecessary form hooks and BaseInquiryForm wrapper"Task 3: Refactor ArtistProposalForm
Files:
-
Modify:
apps/frontend/components/forms/artist-proposal-form.tsx -
Step 1: Rewrite with dedicated mutation
"use client";
/**
* ArtistProposalForm — artist proposal submission form
*
* @description Form for artists to submit proposals for performances at House of Legends.
* Uses artistProposalSchema for validation, dedicated Convex mutation for submission.
*
* @changes
* | Date | Author | Change |
* | ---------- | -------- | ------------------------------------- |
* | 2026-05-11 | Curly Ng | Refactor to dedicated mutation |
*/
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "convex/react";
import { Button } from "~/components/ui/button";
import { Form, FormField } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { ContactFormSuccess } from "~/components/forms/proposal-form-components/contact-form-success";
import {
NameFields,
ContactFields,
SelectButtonMatrix,
} from "~/components/forms/proposal-form-components/form-fields";
import { MessageTextarea } from "~/components/forms/proposal-form-components/message-textarea";
import {
artistProposalSchema,
performanceTypeOptions,
defaultArtistProposalValues,
type ArtistProposalFormData,
} from "~/lib/schemas/artist-proposal";
import { api } from "@packages/backend/convex/_generated/api";
import * as m from "~/src/paraglide/messages";
export function ArtistProposalForm() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const submitMutation = useMutation(api.domains.forms.submitArtistProposal);
const form = useForm<ArtistProposalFormData>({
resolver: zodResolver(artistProposalSchema),
defaultValues: defaultArtistProposalValues,
});
const onSubmit = async (data: ArtistProposalFormData) => {
setIsSubmitting(true);
try {
await submitMutation({
fullName: data.fullName,
email: data.email,
phone: data.phone,
subject: data.subject,
artistName: data.artistName,
performanceType: data.performanceType,
performanceLength: data.performanceLength,
stageRequirements: data.stageRequirements,
message: data.message,
});
setIsSubmitted(true);
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
{isSubmitted ? (
<ContactFormSuccess message={m.artist_proposal_successMessage()} />
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<NameFields<ArtistProposalFormData> control={form.control} />
<ContactFields<ArtistProposalFormData> control={form.control} />
<SelectButtonMatrix
control={form.control}
name="performanceType"
label={m.artist_proposal_performanceType()}
options={performanceTypeOptions}
otherLabel={m.artist_proposal_otherPerformance()}
/>
<FormField
control={form.control}
name="artistName"
render={({ field }) => (
<div className="space-y-2">
<label className="text-sm font-medium">
{m.artist_proposal_artistName()}
</label>
<Input
placeholder={m.artist_proposal_artistNamePlaceholder()}
{...field}
/>
</div>
)}
/>
<FormField
control={form.control}
name="videoLink"
render={({ field }) => (
<div className="space-y-2">
<label className="text-sm font-medium">
{m.artist_proposal_videoLink()}
</label>
<Input type="url" placeholder="https://..." {...field} />
</div>
)}
/>
<MessageTextarea<ArtistProposalFormData>
control={form.control}
name="message"
label={m.artist_proposal_tellUsMore()}
placeholder={m.artist_proposal_tellUsMorePlaceholder()}
maxLength={500}
rows={4}
/>
<Button
type="submit"
disabled={isSubmitting}
className="w-full bg-primary hover:bg-primary/90"
>
{isSubmitting
? m.common_buttons_submitting()
: m.common_buttons_submit()}
</Button>
</form>
)}
</Form>
);
}- Step 2: Verify no TypeScript errors
cd apps/frontend && npx tsc --noEmit 2>&1 | head -20- Step 3: Commit
git add apps/frontend/components/forms/artist-proposal-form.tsx
git commit -m "refactor(artist-proposal): use dedicated submitArtistProposal mutation"Task 4: Refactor PrivateEventsForm
Files:
-
Modify:
apps/frontend/components/forms/private-events-form.tsx -
Step 1: Rewrite with dedicated mutation and inline Turnstile
"use client";
/**
* PrivateEventsForm — private events inquiry form
*
* @description Form for submitting inquiries about private events at House of Legends.
* Uses privateEventsSchema for validation, dedicated Convex mutation for submission.
*
* @changes
* | Date | Author | Change |
* | ---------- | -------- | ------------------------------------- |
* | 2026-05-11 | Curly Ng | Refactor to dedicated mutation |
*/
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "convex/react";
import { api } from "@packages/backend/convex/_generated/api";
import {
NameFields,
ContactFields,
SelectButtonMatrix,
DatePickerField,
NumberField,
} from "~/components/forms/proposal-form-components/form-fields";
import { MessageTextarea } from "~/components/forms/proposal-form-components/message-textarea";
import { ContactFormSuccess } from "~/components/forms/proposal-form-components/contact-form-success";
import { Button } from "~/components/ui/button";
import { Form } from "~/components/ui/form";
import {
privateEventsSchema,
type PrivateEventsFormData,
} from "~/lib/schemas/proposal";
import * as m from "~/src/paraglide/messages";
const SCRIPT_ID = "turnstile-private-events-script";
const TURNSTILE_SITE_KEY =
typeof window !== "undefined"
? (process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "")
: "";
let widgetId: string | null = null;
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve) => {
if (document.getElementById(SCRIPT_ID)) {
resolve();
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.onload = () => resolve();
document.head.appendChild(script);
});
}
const eventTypes = [
{ value: "corporate", label: () => m.private_event_type_corporate() },
{ value: "private", label: () => m.private_event_type_private() },
{ value: "cocktail", label: () => m.private_event_type_cocktail() },
{ value: "dinner", label: () => m.private_event_type_dinner() },
{ value: "other", label: () => m.private_event_type_other() },
] as const;
const defaultValues: PrivateEventsFormData = {
fullName: "",
email: "",
phone: "",
subject: "",
eventType: "",
preferredDate: "",
guestCount: "",
message: "",
};
export function PrivateEventsForm() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [turnstileReady, setTurnstileReady] = useState(false);
const submitMutation = useMutation(api.domains.forms.submitPrivateEvents);
const form = useForm<PrivateEventsFormData>({
resolver: zodResolver(privateEventsSchema),
defaultValues,
});
useEffect(() => {
void loadTurnstileScript().then(() => {
if (typeof window !== "undefined") {
const w = window as Window & {
turnstile?: {
render: (
el: string,
opts: { sitekey: string; theme: string; callback: () => void },
) => string;
remove: (id: string) => void;
getResponse: (id: string) => string;
};
};
if (w.turnstile) {
widgetId = w.turnstile.render("#turnstile-private-events", {
sitekey: TURNSTILE_SITE_KEY,
theme: "dark",
callback: () => setTurnstileReady(true),
});
} else {
setTurnstileReady(true);
}
}
});
return () => {
if (widgetId) {
const w = window as Window & {
turnstile?: { remove: (id: string) => void };
};
w.turnstile?.remove(widgetId!);
}
};
}, []);
const onSubmit = async (data: PrivateEventsFormData) => {
const w = window as Window & {
turnstile?: { getResponse: (id: string) => string };
};
const turnstileToken = w.turnstile?.getResponse(widgetId ?? "") ?? "";
setIsSubmitting(true);
try {
await submitMutation({
fullName: data.fullName,
email: data.email,
phone: data.phone,
subject: data.subject,
eventType: data.eventType,
preferredDate: data.preferredDate,
guestCount: data.guestCount,
message: data.message,
turnstileToken,
});
setIsSubmitted(true);
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
{isSubmitted ? (
<ContactFormSuccess message={m.inquiry_form_thankYou()} />
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<NameFields<PrivateEventsFormData> control={form.control} />
<ContactFields<PrivateEventsFormData> control={form.control} />
<SelectButtonMatrix
control={form.control}
name="eventType"
label={m.private_event_type_label()}
options={eventTypes}
otherLabel={m.private_event_type_otherDescription()}
/>
<div className="grid md:grid-cols-2 gap-4">
<DatePickerField
control={form.control}
name="preferredDate"
label={m.private_event_preferredDate()}
/>
<NumberField
control={form.control}
name="guestCount"
label={m.private_event_guestCount()}
placeholder={m.private_event_guestCountPlaceholder()}
min={1}
max={200}
/>
</div>
<MessageTextarea
control={form.control}
name="message"
label={m.private_event_tellUsMore()}
placeholder={m.private_event_tellUsMorePlaceholder()}
maxLength={500}
rows={4}
/>
<div id="turnstile-private-events" className="hidden" />
<Button
type="submit"
disabled={isSubmitting || !turnstileReady}
className="w-full bg-primary hover:bg-primary/90"
>
{isSubmitting
? m.common_buttons_submitting()
: m.common_buttons_submit()}
</Button>
</form>
)}
</Form>
);
}- Step 2: Verify no TypeScript errors
cd apps/frontend && npx tsc --noEmit 2>&1 | head -20- Step 3: Commit
git add apps/frontend/components/forms/private-events-form.tsx
git commit -m "refactor(private-events): use dedicated submitPrivateEvents mutation"Task 5: Refactor VenueRentalForm
Files:
-
Modify:
apps/frontend/components/forms/venue-rental-form.tsx -
Step 1: Rewrite with dedicated mutation and inline Turnstile
"use client";
/**
* VenueRentalForm — venue rental inquiry form
*
* @description Form for submitting inquiries about renting the House of Legends venue.
* Uses venueRentalSchema for validation, dedicated Convex mutation for submission.
*
* @changes
* | Date | Author | Change |
* | ---------- | -------- | ------------------------------------- |
* | 2026-05-11 | Curly Ng | Refactor to dedicated mutation |
*/
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "convex/react";
import { api } from "@packages/backend/convex/_generated/api";
import {
NameFields,
ContactFields,
SelectButtonMatrix,
DatePickerField,
NumberField,
} from "~/components/forms/proposal-form-components/form-fields";
import { MessageTextarea } from "~/components/forms/proposal-form-components/message-textarea";
import { ContactFormSuccess } from "~/components/forms/proposal-form-components/contact-form-success";
import { Button } from "~/components/ui/button";
import { Form } from "~/components/ui/form";
import {
venueRentalSchema,
type VenueRentalFormData,
} from "~/lib/schemas/venue-rental";
import { venueRentalDefaultValues } from "~/lib/constants/venue-rental";
import * as m from "~/src/paraglide/messages";
const SCRIPT_ID = "turnstile-venue-rental-script";
const TURNSTILE_SITE_KEY =
typeof window !== "undefined"
? (process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "")
: "";
let widgetId: string | null = null;
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve) => {
if (document.getElementById(SCRIPT_ID)) {
resolve();
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.onload = () => resolve();
document.head.appendChild(script);
});
}
const projectTypesTranslated = [
{ value: "photo", label: () => m.venue_rental_type_photo() },
{ value: "video", label: () => m.venue_rental_type_video() },
{ value: "rehearsal", label: () => m.venue_rental_type_rehearsal() },
{ value: "workshop", label: () => m.venue_rental_type_workshop() },
{ value: "private", label: () => m.venue_rental_type_private() },
{ value: "other", label: () => m.venue_rental_type_other() },
] as const;
export function VenueRentalForm() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [turnstileReady, setTurnstileReady] = useState(false);
const submitMutation = useMutation(api.domains.forms.submitVenueRental);
const form = useForm<VenueRentalFormData>({
resolver: zodResolver(venueRentalSchema),
defaultValues: venueRentalDefaultValues,
});
useEffect(() => {
void loadTurnstileScript().then(() => {
if (typeof window !== "undefined") {
const w = window as Window & {
turnstile?: {
render: (
el: string,
opts: { sitekey: string; theme: string; callback: () => void },
) => string;
remove: (id: string) => void;
getResponse: (id: string) => string;
};
};
if (w.turnstile) {
widgetId = w.turnstile.render("#turnstile-venue-rental", {
sitekey: TURNSTILE_SITE_KEY,
theme: "dark",
callback: () => setTurnstileReady(true),
});
} else {
setTurnstileReady(true);
}
}
});
return () => {
if (widgetId) {
const w = window as Window & {
turnstile?: { remove: (id: string) => void };
};
w.turnstile?.remove(widgetId!);
}
};
}, []);
const onSubmit = async (data: VenueRentalFormData) => {
const w = window as Window & {
turnstile?: { getResponse: (id: string) => string };
};
const turnstileToken = w.turnstile?.getResponse(widgetId ?? "") ?? "";
setIsSubmitting(true);
try {
await submitMutation({
fullName: data.fullName,
email: data.email,
phone: data.phone,
subject: data.subject,
projectType: data.projectType,
eventDate: data.eventDate,
estimatedParticipants: data.estimatedParticipants,
message: data.message,
turnstileToken,
});
setIsSubmitted(true);
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
{isSubmitted ? (
<ContactFormSuccess message={m.inquiry_form_thankYou()} />
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<NameFields<VenueRentalFormData> control={form.control} />
<ContactFields<VenueRentalFormData> control={form.control} />
<SelectButtonMatrix
control={form.control}
name="projectType"
label={m.venue_rental_projectType()}
options={projectTypesTranslated}
otherLabel={m.venue_rental_otherDescription()}
/>
<div className="grid md:grid-cols-2 gap-4">
<DatePickerField<VenueRentalFormData>
control={form.control}
name="eventDate"
label={m.venue_rental_preferredDate()}
/>
<NumberField<VenueRentalFormData>
control={form.control}
name="estimatedParticipants"
label={m.venue_rental_participants()}
placeholder={m.venue_rental_participantsPlaceholder()}
min={1}
max={200}
/>
</div>
<MessageTextarea<VenueRentalFormData>
control={form.control}
name="message"
label={m.venue_rental_tellUsMore()}
placeholder={m.venue_rental_tellUsMorePlaceholder()}
maxLength={500}
rows={4}
/>
<div id="turnstile-venue-rental" className="hidden" />
<Button
type="submit"
disabled={isSubmitting || !turnstileReady}
className="w-full bg-primary hover:bg-primary/90"
>
{isSubmitting
? m.common_buttons_submitting()
: m.common_buttons_submit()}
</Button>
</form>
)}
</Form>
);
}- Step 2: Verify no TypeScript errors
cd apps/frontend && npx tsc --noEmit 2>&1 | head -20- Step 3: Commit
git add apps/frontend/components/forms/venue-rental-form.tsx
git commit -m "refactor(venue-rental): use dedicated submitVenueRental mutation"Task 6: Refactor WorkshopProposalForm
Files:
-
Modify:
apps/frontend/components/forms/workshop-proposal-form.tsx -
Step 1: Rewrite with dedicated mutation and inline Turnstile
"use client";
/**
* WorkshopProposalForm — workshop proposal submission form
*
* @description Form for submitting workshop proposals at House of Legends.
* Uses workshopProposalSchema for validation, dedicated Convex mutation for submission.
*
* @changes
* | Date | Author | Change |
* | ---------- | -------- | ------------------------------------- |
* | 2026-05-11 | Curly Ng | Refactor to dedicated mutation |
*/
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "convex/react";
import { api } from "@packages/backend/convex/_generated/api";
import {
NameFields,
ContactFields,
SelectButtonMatrix,
DatePickerField,
NumberField,
} from "~/components/forms/proposal-form-components/form-fields";
import { MessageTextarea } from "~/components/forms/proposal-form-components/message-textarea";
import { ContactFormSuccess } from "~/components/forms/proposal-form-components/contact-form-success";
import { Button } from "~/components/ui/button";
import { Form } from "~/components/ui/form";
import {
workshopProposalSchema,
type WorkshopProposalFormData,
} from "~/lib/schemas/proposal";
import * as m from "~/src/paraglide/messages";
const SCRIPT_ID = "turnstile-workshop-script";
const TURNSTILE_SITE_KEY =
typeof window !== "undefined"
? (process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "")
: "";
let widgetId: string | null = null;
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve) => {
if (document.getElementById(SCRIPT_ID)) {
resolve();
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.onload = () => resolve();
document.head.appendChild(script);
});
}
const workshopTypes = [
{ value: "dance", label: () => m.workshop_type_dance() },
{ value: "theatre", label: () => m.workshop_type_theatre() },
{ value: "stage", label: () => m.workshop_type_stage() },
{ value: "cultural", label: () => m.workshop_type_cultural() },
{ value: "storytelling", label: () => m.workshop_type_storytelling() },
{ value: "masterclass", label: () => m.workshop_type_masterclass() },
{ value: "other", label: () => m.workshop_type_other() },
] as const;
const defaultValues: WorkshopProposalFormData = {
fullName: "",
email: "",
phone: "",
subject: "",
workshopType: "",
preferredDate: "",
estimatedParticipants: "",
message: "",
};
export function WorkshopProposalForm() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [turnstileReady, setTurnstileReady] = useState(false);
const submitMutation = useMutation(api.domains.forms.submitWorkshop);
const form = useForm<WorkshopProposalFormData>({
resolver: zodResolver(workshopProposalSchema),
defaultValues,
});
useEffect(() => {
void loadTurnstileScript().then(() => {
if (typeof window !== "undefined") {
const w = window as Window & {
turnstile?: {
render: (
el: string,
opts: { sitekey: string; theme: string; callback: () => void },
) => string;
remove: (id: string) => void;
getResponse: (id: string) => string;
};
};
if (w.turnstile) {
widgetId = w.turnstile.render("#turnstile-workshop", {
sitekey: TURNSTILE_SITE_KEY,
theme: "dark",
callback: () => setTurnstileReady(true),
});
} else {
setTurnstileReady(true);
}
}
});
return () => {
if (widgetId) {
const w = window as Window & {
turnstile?: { remove: (id: string) => void };
};
w.turnstile?.remove(widgetId!);
}
};
}, []);
const onSubmit = async (data: WorkshopProposalFormData) => {
const w = window as Window & {
turnstile?: { getResponse: (id: string) => string };
};
const turnstileToken = w.turnstile?.getResponse(widgetId ?? "") ?? "";
setIsSubmitting(true);
try {
await submitMutation({
fullName: data.fullName,
email: data.email,
phone: data.phone,
subject: data.subject,
workshopType: data.workshopType,
preferredDate: data.preferredDate,
estimatedParticipants: data.estimatedParticipants,
message: data.message,
turnstileToken,
});
setIsSubmitted(true);
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
{isSubmitted ? (
<ContactFormSuccess message={m.inquiry_form_thankYou()} />
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<NameFields<WorkshopProposalFormData> control={form.control} />
<ContactFields<WorkshopProposalFormData> control={form.control} />
<SelectButtonMatrix
control={form.control}
name="workshopType"
label={m.workshop_type_label()}
options={workshopTypes}
otherLabel={m.workshop_type_otherDescription()}
/>
<div className="grid md:grid-cols-2 gap-4">
<DatePickerField<WorkshopProposalFormData>
control={form.control}
name="preferredDate"
label={m.workshop_preferredDate()}
/>
<NumberField<WorkshopProposalFormData>
control={form.control}
name="estimatedParticipants"
label={m.workshop_participants()}
placeholder={m.workshop_participantsPlaceholder()}
min={1}
max={200}
/>
</div>
<MessageTextarea<WorkshopProposalFormData>
control={form.control}
name="message"
label={m.workshop_tellUsMore()}
placeholder={m.workshop_tellUsMorePlaceholder()}
maxLength={500}
rows={4}
/>
<div id="turnstile-workshop" className="hidden" />
<Button
type="submit"
disabled={isSubmitting || !turnstileReady}
className="w-full bg-primary hover:bg-primary/90"
>
{isSubmitting
? m.common_buttons_submitting()
: m.common_buttons_submit()}
</Button>
</form>
)}
</Form>
);
}- Step 2: Verify no TypeScript errors
cd apps/frontend && npx tsc --noEmit 2>&1 | head -20- Step 3: Commit
git add apps/frontend/components/forms/workshop-proposal-form.tsx
git commit -m "refactor(workshop): use dedicated submitWorkshop mutation"Task 7: Refactor HostAnEventForm
Files:
-
Modify:
apps/frontend/components/forms/host-an-event-form.tsx -
Step 1: Rewrite with dedicated mutation and inline Turnstile
"use client";
/**
* HostAnEventForm — host an event inquiry form
*
* @description Form for submitting inquiries about hosting events at House of Legends.
* Uses hostAnEventSchema for validation, dedicated Convex mutation for submission.
*
* @changes
* | Date | Author | Change |
* | ---------- | -------- | ------------------------------------- |
* | 2026-05-11 | Curly Ng | Refactor to dedicated mutation |
*/
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "convex/react";
import { api } from "@packages/backend/convex/_generated/api";
import {
NameFields,
ContactFields,
SelectButtonMatrix,
DatePickerField,
NumberField,
} from "~/components/forms/proposal-form-components/form-fields";
import { MessageTextarea } from "~/components/forms/proposal-form-components/message-textarea";
import { ContactFormSuccess } from "~/components/forms/proposal-form-components/contact-form-success";
import { Button } from "~/components/ui/button";
import { Form } from "~/components/ui/form";
import {
hostAnEventSchema,
type HostAnEventFormData,
} from "~/lib/schemas/proposal";
import * as m from "~/src/paraglide/messages";
const SCRIPT_ID = "turnstile-host-event-script";
const TURNSTILE_SITE_KEY =
typeof window !== "undefined"
? (process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "")
: "";
let widgetId: string | null = null;
function loadTurnstileScript(): Promise<void> {
return new Promise((resolve) => {
if (document.getElementById(SCRIPT_ID)) {
resolve();
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.onload = () => resolve();
document.head.appendChild(script);
});
}
const eventTypes = [
{ value: "private", label: () => m.host_event_type_private() },
{ value: "venue", label: () => m.host_event_type_venue() },
{ value: "workshop", label: () => m.host_event_type_workshop() },
{ value: "performance", label: () => m.host_event_type_performance() },
{ value: "other", label: () => m.host_event_type_other() },
] as const;
const defaultValues: HostAnEventFormData = {
fullName: "",
email: "",
phone: "",
subject: "",
eventType: "",
eventDate: "",
guestCount: "",
message: "",
};
export function HostAnEventForm() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [turnstileReady, setTurnstileReady] = useState(false);
const submitMutation = useMutation(api.domains.forms.submitHostAnEvent);
const form = useForm<HostAnEventFormData>({
resolver: zodResolver(hostAnEventSchema),
defaultValues,
});
useEffect(() => {
void loadTurnstileScript().then(() => {
if (typeof window !== "undefined") {
const w = window as Window & {
turnstile?: {
render: (
el: string,
opts: { sitekey: string; theme: string; callback: () => void },
) => string;
remove: (id: string) => void;
getResponse: (id: string) => string;
};
};
if (w.turnstile) {
widgetId = w.turnstile.render("#turnstile-host-event", {
sitekey: TURNSTILE_SITE_KEY,
theme: "dark",
callback: () => setTurnstileReady(true),
});
} else {
setTurnstileReady(true);
}
}
});
return () => {
if (widgetId) {
const w = window as Window & {
turnstile?: { remove: (id: string) => void };
};
w.turnstile?.remove(widgetId!);
}
};
}, []);
const onSubmit = async (data: HostAnEventFormData) => {
const w = window as Window & {
turnstile?: { getResponse: (id: string) => string };
};
const turnstileToken = w.turnstile?.getResponse(widgetId ?? "") ?? "";
setIsSubmitting(true);
try {
await submitMutation({
fullName: data.fullName,
email: data.email,
phone: data.phone,
subject: data.subject,
eventType: data.eventType,
eventDate: data.eventDate,
guestCount: data.guestCount,
message: data.message,
turnstileToken,
});
setIsSubmitted(true);
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
{isSubmitted ? (
<ContactFormSuccess message={m.inquiry_form_thankYou()} />
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<NameFields<HostAnEventFormData> control={form.control} />
<ContactFields<HostAnEventFormData> control={form.control} />
<SelectButtonMatrix
control={form.control}
name="eventType"
label={m.host_event_type_label()}
options={eventTypes}
otherLabel={m.host_event_type_otherDescription()}
/>
<div className="grid md:grid-cols-2 gap-4">
<DatePickerField
control={form.control}
name="eventDate"
label={m.host_event_preferredDate()}
/>
<NumberField
control={form.control}
name="guestCount"
label={m.host_event_guestCount()}
placeholder={m.host_event_guestCountPlaceholder()}
min={1}
max={200}
/>
</div>
<MessageTextarea
control={form.control}
name="message"
label={m.host_event_tellUsMore()}
placeholder={m.host_event_tellUsMorePlaceholder()}
maxLength={500}
rows={4}
/>
<div id="turnstile-host-event" className="hidden" />
<Button
type="submit"
disabled={isSubmitting || !turnstileReady}
className="w-full bg-primary hover:bg-primary/90"
>
{isSubmitting
? m.common_buttons_submitting()
: m.common_buttons_submit()}
</Button>
</form>
)}
</Form>
);
}- Step 2: Verify no TypeScript errors
cd apps/frontend && npx tsc --noEmit 2>&1 | head -20- Step 3: Commit
git add apps/frontend/components/forms/host-an-event-form.tsx
git commit -m "refactor(host-an-event): use dedicated submitHostAnEvent mutation"Task 8: Final Verification
- Step 1: Run full TypeScript check
cd apps/frontend && npx tsc --noEmit 2>&1Expected: No errors (only deprecation warnings acceptable)
- Step 2: Run Convex codegen
cd packages/backend && npx convex codegen 2>&1 | head -20Expected: Success
- Step 3: Verify forms still compile
cd apps/frontend && npx tsc --noEmit 2>&1 | grep -i form | head -10Expected: No errors related to forms
- Step 4: Commit final changes
git add -A
git commit -m "refactor: complete form self-contained refactor with dedicated mutations"Architecture Summary
Before:
// Generic mutation with string-based form type
await submitForm({
formType: "ARTIST_PROPOSAL", // string discrimination
data: JSON.stringify({ ... }), // no type safety
});After:
// Dedicated mutation with typed arguments
await submitArtistProposal({
fullName: data.fullName,
email: data.email,
phone: data.phone,
subject: data.subject,
artistName: data.artistName,
performanceType: data.performanceType,
performanceLength: data.performanceLength,
stageRequirements: data.stageRequirements,
message: data.message,
turnstileToken,
});Benefits:
- Type-safe on both frontend and backend
- Zod validation at mutation level
- No string-based form type discrimination
- Each form is self-contained
- Mutations are easily debuggable
Plan complete and saved to docs/superpowers/plans/2026-05-11-form-self-contained-refactor.md.
Two execution options:
1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration
2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?