plans
2026-05-11
2026 05 11 Form Self Contained Refactor

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)

FileActionReason
packages/backend/convex/domains/forms.tsMODIFYAdd dedicated mutations per form type
packages/backend/convex/schema.tsMODIFYAdd inquirySessions table if needed

Frontend (Forms)

FileActionReason
hooks/forms/use-artist-proposal-form.tsDELETEUnnecessary indirection
hooks/forms/use-contact-form.tsDELETEUnnecessary indirection
components/forms/proposal-form-components/base-inquiry-form.tsxDELETEWrapper adds indirection
components/forms/artist-proposal-form.tsxMODIFYUse dedicated mutation
components/forms/private-events-form.tsxMODIFYUse dedicated mutation
components/forms/venue-rental-form.tsxMODIFYUse dedicated mutation
components/forms/workshop-proposal-form.tsxMODIFYUse dedicated mutation
components/forms/host-an-event-form.tsxMODIFYUse dedicated mutation
components/forms/contact-form.tsxKEEPAlready has dedicated mutation

Keep (Reusable Components)

FileReason
components/forms/proposal-form-components/contact-form-success.tsxReusable success state
components/forms/proposal-form-components/form-fields.tsxValid reusable field components
components/forms/proposal-form-components/message-textarea.tsxValid 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 -20

Expected: 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 -10

Expected: 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>&1

Expected: No errors (only deprecation warnings acceptable)

  • Step 2: Run Convex codegen
cd packages/backend && npx convex codegen 2>&1 | head -20

Expected: Success

  • Step 3: Verify forms still compile
cd apps/frontend && npx tsc --noEmit 2>&1 | grep -i form | head -10

Expected: 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:

  1. Type-safe on both frontend and backend
  2. Zod validation at mutation level
  3. No string-based form type discrimination
  4. Each form is self-contained
  5. 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?