Skip to content

Commit 050aa81

Browse files
feat: /volunteer and /speakers forms with Google Sheets + email (#1330)
* feat: add /volunteer and /speakers forms with Google Sheets + email 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) <noreply@anthropic.com> * fix(review): escape email templates, drop invalid JSON-LD, document partial-failure policy Addresses the code review on PR #1330: - Critical: email templates were interpolating user-submitted strings directly into HTML, leaking an XSS surface to the admin inbox. Added utils/escapeHtml.ts with escapeHtml() + sanitizeHref() (rejects non- http(s)/mailto URIs in href attributes) and wrapped every interpolation in the volunteer, speaker, AND sponsor templates. The sponsor template fix is pre-existing-bug cleanup included in the same pass per review. - Medium: the JobPosting JSON-LD on /volunteer and /speakers violated Google's rich-results guidelines (JobPosting is for paid employment, was missing baseSalary and validThrough, and combined jobLocation with TELECOMMUTE jobLocationType). Risk of "Deceptive structured data" manual penalty. Removed both. Page-level metadata (title, description, OG, Twitter) is kept; the site-wide Organization JSON-LD from the app layout already covers brand-level structured data. - High: documented the Promise.allSettled partial-failure policy in both routers — if one channel fails we still return success to the user and rely on Sentry + console.error to flag the gap; only both failing errors out. Previously implicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style: apply Prettier formatting to volunteer/speaker files Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ed3646f commit 050aa81

20 files changed

Lines changed: 1890 additions & 29 deletions

app/(app)/speakers/_client.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { SpeakerForm } from "@/components/Speaker/SpeakerForm";
2+
3+
export function SpeakersClient() {
4+
return (
5+
<div className="bg-black">
6+
<div className="mx-auto max-w-3xl px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
7+
<header className="mb-10">
8+
<p className="mb-4 inline-block rounded-full bg-gradient-to-r from-orange-400/20 to-pink-600/20 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-orange-300">
9+
Speak at Codú
10+
</p>
11+
<h1 className="text-3xl font-extrabold tracking-tight text-white sm:text-4xl">
12+
Pitch a talk at a Codú meetup
13+
</h1>
14+
<p className="mt-4 text-lg leading-relaxed text-neutral-300">
15+
Codú runs regular meetups across Ireland and we&apos;re always
16+
looking for speakers. Whether it&apos;s your first talk or your
17+
fiftieth, we&apos;d love to hear your pitch. Propose a talk (or up
18+
to three) and we&apos;ll be in touch.
19+
</p>
20+
</header>
21+
22+
<SpeakerForm />
23+
24+
<p className="mt-6 text-center text-sm text-neutral-400">
25+
Takes about 3 minutes. First-time speakers welcome — we&apos;ll help
26+
you prep.
27+
</p>
28+
</div>
29+
</div>
30+
);
31+
}

app/(app)/speakers/page.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Metadata } from "next";
2+
import { SpeakersClient } from "./_client";
3+
4+
const PAGE_URL = "https://www.codu.co/speakers";
5+
const PAGE_TITLE = "Speak at Codú — Pitch a Talk for Our Meetups";
6+
const PAGE_DESCRIPTION =
7+
"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.";
8+
9+
export const metadata: Metadata = {
10+
title: PAGE_TITLE,
11+
description: PAGE_DESCRIPTION,
12+
keywords: [
13+
"Codú speaker",
14+
"tech meetup speaker Ireland",
15+
"developer meetup Dublin",
16+
"first time speaker",
17+
"web development talk",
18+
"speak at meetup Ireland",
19+
],
20+
alternates: { canonical: PAGE_URL },
21+
robots: { index: true, follow: true },
22+
openGraph: {
23+
title: "Speak at Codú",
24+
description: PAGE_DESCRIPTION,
25+
url: PAGE_URL,
26+
type: "website",
27+
},
28+
twitter: {
29+
card: "summary_large_image",
30+
title: "Speak at Codú",
31+
description: PAGE_DESCRIPTION,
32+
},
33+
};
34+
35+
export default function SpeakersPage() {
36+
return <SpeakersClient />;
37+
}

app/(app)/volunteer/_client.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { VolunteerForm } from "@/components/Volunteer/VolunteerForm";
2+
3+
export function VolunteerClient() {
4+
return (
5+
<div className="bg-black">
6+
<div className="mx-auto max-w-3xl px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
7+
<header className="mb-10">
8+
<p className="mb-4 inline-block rounded-full bg-gradient-to-r from-orange-400/20 to-pink-600/20 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-orange-300">
9+
Volunteer with Codú
10+
</p>
11+
<h1 className="text-3xl font-extrabold tracking-tight text-white sm:text-4xl">
12+
Help us build Ireland&apos;s largest web dev community
13+
</h1>
14+
<p className="mt-4 text-lg leading-relaxed text-neutral-300">
15+
Codú is Ireland&apos;s largest web dev community — thousands of
16+
developers, regular meetups, and a newsletter across the Irish tech
17+
ecosystem. We&apos;re opening volunteer spots for people interested
18+
in marketing and events.
19+
</p>
20+
</header>
21+
22+
<VolunteerForm />
23+
24+
<p className="mt-6 text-center text-sm text-neutral-400">
25+
Takes about 3 minutes. We read every application and reply within 2
26+
weeks.
27+
</p>
28+
</div>
29+
</div>
30+
);
31+
}

app/(app)/volunteer/page.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Metadata } from "next";
2+
import { VolunteerClient } from "./_client";
3+
4+
const PAGE_URL = "https://www.codu.co/volunteer";
5+
const PAGE_TITLE =
6+
"Volunteer with Codú — Help Build Ireland's Largest Dev Community";
7+
const PAGE_DESCRIPTION =
8+
"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.";
9+
10+
export const metadata: Metadata = {
11+
title: PAGE_TITLE,
12+
description: PAGE_DESCRIPTION,
13+
keywords: [
14+
"Codú volunteer",
15+
"volunteer developer community",
16+
"Ireland tech community",
17+
"web developer volunteer",
18+
"tech meetup organiser Ireland",
19+
"marketing volunteer",
20+
"events volunteer",
21+
],
22+
alternates: { canonical: PAGE_URL },
23+
robots: { index: true, follow: true },
24+
openGraph: {
25+
title: "Volunteer with Codú",
26+
description: PAGE_DESCRIPTION,
27+
url: PAGE_URL,
28+
type: "website",
29+
},
30+
twitter: {
31+
card: "summary_large_image",
32+
title: "Volunteer with Codú",
33+
description: PAGE_DESCRIPTION,
34+
},
35+
};
36+
37+
export default function VolunteerPage() {
38+
return <VolunteerClient />;
39+
}

app/sitemap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const ROUTES_TO_INDEX = [
1414
"/feed",
1515
"/advertise",
1616
"/code-of-conduct",
17+
"/volunteer",
18+
"/speakers",
1719
];
1820

1921
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {

0 commit comments

Comments
 (0)