diff --git a/apps/web/src/app/api/book/[username]/[slug]/route.ts b/apps/web/src/app/api/book/[username]/[slug]/route.ts new file mode 100644 index 0000000..ca3bc6e --- /dev/null +++ b/apps/web/src/app/api/book/[username]/[slug]/route.ts @@ -0,0 +1,110 @@ +import { serviceClient } from '@/lib/supabase/service'; +import { successResponse, errorResponse, handleApiError } from '@/lib/api'; +import { FixedWindowRateLimiter, getClientIp } from '@/lib/rate-limit'; +import { + BookingError, + availableSlots, + createBooking, + findActivePage, + findHostByUsername, + publicHost, + publicPage, +} from '@/lib/booking'; +import { bookRequestSchema } from '@/lib/booking-validations'; + +interface RouteParams { + params: Promise<{ username: string; slug: string }>; +} + +/** A guest can look as often as they like; booking is what gets abused. */ +const bookingsByIp = new FixedWindowRateLimiter(10, 60 * 60_000); +const bookingsByEmail = new FixedWindowRateLimiter(5, 60 * 60_000); + +const MAX_DAYS_PER_REQUEST = 31; + +/** + * GET /api/book/[username]/[slug]?from=YYYY-MM-DD&days=7 + * + * The slots this page can offer, as instants. `from` is a date in the page's + * own zone (defaulting to today there); the guest's browser groups the result + * by its own days. Anonymous. + */ +export async function GET(request: Request, { params }: RouteParams) { + try { + const { username, slug } = await params; + const { searchParams } = new URL(request.url); + + const from = searchParams.get('from') ?? undefined; + if (from !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(from)) { + return errorResponse('from must be YYYY-MM-DD', 400); + } + const daysRaw = searchParams.get('days'); + const days = daysRaw === null ? 7 : Number(daysRaw); + if (!Number.isInteger(days) || days < 1 || days > MAX_DAYS_PER_REQUEST) { + return errorResponse(`days must be between 1 and ${String(MAX_DAYS_PER_REQUEST)}`, 400); + } + + const svc = serviceClient(); + const host = await findHostByUsername(svc, username); + if (!host) return errorResponse('No such user', 404); + const page = await findActivePage(svc, host.id, slug); + if (!page) return errorResponse('No such booking page', 404); + + const slots = await availableSlots(svc, page, from, days); + return successResponse({ + host: publicHost(host), + page: publicPage(page), + from: from ?? null, + days, + slots, + }); + } catch (error) { + return handleApiError(error); + } +} + +/** + * POST /api/book/[username]/[slug] — take a slot. + * + * { "start": "2026-09-08T16:00:00.000Z", "name": "…", "email": "…", "notes": "…" } + * + * Creates the meeting, emails the guest their invite (join code, calendar + * links, RSVP) and tells the host. 409 when the time has gone since the guest + * looked. Anonymous, rate-limited by address and by email. + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const { username, slug } = await params; + + const ip = getClientIp(request); + const byIp = bookingsByIp.check(ip); + if (!byIp.success) { + return errorResponse('Too many bookings from this address. Try again later.', 429); + } + + const body: unknown = await request.json().catch(() => ({})); + const input = bookRequestSchema.parse(body); + + const byEmail = bookingsByEmail.check(input.email); + if (!byEmail.success) { + return errorResponse('Too many bookings for this email. Try again later.', 429); + } + + const svc = serviceClient(); + const host = await findHostByUsername(svc, username); + if (!host) return errorResponse('No such user', 404); + const page = await findActivePage(svc, host.id, slug); + if (!page) return errorResponse('No such booking page', 404); + + const booking = await createBooking(svc, host, page, { + start: input.start, + name: input.name, + email: input.email, + notes: input.notes, + }); + return successResponse(booking, 201); + } catch (error) { + if (error instanceof BookingError) return errorResponse(error.message, error.status); + return handleApiError(error); + } +} diff --git a/apps/web/src/app/api/book/[username]/route.ts b/apps/web/src/app/api/book/[username]/route.ts new file mode 100644 index 0000000..e9b8a39 --- /dev/null +++ b/apps/web/src/app/api/book/[username]/route.ts @@ -0,0 +1,28 @@ +import { serviceClient } from '@/lib/supabase/service'; +import { successResponse, errorResponse, handleApiError } from '@/lib/api'; +import { findHostByUsername, listActivePages, publicHost, publicPage } from '@/lib/booking'; + +interface RouteParams { + params: Promise<{ username: string }>; +} + +/** + * GET /api/book/[username] — a host's booking pages, for anyone. + * + * Anonymous by design: this is the link a host hands out. Only active pages + * are shown, and only the fields a guest needs to pick one. + */ +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { username } = await params; + const svc = serviceClient(); + + const host = await findHostByUsername(svc, username); + if (!host) return errorResponse('No such user', 404); + + const pages = await listActivePages(svc, host.id); + return successResponse({ host: publicHost(host), pages: pages.map(publicPage) }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/src/app/api/booking-pages/[id]/route.ts b/apps/web/src/app/api/booking-pages/[id]/route.ts new file mode 100644 index 0000000..8488748 --- /dev/null +++ b/apps/web/src/app/api/booking-pages/[id]/route.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { successResponse, errorResponse, handleApiError } from '@/lib/api'; +import { bookingPageUpdateSchema } from '@/lib/booking-validations'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// PATCH /api/booking-pages/[id] — change any of a page's fields +export async function PATCH(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const body: unknown = await request.json().catch(() => ({})); + const input = bookingPageUpdateSchema.parse(body); + + const supabase = await createClient(); + const { user, error: authError } = await getAuthenticatedUser(supabase); + if (authError || !user) return errorResponse('Authentication required', 401); + + const svc = serviceClient(); + + const update: Record = { updated_at: new Date().toISOString() }; + if (input.title !== undefined) update.title = input.title; + if (input.slug !== undefined) update.slug = input.slug; + if (input.description !== undefined) update.description = input.description; + if (input.durationMinutes !== undefined) update.duration_minutes = input.durationMinutes; + if (input.timezone !== undefined) update.timezone = input.timezone; + if (input.availability !== undefined) update.availability = input.availability; + if (input.bufferMinutes !== undefined) update.buffer_minutes = input.bufferMinutes; + if (input.minNoticeMinutes !== undefined) update.min_notice_minutes = input.minNoticeMinutes; + if (input.maxDaysAhead !== undefined) update.max_days_ahead = input.maxDaysAhead; + if (input.active !== undefined) update.active = input.active; + + const { data, error } = await (svc as any) + .from('booking_pages') + .update(update) + .eq('id', id) + .eq('host_user_id', user.id) + .select() + .maybeSingle(); + + if (error) { + if (String(error.code) === '23505') { + return errorResponse('You already have a page with that slug', 409); + } + return errorResponse(String(error.message), 400); + } + if (!data) return errorResponse('Booking page not found', 404); + + return successResponse(data); + } catch (error) { + return handleApiError(error); + } +} + +// DELETE /api/booking-pages/[id] — remove a page; meetings already booked stay +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const { id } = await params; + + const supabase = await createClient(); + const { user, error: authError } = await getAuthenticatedUser(supabase); + if (authError || !user) return errorResponse('Authentication required', 401); + + const svc = serviceClient(); + const { data, error } = await (svc as any) + .from('booking_pages') + .delete() + .eq('id', id) + .eq('host_user_id', user.id) + .select('id') + .maybeSingle(); + + if (error) return errorResponse(String(error.message), 400); + if (!data) return errorResponse('Booking page not found', 404); + + return successResponse({ deleted: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/src/app/api/booking-pages/route.ts b/apps/web/src/app/api/booking-pages/route.ts new file mode 100644 index 0000000..7f6930c --- /dev/null +++ b/apps/web/src/app/api/booking-pages/route.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ +import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; +import { serviceClient } from '@/lib/supabase/service'; +import { successResponse, errorResponse, handleApiError } from '@/lib/api'; +import { bookingPageInputSchema, slugify } from '@/lib/booking-validations'; + +/** + * /api/booking-pages — the host's side of booking. + * + * A page is availability plus a duration under a slug. The public half + * (/api/book//) reads what is created here; nothing a guest + * does reaches this route. + */ + +const MAX_PAGES_PER_HOST = 20; + +// GET /api/booking-pages — every page this host owns, active or not +export async function GET() { + try { + const supabase = await createClient(); + const { user, error: authError } = await getAuthenticatedUser(supabase); + if (authError || !user) return errorResponse('Authentication required', 401); + + const svc = serviceClient(); + const { data, error } = await (svc as any) + .from('booking_pages') + .select('*') + .eq('host_user_id', user.id) + .order('created_at', { ascending: true }); + if (error) return errorResponse(String(error.message), 400); + + const { data: profile } = await (svc as any) + .from('profiles') + .select('username') + .eq('id', user.id) + .maybeSingle(); + + return successResponse({ + username: (profile?.username as string | null) ?? null, + pages: (data as unknown[] | null) ?? [], + }); + } catch (error) { + return handleApiError(error); + } +} + +// POST /api/booking-pages — create a page +export async function POST(request: Request) { + try { + const body: unknown = await request.json().catch(() => ({})); + const input = bookingPageInputSchema.parse(body); + + const supabase = await createClient(); + const { user, error: authError } = await getAuthenticatedUser(supabase); + if (authError || !user) return errorResponse('Authentication required', 401); + + const svc = serviceClient(); + + // A page is reached by username, so a host without one has nowhere to be + // booked. Say so up front rather than creating a page nobody can open. + const { data: profile } = await (svc as any) + .from('profiles') + .select('username') + .eq('id', user.id) + .maybeSingle(); + if (!profile?.username) { + return errorResponse( + 'Set a username in Settings first — your booking link is /book//', + 400 + ); + } + + const { count } = await (svc as any) + .from('booking_pages') + .select('id', { count: 'exact', head: true }) + .eq('host_user_id', user.id); + if ((count as number | null) !== null && (count as number) >= MAX_PAGES_PER_HOST) { + return errorResponse(`You already have ${String(MAX_PAGES_PER_HOST)} booking pages`, 400); + } + + const slug = input.slug ?? slugify(input.title); + + const { data, error } = await (svc as any) + .from('booking_pages') + .insert({ + host_user_id: user.id, + slug, + title: input.title, + description: input.description ?? null, + duration_minutes: input.durationMinutes, + timezone: input.timezone, + availability: input.availability, + buffer_minutes: input.bufferMinutes, + min_notice_minutes: input.minNoticeMinutes, + max_days_ahead: input.maxDaysAhead, + active: input.active, + }) + .select() + .single(); + + if (error) { + if (String(error.code) === '23505') { + return errorResponse(`You already have a page at /${slug} — pick another slug`, 409); + } + return errorResponse(String(error.message), 400); + } + + return successResponse({ ...data, url: `/book/${profile.username as string}/${slug}` }, 201); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/src/app/book/[username]/[slug]/BookingClient.tsx b/apps/web/src/app/book/[username]/[slug]/BookingClient.tsx new file mode 100644 index 0000000..87c280a --- /dev/null +++ b/apps/web/src/app/book/[username]/[slug]/BookingClient.tsx @@ -0,0 +1,492 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; +import Link from 'next/link'; +import { + CalendarPlus, + CheckCircle, + ChevronLeft, + ChevronRight, + Clock, + Globe, + Loader2, + User as UserIcon, +} from 'lucide-react'; + +import { buildGoogleCalendarUrl, buildOutlookUrl, downloadIcs } from '@/lib/calendar'; +import { groupSlotsByDay, localDate, type Slot } from '@/lib/booking-slots'; +import type { PublicBookingHost, PublicBookingPage } from '@/lib/booking'; + +interface Props { + host: PublicBookingHost; + page: PublicBookingPage; +} + +interface Confirmation { + meetingId: string; + title: string; + scheduledAt: string; + durationMinutes: number; + joinCode: string; + joinUrl: string; + hostName: string; + rsvpUrl: string; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +function guessZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch { + return 'UTC'; + } +} + +/** A short list of zones to switch to; the guest's own is always first. */ +function zoneChoices(current: string): string[] { + const common = [ + 'America/Los_Angeles', + 'America/Denver', + 'America/Chicago', + 'America/New_York', + 'America/Sao_Paulo', + 'Europe/London', + 'Europe/Berlin', + 'Europe/Moscow', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Singapore', + 'Asia/Tokyo', + 'Australia/Sydney', + 'Pacific/Auckland', + 'UTC', + ]; + return [current, ...common.filter((zone) => zone !== current)]; +} + +function ymdShift(ymd: string, days: number): string { + const [y, m, d] = ymd.split('-').map(Number); + const date = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1) + days * DAY_MS); + return date.toISOString().slice(0, 10); +} + +function dayLabel(ymd: string): { weekday: string; date: string } { + const [y, m, d] = ymd.split('-').map(Number); + const date = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1, 12)); + return { + weekday: date.toLocaleDateString('en-US', { weekday: 'short', timeZone: 'UTC' }), + date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }), + }; +} + +function timeLabel(iso: string, zone: string): string { + return new Date(iso).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + timeZone: zone, + }); +} + +function whenLabel(iso: string, zone: string): string { + return new Date(iso).toLocaleString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZone: zone, + timeZoneName: 'short', + }); +} + +/** + * The booking page a guest sees: a week of days, the free times in their own + * zone, a short form, and a confirmation with the join code and calendar links. + * + * Slots come from the API as instants; the grouping into days happens here + * in the guest's zone, which is why switching the zone re-draws the grid + * without another request. + */ +export function BookingClient({ host, page }: Props) { + const [zone, setZone] = useState('UTC'); + const [weekStart, setWeekStart] = useState(''); + const [slots, setSlots] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [selected, setSelected] = useState(null); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [notes, setNotes] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [confirmation, setConfirmation] = useState(null); + + // The guest's zone and today's date are only knowable in the browser. + useEffect(() => { + const guessed = guessZone(); + setZone(guessed); + setWeekStart(localDate(Date.now(), guessed)); + }, []); + + const lastDay = useMemo( + () => localDate(Date.now() + page.maxDaysAhead * DAY_MS, zone), + [page.maxDaysAhead, zone] + ); + + const loadWeek = useCallback( + async (start: string) => { + setLoading(true); + setLoadError(null); + try { + // A day either side, because the API's `from` is a date in the page's + // zone and the guest's week may start on a different calendar day there. + const from = ymdShift(start, -1); + const res = await fetch( + `/api/book/${encodeURIComponent(host.username)}/${encodeURIComponent(page.slug)}?from=${from}&days=9` + ); + const json = (await res.json()) as { data?: { slots: Slot[] }; error?: string }; + if (!res.ok || !json.data) throw new Error(json.error ?? 'Could not load times'); + setSlots(json.data.slots); + } catch (error) { + setLoadError(error instanceof Error ? error.message : 'Could not load times'); + setSlots([]); + } finally { + setLoading(false); + } + }, + [host.username, page.slug] + ); + + useEffect(() => { + if (weekStart) void loadWeek(weekStart); + }, [weekStart, loadWeek]); + + const days = useMemo(() => { + if (!weekStart) return []; + const grouped = new Map(groupSlotsByDay(slots, zone).map((group) => [group.date, group.slots])); + return Array.from({ length: 7 }, (_, index) => { + const date = ymdShift(weekStart, index); + return { date, slots: grouped.get(date) ?? [] }; + }); + }, [slots, weekStart, zone]); + + const canGoBack = weekStart !== '' && weekStart > localDate(Date.now(), zone); + const canGoForward = weekStart !== '' && ymdShift(weekStart, 7) <= lastDay; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!selected) return; + setSubmitting(true); + setSubmitError(null); + try { + const res = await fetch( + `/api/book/${encodeURIComponent(host.username)}/${encodeURIComponent(page.slug)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + start: selected.start, + name, + email, + notes: notes || undefined, + timezone: zone, + }), + } + ); + const json = (await res.json()) as { data?: Confirmation; error?: string }; + if (!res.ok || !json.data) { + // The time went while the form was open; show the week again. + if (res.status === 409) { + setSelected(null); + void loadWeek(weekStart); + } + throw new Error(json.error ?? 'Could not book that time'); + } + setConfirmation(json.data); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : 'Could not book that time'); + } finally { + setSubmitting(false); + } + }; + + if (confirmation) { + const calendarEvent = { + title: confirmation.title, + description: `Join at ${confirmation.joinUrl} with code ${confirmation.joinCode}`, + startIso: confirmation.scheduledAt, + durationMinutes: confirmation.durationMinutes, + joinUrl: confirmation.joinUrl, + }; + return ( +
+ +

You're booked

+

+ {whenLabel(confirmation.scheduledAt, zone)} with {confirmation.hostName} +

+

+ A confirmation with everything below is on its way to {email}. +

+ +
+

+ Your join code +

+

+ {confirmation.joinCode} +

+

+ The call happens in PairUX. When it's time, open the link below or enter the code. +

+
+ + + Open the call page + + +
+ + Add to calendar: + + + Google + + + Outlook + + +
+ +

+ Can't make it after all?{' '} + + Let {confirmation.hostName} know + + . +

+
+ ); + } + + return ( +
+ + +
+ {selected ? ( +
void submit(event)} className="mx-auto max-w-md"> + +

+ {whenLabel(selected.start, zone)} +

+

+ {page.durationMinutes} minutes with {host.displayName} +

+ + + +