From e5b6cac4809a07183cffcda1976d87dad3d061fb Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Tue, 21 Apr 2026 12:02:25 +0100 Subject: [PATCH 1/3] feat: add /volunteer and /speakers forms with Google Sheets + email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two public recruitment pages for the Codú community: - /volunteer — marketing/events volunteer sign-up - /speakers — pitch a talk (1–3 talks per submission, dynamic repeater) Both forms: - Submit via a tRPC mutation (volunteer.submit / speaker.submit) - Append a row to a shared "Codú Submissions" Google Sheet tab, and send an admin email notification in parallel via Promise.allSettled so a failure in one channel doesn't block the other - Include an invisible honeypot field to catch naive bots - Match the existing dark-theme Tailwind UI and use the ui-components primitives (Input, Textarea, Field, ErrorMessage) - Validate client-side with Zod (manual safeParse, matching the sponsor form pattern) and server-side via tRPC input schemas Google Sheets plumbing is generic — utils/googleSheets.ts exposes appendRowToSubmissionsSheet({ tab, values }) using a single service- account JWT, lazily instantiated and cached. Tab names live in config/submissions.ts so they're consistent across environments; only the sheet ID and service-account creds come from env. SEO: - Keyword-rich metadata + OG/Twitter cards on both pages - JobPosting JSON-LD (employmentType: VOLUNTEER) for richer SERP cards - /volunteer and /speakers added to sitemap Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/speakers/_client.tsx | 31 ++ app/(app)/speakers/page.tsx | 69 ++++ app/(app)/volunteer/_client.tsx | 31 ++ app/(app)/volunteer/page.tsx | 69 ++++ app/sitemap.ts | 2 + components/Speaker/SpeakerForm.tsx | 379 ++++++++++++++++++ components/Volunteer/VolunteerForm.tsx | 285 +++++++++++++ config/submissions.ts | 7 + package-lock.json | 311 +++++++++++++- package.json | 1 + schema/speaker.ts | 105 +++++ schema/volunteer.ts | 77 ++++ server/api/router/index.ts | 4 + server/api/router/speaker.ts | 132 ++++++ server/api/router/volunteer.ts | 117 ++++++ .../createSpeakerApplicationEmailTemplate.ts | 118 ++++++ ...createVolunteerApplicationEmailTemplate.ts | 94 +++++ utils/googleSheets.ts | 55 +++ 18 files changed, 1869 insertions(+), 18 deletions(-) create mode 100644 app/(app)/speakers/_client.tsx create mode 100644 app/(app)/speakers/page.tsx create mode 100644 app/(app)/volunteer/_client.tsx create mode 100644 app/(app)/volunteer/page.tsx create mode 100644 components/Speaker/SpeakerForm.tsx create mode 100644 components/Volunteer/VolunteerForm.tsx create mode 100644 config/submissions.ts create mode 100644 schema/speaker.ts create mode 100644 schema/volunteer.ts create mode 100644 server/api/router/speaker.ts create mode 100644 server/api/router/volunteer.ts create mode 100644 utils/createSpeakerApplicationEmailTemplate.ts create mode 100644 utils/createVolunteerApplicationEmailTemplate.ts create mode 100644 utils/googleSheets.ts diff --git a/app/(app)/speakers/_client.tsx b/app/(app)/speakers/_client.tsx new file mode 100644 index 00000000..e8ed9cd0 --- /dev/null +++ b/app/(app)/speakers/_client.tsx @@ -0,0 +1,31 @@ +import { SpeakerForm } from "@/components/Speaker/SpeakerForm"; + +export function SpeakersClient() { + return ( +
+
+
+

+ Speak at Codú +

+

+ Pitch a talk at a Codú meetup +

+

+ Codú runs regular meetups across Ireland and we're always + looking for speakers. Whether it's your first talk or your + fiftieth, we'd love to hear your pitch. Propose a talk (or up + to three) and we'll be in touch. +

+
+ + + +

+ Takes about 3 minutes. First-time speakers welcome — we'll help + you prep. +

+
+
+ ); +} diff --git a/app/(app)/speakers/page.tsx b/app/(app)/speakers/page.tsx new file mode 100644 index 00000000..91531fb1 --- /dev/null +++ b/app/(app)/speakers/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import { JsonLd } from "@/components/JsonLd"; +import { SpeakersClient } from "./_client"; + +const PAGE_URL = "https://www.codu.co/speakers"; +const PAGE_TITLE = "Speak at Codú — Pitch a Talk for Our Meetups"; +const PAGE_DESCRIPTION = + "Pitch a talk at a Codú meetup. First-time speakers welcome. We run regular developer meetups across Ireland and are always looking for people to share what they've built, learned, or broken."; + +export const metadata: Metadata = { + title: PAGE_TITLE, + description: PAGE_DESCRIPTION, + keywords: [ + "Codú speaker", + "tech meetup speaker Ireland", + "developer meetup Dublin", + "first time speaker", + "web development talk", + "speak at meetup Ireland", + ], + alternates: { canonical: PAGE_URL }, + robots: { index: true, follow: true }, + openGraph: { + title: "Speak at Codú", + description: PAGE_DESCRIPTION, + url: PAGE_URL, + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Speak at Codú", + description: PAGE_DESCRIPTION, + }, +}; + +const speakerJsonLd = { + "@context": "https://schema.org", + "@type": "JobPosting", + title: "Speaker — Codú Meetups", + description: PAGE_DESCRIPTION, + employmentType: "VOLUNTEER", + hiringOrganization: { + "@type": "Organization", + name: "Codú", + sameAs: "https://www.codu.co", + logo: "https://www.codu.co/images/codu-logo.png", + }, + jobLocation: { + "@type": "Place", + address: { + "@type": "PostalAddress", + addressLocality: "Dublin", + addressCountry: "IE", + }, + }, + applicantLocationRequirements: { "@type": "Country", name: "Worldwide" }, + jobLocationType: "TELECOMMUTE", + datePosted: new Date().toISOString().split("T")[0], + url: PAGE_URL, +}; + +export default function SpeakersPage() { + return ( + <> + + + + ); +} diff --git a/app/(app)/volunteer/_client.tsx b/app/(app)/volunteer/_client.tsx new file mode 100644 index 00000000..a8118e3f --- /dev/null +++ b/app/(app)/volunteer/_client.tsx @@ -0,0 +1,31 @@ +import { VolunteerForm } from "@/components/Volunteer/VolunteerForm"; + +export function VolunteerClient() { + return ( +
+
+
+

+ Volunteer with Codú +

+

+ Help us build Ireland's largest web dev community +

+

+ Codú is Ireland's largest web dev community — thousands of + developers, regular meetups, and a newsletter across the Irish tech + ecosystem. We're opening volunteer spots for people interested + in marketing and events. +

+
+ + + +

+ Takes about 3 minutes. We read every application and reply within 2 + weeks. +

+
+
+ ); +} diff --git a/app/(app)/volunteer/page.tsx b/app/(app)/volunteer/page.tsx new file mode 100644 index 00000000..815c21a4 --- /dev/null +++ b/app/(app)/volunteer/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import { JsonLd } from "@/components/JsonLd"; +import { VolunteerClient } from "./_client"; + +const PAGE_URL = "https://www.codu.co/volunteer"; +const PAGE_TITLE = "Volunteer with Codú — Help Build Ireland's Largest Dev Community"; +const PAGE_DESCRIPTION = + "Join the team behind Codú. We're recruiting volunteer marketers and event organisers to help run meetups, newsletters, partnerships, and socials across the Irish tech ecosystem."; + +export const metadata: Metadata = { + title: PAGE_TITLE, + description: PAGE_DESCRIPTION, + keywords: [ + "Codú volunteer", + "volunteer developer community", + "Ireland tech community", + "web developer volunteer", + "tech meetup organiser Ireland", + "marketing volunteer", + "events volunteer", + ], + alternates: { canonical: PAGE_URL }, + robots: { index: true, follow: true }, + openGraph: { + title: "Volunteer with Codú", + description: PAGE_DESCRIPTION, + url: PAGE_URL, + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Volunteer with Codú", + description: PAGE_DESCRIPTION, + }, +}; + +const volunteerJsonLd = { + "@context": "https://schema.org", + "@type": "JobPosting", + title: "Volunteer — Marketing & Events", + description: PAGE_DESCRIPTION, + employmentType: "VOLUNTEER", + hiringOrganization: { + "@type": "Organization", + name: "Codú", + sameAs: "https://www.codu.co", + logo: "https://www.codu.co/images/codu-logo.png", + }, + jobLocation: { + "@type": "Place", + address: { + "@type": "PostalAddress", + addressCountry: "IE", + }, + }, + applicantLocationRequirements: { "@type": "Country", name: "Worldwide" }, + jobLocationType: "TELECOMMUTE", + datePosted: new Date().toISOString().split("T")[0], + url: PAGE_URL, +}; + +export default function VolunteerPage() { + return ( + <> + + + + ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 27abc0b1..76cbc1cb 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -14,6 +14,8 @@ const ROUTES_TO_INDEX = [ "/feed", "/advertise", "/code-of-conduct", + "/volunteer", + "/speakers", ]; export default async function sitemap(): Promise { diff --git a/components/Speaker/SpeakerForm.tsx b/components/Speaker/SpeakerForm.tsx new file mode 100644 index 00000000..1b21e359 --- /dev/null +++ b/components/Speaker/SpeakerForm.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useForm, useFieldArray, type FieldPath } from "react-hook-form"; +import { + SpeakerApplicationSchema, + type SpeakerApplicationInput, + speakerFormats, + speakerFormatLabels, + speakerExperiences, + speakerExperienceLabels, + talkLengths, + talkLengthLabels, +} from "@/schema/speaker"; +import { Input } from "@/components/ui-components/input"; +import { Textarea } from "@/components/ui-components/textarea"; +import { + Field, + Label, + ErrorMessage, +} from "@/components/ui-components/fieldset"; +import { api } from "@/server/trpc/react"; +import clsx from "clsx"; +import { CheckIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; + +const MAX_TALKS = 3; + +function SuccessState() { + return ( +
+
+ +
+

Thanks for pitching!

+

+ We read every submission and reply within 2 weeks. In the meantime, say + hi in our{" "} + + Discord + + . +

+
+ ); +} + +export function SpeakerForm() { + const { + register, + control, + handleSubmit, + setError, + formState: { errors, isSubmitting, isSubmitSuccessful }, + } = useForm({ + defaultValues: { + name: "", + email: "", + link: "", + location: "", + bio: "", + talks: [{ title: "", length: "STANDARD_20", abstract: "" }], + other: "", + website: "", + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: "talks", + }); + + const submitMutation = api.speaker.submit.useMutation(); + + const onSubmit = async (data: SpeakerApplicationInput) => { + const result = SpeakerApplicationSchema.safeParse(data); + if (!result.success) { + result.error.issues.forEach((issue) => { + setError( + issue.path.join(".") as FieldPath, + { type: "manual", message: issue.message }, + ); + }); + return; + } + await submitMutation.mutateAsync(result.data); + }; + + if (isSubmitSuccessful && submitMutation.isSuccess) { + return ; + } + + return ( +
+ + +
+ + + + {errors.name && {errors.name.message}} + + + + + + {errors.email && {errors.email.message}} + +
+ + + + + {errors.link && {errors.link.message}} + + + + + + {errors.location && ( + {errors.location.message} + )} + + + + +