From 6d3df8416be72bb0f0b2cb81200546a2ca168a38 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Fri, 23 Oct 2026 01:05:08 -0400 Subject: [PATCH] feat(calendar): replace broken external appointment links with native multi-calendar booking --- .github/workflows/deploy-workers.yml | 24 ++ apps/ops/src/App.tsx | 8 +- apps/sanity/schemas/documents/booking.ts | 4 +- .../schemas/singletons/sponsorConfig.ts | 2 +- apps/site/src/lib/google-calendar.ts | 360 ++++++++++++++---- apps/site/src/pages/api/booking.ts | 223 +++++++---- apps/site/src/pages/api/calendar/freebusy.ts | 13 +- apps/site/src/pages/api/sponsors/outbound.ts | 4 +- apps/site/src/pages/api/sponsorship.ts | 59 ++- apps/site/src/pages/book.astro | 108 ++++-- apps/site/src/pages/portal/[token].astro | 346 ++++++++++++++--- apps/site/src/pages/sponsorships.astro | 84 +++- 12 files changed, 978 insertions(+), 257 deletions(-) diff --git a/.github/workflows/deploy-workers.yml b/.github/workflows/deploy-workers.yml index 6cbbeaf1..92e6f3ff 100644 --- a/.github/workflows/deploy-workers.yml +++ b/.github/workflows/deploy-workers.yml @@ -66,6 +66,15 @@ jobs: if [ -n "$GUEST_PORTAL_SECRET" ]; then echo "$GUEST_PORTAL_SECRET" | pnpm --filter @codingcatdev/site exec wrangler secret put GUEST_PORTAL_SECRET --env="" fi + if [ -n "$GOOGLE_CLIENT_ID" ]; then + echo "$GOOGLE_CLIENT_ID" | pnpm --filter @codingcatdev/site exec wrangler secret put GOOGLE_CLIENT_ID --env="" + fi + if [ -n "$GOOGLE_CLIENT_SECRET" ]; then + echo "$GOOGLE_CLIENT_SECRET" | pnpm --filter @codingcatdev/site exec wrangler secret put GOOGLE_CLIENT_SECRET --env="" + fi + if [ -n "$GOOGLE_REFRESH_TOKEN" ]; then + echo "$GOOGLE_REFRESH_TOKEN" | pnpm --filter @codingcatdev/site exec wrangler secret put GOOGLE_REFRESH_TOKEN --env="" + fi env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -75,6 +84,9 @@ jobs: SANITY_CONTEXT_TOKEN: ${{ secrets.SANITY_CONTEXT_TOKEN }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} GUEST_PORTAL_SECRET: ${{ secrets.GUEST_PORTAL_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GOOGLE_REFRESH_TOKEN: ${{ secrets.GOOGLE_REFRESH_TOKEN }} deploy-production: if: github.ref == 'refs/heads/main' @@ -128,6 +140,15 @@ jobs: if [ -n "$GUEST_PORTAL_SECRET" ]; then echo "$GUEST_PORTAL_SECRET" | pnpm --filter @codingcatdev/site exec wrangler secret put GUEST_PORTAL_SECRET --env production fi + if [ -n "$GOOGLE_CLIENT_ID" ]; then + echo "$GOOGLE_CLIENT_ID" | pnpm --filter @codingcatdev/site exec wrangler secret put GOOGLE_CLIENT_ID --env production + fi + if [ -n "$GOOGLE_CLIENT_SECRET" ]; then + echo "$GOOGLE_CLIENT_SECRET" | pnpm --filter @codingcatdev/site exec wrangler secret put GOOGLE_CLIENT_SECRET --env production + fi + if [ -n "$GOOGLE_REFRESH_TOKEN" ]; then + echo "$GOOGLE_REFRESH_TOKEN" | pnpm --filter @codingcatdev/site exec wrangler secret put GOOGLE_REFRESH_TOKEN --env production + fi env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -137,3 +158,6 @@ jobs: SANITY_CONTEXT_TOKEN: ${{ secrets.SANITY_CONTEXT_TOKEN }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} GUEST_PORTAL_SECRET: ${{ secrets.GUEST_PORTAL_SECRET }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GOOGLE_REFRESH_TOKEN: ${{ secrets.GOOGLE_REFRESH_TOKEN }} diff --git a/apps/ops/src/App.tsx b/apps/ops/src/App.tsx index 9508f3f4..f69b7717 100644 --- a/apps/ops/src/App.tsx +++ b/apps/ops/src/App.tsx @@ -286,20 +286,20 @@ export function App() {

- Open Podcast Recording Schedule ↗ + Open Podcast Recording Booking ↗ - Open Sponsor Discovery Schedule ↗ + Open Sponsor Discovery & Deal Room ↗
diff --git a/apps/sanity/schemas/documents/booking.ts b/apps/sanity/schemas/documents/booking.ts index 06b525bb..c4b1d04a 100644 --- a/apps/sanity/schemas/documents/booking.ts +++ b/apps/sanity/schemas/documents/booking.ts @@ -71,9 +71,9 @@ export default defineType({ }), defineField({ name: 'appointmentScheduleUrl', - title: 'Google Calendar Appointment Schedule Link', + title: 'Google Calendar Event / Booking Link', type: 'url', - initialValue: 'https://calendar.app.google/s3B6PEqbVsGcY81MA', + initialValue: 'https://codingcat.dev/book', }), defineField({ name: 'googleCalendarEventId', diff --git a/apps/sanity/schemas/singletons/sponsorConfig.ts b/apps/sanity/schemas/singletons/sponsorConfig.ts index 8e198074..33b7260f 100644 --- a/apps/sanity/schemas/singletons/sponsorConfig.ts +++ b/apps/sanity/schemas/singletons/sponsorConfig.ts @@ -96,7 +96,7 @@ export default defineType({ type: "text", description: "Template for automated sponsor outreach emails. Use {{companyName}} for personalization", initialValue: - "Hi {{contactName}},\n\nSaw {{companyName}}'s recent momentum and put together two concrete technical video concepts we could build and distribute across CodingCat.dev (61K+ YouTube subscribers, 7-platform Shorts syndication, and canonical AEO blog posts).\n\n{{videoConcepts}}\n\nWould you be open to a quick 15-minute chat this week? You can grab a time directly here: https://calendar.app.google/tyGiF6dGFQeaVrN66\n\nBest,\nAlex Patterson\nFounder, CodingCat.dev", + "Hi {{contactName}},\n\nSaw {{companyName}}'s recent momentum and put together two concrete technical video concepts we could build and distribute across CodingCat.dev (61K+ YouTube subscribers, 7-platform Shorts syndication, and canonical AEO blog posts).\n\n{{videoConcepts}}\n\nWould you be open to a quick 15-minute chat this week? You can grab a live time slot directly here: https://codingcat.dev/sponsorships#sponsorship-form\n\nBest,\nAlex Patterson\nFounder, CodingCat.dev", }), defineField({ name: "maxOutreachPerRun", diff --git a/apps/site/src/lib/google-calendar.ts b/apps/site/src/lib/google-calendar.ts index ae68f2d7..cfbbb4b6 100644 --- a/apps/site/src/lib/google-calendar.ts +++ b/apps/site/src/lib/google-calendar.ts @@ -3,6 +3,7 @@ import { env } from "cloudflare:workers"; export const CODINGCAT_CALENDAR_IDS = [ "alex.patterson@gmail.com", "alex@codingcat.dev", + "alex@flipfeeds.com", "alex.patterson@sanity.io", "mikqhmn29svd9p9m99quch9nuo@group.calendar.google.com", // Oliver - Alex "2em0afukjm0gk9k3n0ohh5ag3g@group.calendar.google.com", // Oliver - Events @@ -13,18 +14,32 @@ export interface AvailableCalendarSlot { id: string; startAt: string; endAt: string; + durationMinutes: number; displayDate: string; displayTime: string; } -async function getAccessToken(): Promise { +export interface CalendarCheckStatus { + id: string; + accessible: boolean; + busyCount: number; + error?: string; +} + +async function getAccessToken( + refreshTokenOverride?: string, +): Promise { const workerEnv = (env ?? {}) as unknown as Record< string, string | undefined >; - const clientId = workerEnv.GOOGLE_CLIENT_ID; - const clientSecret = workerEnv.GOOGLE_CLIENT_SECRET; - const refreshToken = workerEnv.GOOGLE_REFRESH_TOKEN; + const clientId = workerEnv.GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID; + const clientSecret = + workerEnv.GOOGLE_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET; + const refreshToken = + refreshTokenOverride || + workerEnv.GOOGLE_REFRESH_TOKEN || + process.env.GOOGLE_REFRESH_TOKEN; if (!clientId || !clientSecret || !refreshToken) { return null; @@ -52,22 +67,27 @@ async function getAccessToken(): Promise { function generateDefaultCandidateSlots( daysAhead = 14, + durationMinutes = 45, ): AvailableCalendarSlot[] { const slots: AvailableCalendarSlot[] = []; const now = new Date(); // Candidate slots in overlapping US PT (8:00 AM - 2:00 PM PT) & ET (11:00 AM - 5:00 PM ET) work hours: - // 16:00 UTC (9:00 AM PDT / 12:00 PM EDT), 17:30 UTC (10:30 AM PDT / 1:30 PM EDT), 19:00 UTC (12:00 PM PDT / 3:00 PM EDT) + // 15:00 UTC (8:00 AM PDT / 11:00 AM EDT), 16:00 UTC (9:00 AM PDT / 12:00 PM EDT), + // 17:30 UTC (10:30 AM PDT / 1:30 PM EDT), 19:00 UTC (12:00 PM PDT / 3:00 PM EDT), + // 20:00 UTC (1:00 PM PDT / 4:00 PM EDT) const candidateUtcHours = [ + { hour: 15, minute: 0, label: "8:00 AM PT / 11:00 AM ET" }, { hour: 16, minute: 0, label: "9:00 AM PT / 12:00 PM ET" }, { hour: 17, minute: 30, label: "10:30 AM PT / 1:30 PM ET" }, { hour: 19, minute: 0, label: "12:00 PM PT / 3:00 PM ET" }, + { hour: 20, minute: 0, label: "1:00 PM PT / 4:00 PM ET" }, ]; for (let d = 1; d <= daysAhead; d++) { const dayDate = new Date(now.getTime() + d * 86400_000); const dayOfWeek = dayDate.getUTCDay(); - // Weekdays Monday (1) through Friday (5), prioritizing Tue-Thu + // Weekdays Monday (1) through Friday (5) if (dayOfWeek === 0 || dayOfWeek === 6) { continue; } @@ -83,18 +103,19 @@ function generateDefaultCandidateSlots( 0, ), ); - const end = new Date(start.getTime() + 45 * 60_000); + const end = new Date(start.getTime() + durationMinutes * 60_000); slots.push({ id: `slot-${start.toISOString().slice(0, 16).replace(/[:T]/g, "-")}`, startAt: start.toISOString(), endAt: end.toISOString(), + durationMinutes, displayDate: start.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "America/New_York", }), - displayTime: cand.label, + displayTime: `${cand.label} (${durationMinutes}m)`, }); } } @@ -102,26 +123,30 @@ function generateDefaultCandidateSlots( return slots; } -/** - * Queries Google Calendar FreeBusy API across all 6 of Alex Patterson's calendars - * (Personal, CodingCat.dev, Sanity.io, and Oliver family calendars) and returns - * conflict-free 45-minute recording slots during 8am-5pm PT/ET work hours. - */ -export async function getMultiCalendarAvailableSlots( - maxSlots = 9, -): Promise<{ slots: AvailableCalendarSlot[]; liveVerified: boolean }> { - const candidates = generateDefaultCandidateSlots(14); +async function fetchBusyRanges( + timeMin: string, + timeMax: string, +): Promise<{ + liveVerified: boolean; + busyRanges: Array<{ startMs: number; endMs: number }>; + calendarStatuses: CalendarCheckStatus[]; +}> { const accessToken = await getAccessToken(); - - if (!accessToken || candidates.length === 0) { + if (!accessToken) { return { - slots: candidates.slice(0, maxSlots), liveVerified: false, + busyRanges: [], + calendarStatuses: CODINGCAT_CALENDAR_IDS.map((id) => ({ + id, + accessible: false, + busyCount: 0, + error: "missing_oauth_token", + })), }; } - const timeMin = candidates[0].startAt; - const timeMax = candidates[candidates.length - 1].endAt; + const busyRanges: Array<{ startMs: number; endMs: number }> = []; + const calendarStatuses: CalendarCheckStatus[] = []; try { const res = await fetch("https://www.googleapis.com/calendar/v3/freeBusy", { @@ -138,49 +163,195 @@ export async function getMultiCalendarAvailableSlots( }); if (!res.ok) { - return { slots: candidates.slice(0, maxSlots), liveVerified: false }; + return { + liveVerified: false, + busyRanges: [], + calendarStatuses: CODINGCAT_CALENDAR_IDS.map((id) => ({ + id, + accessible: false, + busyCount: 0, + error: `http_${res.status}`, + })), + }; } const data = (await res.json()) as { calendars?: Record< string, - { busy?: Array<{ start: string; end: string }> } + { + busy?: Array<{ start: string; end: string }>; + errors?: Array<{ reason?: string }>; + } >; }; - const busyRanges: Array<{ startMs: number; endMs: number }> = []; - for (const cal of Object.values(data.calendars ?? {})) { - for (const b of cal.busy ?? []) { + for (const id of CODINGCAT_CALENDAR_IDS) { + const cal = data.calendars?.[id]; + const errReason = cal?.errors?.[0]?.reason; + const busyList = cal?.busy ?? []; + for (const b of busyList) { busyRanges.push({ startMs: new Date(b.start).getTime(), endMs: new Date(b.end).getTime(), }); } + calendarStatuses.push({ + id, + accessible: !errReason, + busyCount: busyList.length, + ...(errReason ? { error: errReason } : {}), + }); } - const available = candidates.filter((slot) => { - const slotStart = new Date(slot.startAt).getTime(); - const slotEnd = new Date(slot.endAt).getTime(); - return !busyRanges.some( - (b) => slotStart < b.endMs && slotEnd > b.startMs, - ); - }); + // Optional secondary token for flipfeeds.com if alex@flipfeeds.com isn't shared via ACL yet + const workerEnv = (env ?? {}) as unknown as Record< + string, + string | undefined + >; + const flipfeedsRefreshToken = + workerEnv.GOOGLE_FLIPFEEDS_REFRESH_TOKEN || + process.env.GOOGLE_FLIPFEEDS_REFRESH_TOKEN; + const flipStatus = calendarStatuses.find( + (c) => c.id === "alex@flipfeeds.com", + ); + if (flipfeedsRefreshToken && flipStatus && !flipStatus.accessible) { + const flipAccess = await getAccessToken(flipfeedsRefreshToken); + if (flipAccess) { + const flipRes = await fetch( + "https://www.googleapis.com/calendar/v3/freeBusy", + { + method: "POST", + headers: { + Authorization: `Bearer ${flipAccess}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + timeMin, + timeMax, + items: [{ id: "alex@flipfeeds.com" }], + }), + }, + ); + if (flipRes.ok) { + const flipData = (await flipRes.json()) as { + calendars?: Record< + string, + { busy?: Array<{ start: string; end: string }> } + >; + }; + const flipBusy = + flipData.calendars?.["alex@flipfeeds.com"]?.busy ?? []; + for (const b of flipBusy) { + busyRanges.push({ + startMs: new Date(b.start).getTime(), + endMs: new Date(b.end).getTime(), + }); + } + flipStatus.accessible = true; + flipStatus.busyCount = flipBusy.length; + delete flipStatus.error; + } + } + } return { - slots: (available.length > 0 ? available : candidates).slice(0, maxSlots), liveVerified: true, + busyRanges, + calendarStatuses, }; } catch { + return { + liveVerified: false, + busyRanges: [], + calendarStatuses: CODINGCAT_CALENDAR_IDS.map((id) => ({ + id, + accessible: false, + busyCount: 0, + error: "network_error", + })), + }; + } +} + +/** + * Queries Google Calendar FreeBusy API across Alex Patterson's calendars + * (codingcat.dev, flipfeeds.com, alex.patterson@gmail.com, sanity.io, and Oliver family calendars) + * and returns conflict-free slots during 8am-5pm PT/ET work hours. + */ +export async function getMultiCalendarAvailableSlots( + maxSlots = 9, + durationMinutes = 45, +): Promise<{ + slots: AvailableCalendarSlot[]; + liveVerified: boolean; + calendarStatuses: CalendarCheckStatus[]; +}> { + const candidates = generateDefaultCandidateSlots(14, durationMinutes); + if (candidates.length === 0) { + return { slots: [], liveVerified: false, calendarStatuses: [] }; + } + + const timeMin = candidates[0].startAt; + const timeMax = candidates[candidates.length - 1].endAt; + const { liveVerified, busyRanges, calendarStatuses } = await fetchBusyRanges( + timeMin, + timeMax, + ); + + if (!liveVerified) { return { slots: candidates.slice(0, maxSlots), liveVerified: false, + calendarStatuses, }; } + + const available = candidates.filter((slot) => { + const slotStart = new Date(slot.startAt).getTime(); + const slotEnd = new Date(slot.endAt).getTime(); + return !busyRanges.some((b) => slotStart < b.endMs && slotEnd > b.startMs); + }); + + return { + slots: (available.length > 0 ? available : candidates).slice(0, maxSlots), + liveVerified: true, + calendarStatuses, + }; +} + +/** + * Builds a Google Calendar "Add to Calendar" template link so guests/sponsors can + * also save the event to their own calendar in 1 click. + */ +export function buildGoogleCalendarTemplateUrl(opts: { + title: string; + startAt: string; + durationMinutes: number; + description: string; +}): string { + const start = new Date(opts.startAt); + const end = new Date(start.getTime() + opts.durationMinutes * 60_000); + const fmt = (d: Date) => + d + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}Z$/, "Z"); + const params = new URLSearchParams({ + action: "TEMPLATE", + text: opts.title, + dates: `${fmt(start)}/${fmt(end)}`, + details: opts.description, + location: "CodingCat.dev Studio (Google Calendar Invite)", + }); + return `https://calendar.google.com/calendar/render?${params.toString()}`; } /** - * Creates a Google Calendar event on alex@codingcat.dev (or alex.patterson@gmail.com) - * when a Sponsored Showcase or approved Editorial session is booked. + * Creates a Google Calendar event on alex@codingcat.dev (and invites alex.patterson@gmail.com + alex@flipfeeds.com): + * - Sponsored bookings ("sponsored" | "full-stack-launch" | "sponsor-kickoff"): + * Immediately creates a CONFIRMED event and sends Google Calendar invites (sendUpdates=all) to the sponsor and Alex. + * - Editorial bookings ("editorial"): + * Creates a TENTATIVE calendar hold on Alex's calendar (sendUpdates=none) so the slot is reserved pending editorial approval. */ export async function createGoogleCalendarBookingEvent(opts: { guestName: string; @@ -188,34 +359,90 @@ export async function createGoogleCalendarBookingEvent(opts: { company: string; track: string; startAt: string; + durationMinutes?: number; topics: string; portalUrl?: string; -}): Promise<{ eventId: string; htmlLink?: string } | null> { - const accessToken = await getAccessToken(); - if (!accessToken) { - return null; - } +}): Promise<{ + eventId?: string; + htmlLink?: string; + addToCalendarUrl: string; + calendarMode: "confirmed_invite" | "tentative_hold" | "template_fallback"; + conflictDetected?: boolean; +}> { + const isEditorial = opts.track === "editorial"; + const isKickoff = opts.track === "sponsor-kickoff"; + const durationMinutes = opts.durationMinutes ?? (isKickoff ? 15 : 45); const start = new Date(opts.startAt); - const end = new Date(start.getTime() + 45 * 60_000); + const end = new Date(start.getTime() + durationMinutes * 60_000); - const summary = - opts.track === "editorial" - ? `CodingCat.dev Podcast: ${opts.guestName} (${opts.company})` + const summary = isKickoff + ? `[SPONSOR KICKOFF] ${opts.company} × CodingCat.dev (${opts.guestName})` + : isEditorial + ? `[HOLD - EDITORIAL] CodingCat.dev Podcast: ${opts.guestName} (${opts.company})` : `[SPONSORED] CodingCat.dev Showcase: ${opts.guestName} (${opts.company})`; const descriptionLines = [ - `CodingCat.dev Recording Session (${opts.track.toUpperCase()})`, - `Guest: ${opts.guestName} (${opts.guestEmail})`, + `CodingCat.dev Session (${opts.track.toUpperCase()} · ${durationMinutes} min)`, + `Guest / Contact: ${opts.guestName} <${opts.guestEmail}>`, `Company: ${opts.company}`, - `Topics: ${opts.topics}`, - "E-SIGN Media Release: Signed & stored in Sanity", - ...(opts.portalUrl ? [`Deal Room: ${opts.portalUrl}`] : []), + `Topics / Goals: ${opts.topics}`, + isEditorial + ? "Status: Tentative Editorial Hold (Approve in Google Calendar to invite guest)" + : "Status: Confirmed Sponsored Session", + ...(opts.portalUrl ? [`Sponsor Deal Room: ${opts.portalUrl}`] : []), + ]; + const description = descriptionLines.join("\n"); + + const addToCalendarUrl = buildGoogleCalendarTemplateUrl({ + title: summary.replace(/^\[HOLD - EDITORIAL\]\s*/, ""), + startAt: start.toISOString(), + durationMinutes, + description, + }); + + const accessToken = await getAccessToken(); + if (!accessToken) { + return { + addToCalendarUrl, + calendarMode: "template_fallback", + }; + } + + // Real-time conflict check across all calendars before inserting + const { liveVerified, busyRanges } = await fetchBusyRanges( + start.toISOString(), + end.toISOString(), + ); + if (liveVerified) { + const startMs = start.getTime(); + const endMs = end.getTime(); + const hasConflict = busyRanges.some( + (b) => startMs < b.endMs && endMs > b.startMs, + ); + if (hasConflict) { + return { + addToCalendarUrl, + calendarMode: "template_fallback", + conflictDetected: true, + }; + } + } + + const sendUpdates = isEditorial ? "none" : "all"; + const status = isEditorial ? "tentative" : "confirmed"; + const attendees = [ + { email: "alex.patterson@gmail.com" }, + { email: "alex@codingcat.dev" }, + { email: "alex@flipfeeds.com" }, + ...(isEditorial + ? [] + : [{ email: opts.guestEmail, displayName: opts.guestName }]), ]; try { const res = await fetch( - "https://www.googleapis.com/calendar/v3/calendars/alex%40codingcat.dev/events?sendUpdates=all", + `https://www.googleapis.com/calendar/v3/calendars/alex%40codingcat.dev/events?sendUpdates=${sendUpdates}`, { method: "POST", headers: { @@ -224,27 +451,32 @@ export async function createGoogleCalendarBookingEvent(opts: { }, body: JSON.stringify({ summary, - description: descriptionLines.join("\n"), + description, + status, start: { dateTime: start.toISOString() }, end: { dateTime: end.toISOString() }, - attendees: [ - { email: "alex.patterson@gmail.com" }, - { email: "alex@codingcat.dev" }, - { email: opts.guestEmail, displayName: opts.guestName }, - ], + attendees, }), }, ); if (!res.ok) { - return null; + return { + addToCalendarUrl, + calendarMode: "template_fallback", + }; } const event = (await res.json()) as { id?: string; htmlLink?: string }; - if (!event.id) { - return null; - } - return { eventId: event.id, htmlLink: event.htmlLink }; + return { + eventId: event.id, + htmlLink: event.htmlLink, + addToCalendarUrl, + calendarMode: isEditorial ? "tentative_hold" : "confirmed_invite", + }; } catch { - return null; + return { + addToCalendarUrl, + calendarMode: "template_fallback", + }; } } diff --git a/apps/site/src/pages/api/booking.ts b/apps/site/src/pages/api/booking.ts index 53158e0e..b98e852e 100644 --- a/apps/site/src/pages/api/booking.ts +++ b/apps/site/src/pages/api/booking.ts @@ -1,6 +1,7 @@ import { env } from "cloudflare:workers"; import type { APIRoute } from "astro"; import { sendCodingCatEmail } from "@/lib/email"; +import { createGoogleCalendarBookingEvent } from "@/lib/google-calendar"; import { getSanityServerClient, resolveSponsorTier, @@ -15,9 +16,11 @@ interface BookingPayload { guestEmail?: string; company?: string; socialUrl?: string; - track?: "editorial" | "sponsored" | "full-stack-launch"; + track?: "editorial" | "sponsored" | "full-stack-launch" | "sponsor-kickoff"; slotStartAt?: string; + durationMinutes?: number; topics?: string; + portalToken?: string; signatureMode?: "draw" | "type"; signatureData?: string; consentRecording?: boolean; @@ -41,9 +44,15 @@ export const POST: APIRoute = async ({ request, url }) => { const company = data.company?.trim() || guestName || "Podcast Guest"; const socialUrl = data.socialUrl?.trim(); const track = data.track || "editorial"; + const isKickoff = track === "sponsor-kickoff"; + const isSponsored = + track === "sponsored" || track === "full-stack-launch" || isKickoff; + const durationMinutes = data.durationMinutes === 15 || isKickoff ? 15 : 45; const slotStartAt = data.slotStartAt || new Date(Date.now() + 7 * 86400_000).toISOString(); - const topics = data.topics?.trim(); + const topics = + data.topics?.trim() || + (isKickoff ? `15-Minute Sponsor Kickoff Call (${company})` : ""); const signatureMode = data.signatureMode || "type"; const signatureData = data.signatureData || guestName; @@ -58,12 +67,11 @@ export const POST: APIRoute = async ({ request, url }) => { } const bookingId = `booking-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; - const portalToken = `spt_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; - const isSponsored = track === "sponsored" || track === "full-stack-launch"; - const appointmentScheduleUrl = - track === "full-stack-launch" - ? "https://calendar.app.google/tyGiF6dGFQeaVrN66" - : "https://calendar.app.google/s3B6PEqbVsGcY81MA"; + const existingToken = data.portalToken?.trim(); + const portalToken = + existingToken && existingToken.length > 0 + ? existingToken + : `spt_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`; const workerEnv = (env ?? {}) as unknown as Record< string, @@ -83,80 +91,117 @@ export const POST: APIRoute = async ({ request, url }) => { ); const leadId = `sponsorLead-${portalToken}`; - try { - const [checkout, invoice] = await Promise.all([ - createStripeMilestoneCheckout({ - leadId, - portalToken, - companyName: company, - contactEmail: guestEmail, - packageName: tier.name, - milestone: "deposit", - amountCents: tier.depositAmountCents, - siteUrl, - }), - createStripeMilestoneInvoice({ - leadId, - portalToken, - companyName: company, - contactEmail: guestEmail, - packageName: tier.name, - milestone: "deposit", - amountCents: tier.depositAmountCents, - siteUrl, - daysUntilDue: 15, - }), - ]); - stripeDepositCheckoutUrl = checkout?.checkoutUrl; - stripeDepositInvoiceUrl = invoice?.hostedInvoiceUrl; - } catch { - // Stripe optional in dev + const queryFallback = new URLSearchParams({ + company, + name: guestName, + email: guestEmail, + tier: tier.id, + bookedSlot: slotStartAt, + }); + portalUrl = `${siteUrl}/portal/${portalToken}?${queryFallback.toString()}`; + + if (!existingToken) { + try { + const [checkout, invoice] = await Promise.all([ + createStripeMilestoneCheckout({ + leadId, + portalToken, + companyName: company, + contactEmail: guestEmail, + packageName: tier.name, + milestone: "deposit", + amountCents: tier.depositAmountCents, + siteUrl, + }), + createStripeMilestoneInvoice({ + leadId, + portalToken, + companyName: company, + contactEmail: guestEmail, + packageName: tier.name, + milestone: "deposit", + amountCents: tier.depositAmountCents, + siteUrl, + daysUntilDue: 15, + }), + ]); + stripeDepositCheckoutUrl = checkout?.checkoutUrl; + stripeDepositInvoiceUrl = invoice?.hostedInvoiceUrl; + } catch { + // Stripe optional in dev + } } if (canWrite) { try { - await client.createOrReplace({ - _id: leadId, - _type: "sponsorLead", - companyName: company, - contactName: guestName, - contactEmail: guestEmail, - ...(socialUrl ? { website: socialUrl } : {}), - portalToken, - source: "booking", - status: - stripeDepositCheckoutUrl || stripeDepositInvoiceUrl - ? "deposit_sent" - : "new", - rateCard: tier.id, - totalAmountCents: tier.totalAmountCents, - depositAmountCents: tier.depositAmountCents, - balanceAmountCents: tier.balanceAmountCents, - ...(stripeDepositCheckoutUrl ? { stripeDepositCheckoutUrl } : {}), - ...(stripeDepositInvoiceUrl ? { stripeDepositInvoiceUrl } : {}), - intent: topics, - deliverables: tier.deliverables.map((d, idx) => ({ - _key: `deliv-${idx}`, - title: d.title, - channel: d.channel, - status: "pending", - })), - lastEmailAt: new Date().toISOString(), - }); + if (existingToken) { + await client + .patch(leadId) + .set({ + status: "booked", + lastEmailAt: new Date().toISOString(), + }) + .commit(); + } else { + await client.createOrReplace({ + _id: leadId, + _type: "sponsorLead", + companyName: company, + contactName: guestName, + contactEmail: guestEmail, + ...(socialUrl ? { website: socialUrl } : {}), + portalToken, + source: "booking", + status: "booked", + rateCard: tier.id, + totalAmountCents: tier.totalAmountCents, + depositAmountCents: tier.depositAmountCents, + balanceAmountCents: tier.balanceAmountCents, + ...(stripeDepositCheckoutUrl ? { stripeDepositCheckoutUrl } : {}), + ...(stripeDepositInvoiceUrl ? { stripeDepositInvoiceUrl } : {}), + intent: topics, + deliverables: tier.deliverables.map((d, idx) => ({ + _key: `deliv-${idx}`, + title: d.title, + channel: d.channel, + status: "pending", + })), + lastEmailAt: new Date().toISOString(), + }); + } } catch { // Non-fatal } } + } - const queryFallback = new URLSearchParams({ - company, - name: guestName, - email: guestEmail, - tier: tier.id, - }); - portalUrl = `${siteUrl}/portal/${portalToken}?${queryFallback.toString()}`; + // Create live Google Calendar event (Confirmed Invite for Sponsored, Tentative Hold for Editorial) + const calendarEvent = await createGoogleCalendarBookingEvent({ + guestName, + guestEmail, + company, + track, + startAt: slotStartAt, + durationMinutes, + topics, + portalUrl, + }); + + if (calendarEvent.conflictDetected) { + return new Response( + JSON.stringify({ + success: false, + conflictDetected: true, + message: + "That time slot was just taken on Alex's calendar. Please select another available window.", + }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ); } + const appointmentScheduleUrl = + calendarEvent.htmlLink || calendarEvent.addToCalendarUrl; + if (canWrite) { try { await client.createOrReplace({ @@ -166,8 +211,8 @@ export const POST: APIRoute = async ({ request, url }) => { guestEmail, company, ...(socialUrl ? { socialUrl } : {}), - track, - status: "pending", + track: isKickoff ? "sponsored" : track, + status: isSponsored ? "confirmed" : "pending", slotStartAt, topics, appointmentScheduleUrl, @@ -184,17 +229,31 @@ export const POST: APIRoute = async ({ request, url }) => { } } + const subjectLabel = isKickoff + ? "Sponsor Kickoff Call" + : isSponsored + ? "Fast-Track Sponsored Booking" + : "Editorial Booking Hold"; + + const calendarStatusLabel = + calendarEvent.calendarMode === "confirmed_invite" + ? "Confirmed Google Calendar invite sent to guest & Alex" + : calendarEvent.calendarMode === "tentative_hold" + ? "Tentative hold created on Alex's Google Calendar (pending editorial approval)" + : "Add-to-Calendar link generated"; + await sendCodingCatEmail({ to: "alex.patterson@gmail.com", - subject: `[CodingCat.dev ${isSponsored ? "Fast-Track Sponsored" : "Editorial"} Booking] ${guestName} (${company})`, + subject: `[CodingCat.dev ${subjectLabel}] ${guestName} (${company})`, text: [ - "New Podcast Booking & Signed Media Release", - `Track: ${track}`, - `Guest: ${guestName} <${guestEmail}>`, + `New CodingCat.dev ${subjectLabel}`, + `Track: ${track} (${durationMinutes} min)`, + `Guest / Contact: ${guestName} <${guestEmail}>`, `Company: ${company}`, - `Requested Slot: ${slotStartAt}`, + `Scheduled Slot: ${slotStartAt}`, + `Calendar Status: ${calendarStatusLabel}`, `Topics: ${topics}`, - `Calendar Link: ${appointmentScheduleUrl}`, + `Google Calendar Event: ${appointmentScheduleUrl}`, ...(portalUrl ? [`Deal Room: ${portalUrl}`] : []), ].join("\n"), }); @@ -204,7 +263,13 @@ export const POST: APIRoute = async ({ request, url }) => { success: true, bookingId, track, + durationMinutes, + slotStartAt, isSponsored, + calendarMode: calendarEvent.calendarMode, + calendarEventId: calendarEvent.eventId, + calendarEventUrl: calendarEvent.htmlLink, + addToCalendarUrl: calendarEvent.addToCalendarUrl, appointmentScheduleUrl, portalToken: isSponsored ? portalToken : undefined, portalUrl, diff --git a/apps/site/src/pages/api/calendar/freebusy.ts b/apps/site/src/pages/api/calendar/freebusy.ts index 5c4074ab..d297b322 100644 --- a/apps/site/src/pages/api/calendar/freebusy.ts +++ b/apps/site/src/pages/api/calendar/freebusy.ts @@ -7,15 +7,22 @@ import { export const GET: APIRoute = async ({ request, url }) => { const reqUrl = url ?? new URL(request.url); const max = Number(reqUrl.searchParams.get("limit") || 9); - const { slots, liveVerified } = await getMultiCalendarAvailableSlots( - Math.min(18, Math.max(3, max)), - ); + const rawDuration = Number(reqUrl.searchParams.get("duration") || 45); + const durationMinutes = rawDuration === 15 ? 15 : 45; + + const { slots, liveVerified, calendarStatuses } = + await getMultiCalendarAvailableSlots( + Math.min(18, Math.max(3, max)), + durationMinutes, + ); return new Response( JSON.stringify({ success: true, liveVerified, + durationMinutes, checkedCalendars: CODINGCAT_CALENDAR_IDS, + calendarStatuses, workHoursPolicy: "8:00 AM - 5:00 PM PT / ET Overlap (11:00 AM - 5:00 PM ET)", slots, diff --git a/apps/site/src/pages/api/sponsors/outbound.ts b/apps/site/src/pages/api/sponsors/outbound.ts index 89f84547..50a0b223 100644 --- a/apps/site/src/pages/api/sponsors/outbound.ts +++ b/apps/site/src/pages/api/sponsors/outbound.ts @@ -71,8 +71,8 @@ function buildTailoredConcepts( "", "Our Full-Stack Launch Bundle ($3,500 total, locked with a 50% upfront deposit and 50% on publish) includes the dedicated YouTube build, 4 vertical Shorts published across 7 platforms (28 native video posts), a canonical SEO + Agent-Ready (WebMCP / llms.txt) article on codingcat.dev syndicated to Dev.to and Hashnode, and a live ROI Deal Room.", "", - "If you are open to a quick 15-minute scoping call this week, you can grab a time directly on my calendar here:", - "https://calendar.app.google/tyGiF6dGFQeaVrN66", + "If you are open to a quick 15-minute scoping call this week, you can pick a live conflict-free slot or open a Sponsor Deal Room here:", + "https://codingcat.dev/sponsorships#sponsorship-form", "", "Best,", "Alex Patterson", diff --git a/apps/site/src/pages/api/sponsorship.ts b/apps/site/src/pages/api/sponsorship.ts index c2d6b054..14d9bc06 100644 --- a/apps/site/src/pages/api/sponsorship.ts +++ b/apps/site/src/pages/api/sponsorship.ts @@ -1,6 +1,7 @@ import { env } from "cloudflare:workers"; import type { APIRoute } from "astro"; import { sendCodingCatEmail } from "@/lib/email"; +import { createGoogleCalendarBookingEvent } from "@/lib/google-calendar"; import { getSanityServerClient, resolveSponsorTier, @@ -20,6 +21,7 @@ interface SponsorshipPayload { tier?: string | string[]; sponsorshipTier?: string | string[]; paymentPreference?: "checkout" | "invoice"; + kickoffSlotStartAt?: string; message?: string; } @@ -85,6 +87,7 @@ export const POST: APIRoute = async ({ request, url }) => { (data.company ?? data.companyName)?.trim() || name || "Sponsor Partner"; const website = data.website?.trim(); const rawTier = data.tier ?? data.sponsorshipTier; + const kickoffSlotStartAt = data.kickoffSlotStartAt?.trim(); const message = data.message?.trim(); if (!name) { @@ -138,7 +141,38 @@ export const POST: APIRoute = async ({ request, url }) => { const fallbackOrigin = url?.origin ?? new URL(request.url).origin; const siteUrl = (workerEnv.SITE_URL || fallbackOrigin).replace(/\/$/, ""); - // 1. Optional Stripe 50% Upfront Deposit Checkout + Net-15 Invoice generation + const queryFallback = new URLSearchParams({ + company, + name, + email, + tier: selectedTier.id, + ...(kickoffSlotStartAt ? { bookedSlot: kickoffSlotStartAt } : {}), + }); + const portalUrl = `${siteUrl}/portal/${portalToken}?${queryFallback.toString()}`; + + // 1. Optional 15-min Sponsor Kickoff Call booking if slot pre-selected on /sponsorships + let calendarEventUrl: string | undefined; + if (kickoffSlotStartAt) { + try { + const calResult = await createGoogleCalendarBookingEvent({ + guestName: name, + guestEmail: email, + company, + track: "sponsor-kickoff", + startAt: kickoffSlotStartAt, + durationMinutes: 15, + topics: + message || + `15-Min Sponsor Kickoff: ${selectedTier.name} ($${selectedTier.priceUsd})`, + portalUrl, + }); + calendarEventUrl = calResult.htmlLink || calResult.addToCalendarUrl; + } catch { + // Non-fatal + } + } + + // 2. Optional Stripe 50% Upfront Deposit Checkout + Net-15 Invoice generation let stripeDepositCheckoutUrl: string | undefined; let stripeDepositInvoiceId: string | undefined; let stripeDepositInvoiceUrl: string | undefined; @@ -174,7 +208,7 @@ export const POST: APIRoute = async ({ request, url }) => { // Non-fatal if Stripe is not configured in local dev } - // 2. Persist sponsorLead directly in Sanity + // 3. Persist sponsorLead directly in Sanity const { client, canWrite } = getSanityServerClient(); if (canWrite) { try { @@ -187,8 +221,9 @@ export const POST: APIRoute = async ({ request, url }) => { ...(website ? { website } : {}), portalToken, source: "inbound", - status: - stripeDepositCheckoutUrl || stripeDepositInvoiceUrl + status: kickoffSlotStartAt + ? "booked" + : stripeDepositCheckoutUrl || stripeDepositInvoiceUrl ? "deposit_sent" : "new", rateCard: selectedTier.id, @@ -214,15 +249,7 @@ export const POST: APIRoute = async ({ request, url }) => { } } - const queryFallback = new URLSearchParams({ - company, - name, - email, - tier: selectedTier.id, - }); - const portalUrl = `${siteUrl}/portal/${portalToken}?${queryFallback.toString()}`; - - // 3. Fire-and-forget notification email via Cloudflare Email Routing if bound + // 4. Fire-and-forget notification email via Cloudflare Email Routing if bound await sendCodingCatEmail({ to: "alex.patterson@gmail.com", subject: `[CodingCat.dev Sponsor Inquiry] ${company} (${selectedTier.name} - $${selectedTier.priceUsd})`, @@ -231,6 +258,10 @@ export const POST: APIRoute = async ({ request, url }) => { `Company: ${company}`, `Contact: ${name} <${email}>`, `Package: ${selectedTier.name} ($${selectedTier.priceUsd} total / $${selectedTier.depositAmountCents / 100} 50% deposit)`, + ...(kickoffSlotStartAt + ? [`15-Min Kickoff Slot Booked: ${kickoffSlotStartAt}`] + : []), + ...(calendarEventUrl ? [`Calendar Event: ${calendarEventUrl}`] : []), `Deal Room URL: ${portalUrl}`, `Message: ${message || "(none)"}`, ].join("\n"), @@ -245,6 +276,8 @@ export const POST: APIRoute = async ({ request, url }) => { portalToken, portalUrl, dealRoomUrl: portalUrl, + kickoffSlotStartAt, + calendarEventUrl, depositAmountUsd: selectedTier.depositAmountCents / 100, totalAmountUsd: selectedTier.priceUsd, stripeDepositCheckoutUrl, diff --git a/apps/site/src/pages/book.astro b/apps/site/src/pages/book.astro index 059fa21c..c2d1bb11 100644 --- a/apps/site/src/pages/book.astro +++ b/apps/site/src/pages/book.astro @@ -13,6 +13,8 @@ const initialTrack = const initialCompany = searchParams.get("company") || ""; const initialName = searchParams.get("name") || ""; const initialEmail = searchParams.get("email") || ""; +const initialPortalToken = searchParams.get("portalToken") || ""; +const initialSlotParam = searchParams.get("slot") || ""; const { slots: upcomingSlots, liveVerified } = await getMultiCalendarAvailableSlots(6); @@ -143,14 +145,9 @@ const description = )} - - View full live Google Calendar schedule ↗ - + + Checked across CodingCat.dev, FlipFeeds & studio calendars +
@@ -352,30 +349,38 @@ const description =
-

- Intake & Media Release Recorded! +

+ Session Booked & E-SIGN Release Recorded!

-

- Your E-SIGN appearance release with Alex Patterson has been saved to Sanity. Lock in your exact Google Calendar invite below. +

+ Your E-SIGN appearance release with Alex Patterson has been saved to Sanity and synced with Google Calendar.

- +
- - Step 2: Lock Your Google Calendar Invite + + Google Calendar Sync +

- Open Google Calendar Appointment Schedule ↗ + Add / View Session in Google Calendar ↗ -

- Selecting your time on the Google Calendar schedule automatically sends calendar invites and recording links to both you and Alex. + +

+ A calendar hold and E-SIGN receipt have been created for your selected recording window.

@@ -405,10 +410,10 @@ const description = -