diff --git a/README.md b/README.md index f16e380..61df521 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ const { slots } = await findFreeTime({ }); ``` +For a whole app, the [Next.js booking page](examples/nextjs-booking) shows your free times, takes bookings and deploys to Vercel in one click. + ## Switch calendars, not code When the logic works, swap the mock for a real calendar. Only the import and the line that creates `calendar` change, and events come back in the same shape from every provider. diff --git a/bun.lock b/bun.lock index d2c47f8..e6c0266 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,27 @@ "tailwindcss": "4.3.4", }, }, + "examples/nextjs-booking": { + "name": "calsdk-nextjs-booking", + "dependencies": { + "@cal-sdk/core": "^0.1.0", + "@cal-sdk/google": "^0.1.0", + "@cal-sdk/mock": "^0.1.0", + "next": "16.4.5", + "react": "19.3.1", + "react-dom": "19.3.1", + "server-only": "0.0.1", + }, + "devDependencies": { + "@tailwindcss/postcss": "4.3.4", + "@types/node": "22", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "postcss": "8.5.32", + "tailwindcss": "4.3.4", + "typescript": "7.0.2", + }, + }, "examples/playground": { "name": "calsdk-playground", "dependencies": { @@ -652,6 +673,8 @@ "cac": ["cac@7.1.0", "", {}, "sha512-LMDU/U2yA4IPIY8KT0NsNPWEYZvAMkbgyspxJ6btJaYV+WMDOHAZCmB0lsTObiHaRS1V/ztKAl7+TSkTqXyeoA=="], + "calsdk-nextjs-booking": ["calsdk-nextjs-booking@workspace:examples/nextjs-booking"], + "calsdk-playground": ["calsdk-playground@workspace:examples/playground"], "calsdk-quickstart": ["calsdk-quickstart@workspace:examples/quickstart"], @@ -1040,6 +1063,8 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="], diff --git a/examples/nextjs-booking/.env.example b/examples/nextjs-booking/.env.example new file mode 100644 index 0000000..656f99e --- /dev/null +++ b/examples/nextjs-booking/.env.example @@ -0,0 +1,8 @@ +# Leave these empty to run on a demo calendar. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REFRESH_TOKEN= + +BOOKING_OWNER=Alex Rivera +BOOKING_TITLE=Intro call +BOOKING_TIME_ZONE=Europe/Berlin diff --git a/examples/nextjs-booking/.gitignore b/examples/nextjs-booking/.gitignore new file mode 100644 index 0000000..81f74ff --- /dev/null +++ b/examples/nextjs-booking/.gitignore @@ -0,0 +1,2 @@ +.next/ +next-env.d.ts diff --git a/examples/nextjs-booking/README.md b/examples/nextjs-booking/README.md new file mode 100644 index 0000000..2239991 --- /dev/null +++ b/examples/nextjs-booking/README.md @@ -0,0 +1,41 @@ +# Booking page with calsdk and Next.js + +A one-person booking page: visitors see your free times from Google Calendar, pick one, and get an invite with a Meet link. Without credentials it runs on a demo calendar, so you can deploy it first and connect your calendar later. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvkpdeveloper%2Fcalsdk%2Ftree%2Fmain%2Fexamples%2Fnextjs-booking&project-name=booking-page&repository-name=booking-page) + +## How it works + +- `lib/slots.ts` asks `findFreeTime` for open slots in your working hours over the next 14 days. +- `app/actions.ts` checks the slot is still free, then books it with `createEvent` and emails the invite. +- `lib/calendar.ts` picks Google Calendar when credentials are set, and a demo calendar otherwise. + +Swapping `google()` for `microsoft()` or `icloud()` from calsdk is the only change needed for Outlook or iCloud. + +## Connect your Google Calendar + +1. In the Google Cloud console, enable the Google Calendar API and create an OAuth client of type "Web application" with `https://developers.google.com/oauthplayground` as a redirect URI. +2. Open the [OAuth Playground](https://developers.google.com/oauthplayground), tick "Use your own OAuth credentials" in the settings, authorize the `https://www.googleapis.com/auth/calendar` scope and exchange the code for a refresh token. +3. Publish the OAuth app (it can stay unverified for your own account). While it is in "Testing", Google expires refresh tokens after 7 days. +4. Set these environment variables in Vercel, or in `.env.local` when running locally: + +| Variable | Value | +| ---------------------- | --------------------------------------------------------- | +| `GOOGLE_CLIENT_ID` | OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | OAuth client secret | +| `GOOGLE_REFRESH_TOKEN` | Refresh token from step 2 | +| `BOOKING_OWNER` | Your name, shown on the page | +| `BOOKING_TITLE` | Meeting name, such as `Intro call` | +| `BOOKING_TIME_ZONE` | IANA zone for your working hours, such as `Europe/Berlin` | + +Meeting length, days ahead and working hours live in `lib/config.ts`. + +## Run it locally + +```sh +cp .env.example .env.local +npm install +npm run dev +``` + +From the calsdk repo, run `bun install`, `bun run build` and then `bun run --cwd examples/nextjs-booking dev` instead. diff --git a/examples/nextjs-booking/app/actions.ts b/examples/nextjs-booking/app/actions.ts new file mode 100644 index 0000000..78c5b31 --- /dev/null +++ b/examples/nextjs-booking/app/actions.ts @@ -0,0 +1,42 @@ +'use server'; + +import { createEvent } from '@cal-sdk/core'; +import { revalidatePath } from 'next/cache'; +import { calendar, isDemo } from '@/lib/calendar'; +import { booking } from '@/lib/config'; +import { isStillFree } from '@/lib/slots'; + +export type BookingState = + | { status: 'idle' } + | { status: 'error'; message: string } + | { status: 'booked'; start: string; email: string }; + +export async function book(_previous: BookingState, form: FormData): Promise { + const name = String(form.get('name') ?? '').trim(); + const email = String(form.get('email') ?? '').trim(); + const start = new Date(String(form.get('start') ?? '')); + + if (!name || !/^[^\s@]+@[^\s@]+$/.test(email) || Number.isNaN(start.getTime())) { + return { status: 'error', message: 'Add your name and a valid email.' }; + } + // The page may be minutes old: check the live calendar before writing. + if (!(await isStillFree(start))) { + return { status: 'error', message: 'That time was just taken. Pick another one.' }; + } + + const end = new Date(start.getTime() + booking.duration * 60_000); + await createEvent({ + calendar, + event: { + title: `${booking.title}: ${name}`, + start: { dateTime: start.toISOString(), timeZone: booking.timeZone }, + end: { dateTime: end.toISOString(), timeZone: booking.timeZone }, + attendees: [{ email, name }], + createConference: !isDemo, + }, + sendUpdates: 'all', + }); + + revalidatePath('/'); + return { status: 'booked', start: start.toISOString(), email }; +} diff --git a/examples/nextjs-booking/app/globals.css b/examples/nextjs-booking/app/globals.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/examples/nextjs-booking/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/examples/nextjs-booking/app/layout.tsx b/examples/nextjs-booking/app/layout.tsx new file mode 100644 index 0000000..eba8abb --- /dev/null +++ b/examples/nextjs-booking/app/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; +import { booking } from '@/lib/config'; +import './globals.css'; + +export const metadata: Metadata = { + title: `${booking.title} with ${booking.owner}`, + description: `Book a ${booking.duration}-minute ${booking.title.toLowerCase()} with ${booking.owner}.`, +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/examples/nextjs-booking/app/page.tsx b/examples/nextjs-booking/app/page.tsx new file mode 100644 index 0000000..31c12ab --- /dev/null +++ b/examples/nextjs-booking/app/page.tsx @@ -0,0 +1,36 @@ +import { isDemo } from '@/lib/calendar'; +import { booking } from '@/lib/config'; +import { openSlots } from '@/lib/slots'; +import { Picker } from './picker'; + +// Slots come from the live calendar on every request. +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const slots = await openSlots(); + + return ( +
+
+

{booking.owner}

+

{booking.title}

+
+
+
Length
+
{booking.duration} minutes
+
+
+
Times in
+
{booking.timeZone.replace('_', ' ')}
+
+
+ {isDemo && ( +

+ Demo calendar. Set your Google credentials to take real bookings. +

+ )} +
+ +
+ ); +} diff --git a/examples/nextjs-booking/app/picker.tsx b/examples/nextjs-booking/app/picker.tsx new file mode 100644 index 0000000..d5a7a1f --- /dev/null +++ b/examples/nextjs-booking/app/picker.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useActionState, useState } from 'react'; +import { type BookingState, book } from './actions'; + +export function Picker({ slots, timeZone }: { slots: string[]; timeZone: string }) { + // A fixed locale and zone, so the server and the browser render the same text. + const dayKey = new Intl.DateTimeFormat('en-CA', { timeZone, dateStyle: 'short' }); + const dayLabel = new Intl.DateTimeFormat('en-US', { + timeZone, + weekday: 'short', + month: 'short', + day: 'numeric', + }); + const timeLabel = new Intl.DateTimeFormat('en-US', { + timeZone, + hour: 'numeric', + minute: '2-digit', + }); + + const days = new Map(); + for (const slot of slots) { + const key = dayKey.format(new Date(slot)); + days.set(key, [...(days.get(key) ?? []), slot]); + } + const dayKeys = [...days.keys()]; + + const [day, setDay] = useState(dayKeys[0]); + const [start, setStart] = useState(); + const [state, action, pending] = useActionState(book, { status: 'idle' }); + + if (state.status === 'booked') { + const when = new Date(state.start); + return ( +
+

You're booked

+

+ {dayLabel.format(when)} at {timeLabel.format(when)}. An invite is on its way to{' '} + {state.email}. +

+
+ ); + } + + if (dayKeys.length === 0) { + return

No open times right now. Check back soon.

; + } + + const selectedDay = day && days.has(day) ? day : dayKeys[0]; + + return ( +
+

Pick a day

+
+ {dayKeys.map((key) => { + const first = days.get(key)?.[0] ?? ''; + return ( + + ); + })} +
+ +

Pick a time

+
+ {(days.get(selectedDay ?? '') ?? []).map((slot) => ( + + ))} +
+ + {start && ( +
+ + + + {state.status === 'error' &&

{state.message}

} + +
+ )} +
+ ); +} diff --git a/examples/nextjs-booking/lib/calendar.ts b/examples/nextjs-booking/lib/calendar.ts new file mode 100644 index 0000000..17d3050 --- /dev/null +++ b/examples/nextjs-booking/lib/calendar.ts @@ -0,0 +1,70 @@ +import 'server-only'; +import type { CalendarProvider } from '@cal-sdk/core'; +import { google } from '@cal-sdk/google'; +import { mockCalendar } from '@cal-sdk/mock'; +import { booking } from './config'; + +/** Whether bookings go to a real Google Calendar. Without credentials the page runs on a demo calendar. */ +export const isDemo = !( + process.env.GOOGLE_CLIENT_ID && + process.env.GOOGLE_CLIENT_SECRET && + process.env.GOOGLE_REFRESH_TOKEN +); + +export const calendar: CalendarProvider = isDemo + ? demoCalendar() + : google({ accessToken: googleAccessToken }); + +let cached: { token: string; expiresAt: number } | undefined; + +/** Trades the owner's refresh token for an access token, reused until shortly before it expires. */ +async function googleAccessToken() { + if (cached && Date.now() < cached.expiresAt) return cached.token; + + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: process.env.GOOGLE_CLIENT_ID ?? '', + client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '', + refresh_token: process.env.GOOGLE_REFRESH_TOKEN ?? '', + }), + }); + if (!response.ok) throw new Error(`Google token refresh failed (HTTP ${response.status})`); + const { access_token, expires_in } = (await response.json()) as { + access_token: string; + expires_in: number; + }; + cached = { token: access_token, expiresAt: Date.now() + (expires_in - 60) * 1000 }; + return access_token; +} + +/** A calendar with a few meetings over the next weekdays, so the page has something to work around. */ +function demoCalendar() { + // The next weekdays after today, as YYYY-MM-DD. + const weekdays: string[] = []; + for (let offset = 1; weekdays.length < 3; offset++) { + const date = new Date(Date.now() + offset * 86_400_000); + if (date.getUTCDay() % 6 !== 0) weekdays.push(date.toISOString().slice(0, 10)); + } + // UTC times, so the demo doesn't depend on the server's time zone. + const at = (weekday: number, time: string) => ({ + dateTime: `${weekdays[weekday - 1]}T${time}:00Z`, + timeZone: 'UTC', + }); + + return mockCalendar({ + calendars: [{ id: 'primary', name: 'Demo', timeZone: booking.timeZone }], + events: [ + { + title: 'Standup', + start: at(1, '09:30'), + end: at(1, '10:00'), + recurrence: ['RRULE:FREQ=DAILY;COUNT=14'], + }, + { title: 'Design review', start: at(1, '13:00'), end: at(1, '15:00') }, + { title: 'Lunch', start: at(2, '12:00'), end: at(2, '13:30') }, + { title: 'Customer call', start: at(3, '10:00'), end: at(3, '12:00') }, + ], + }); +} diff --git a/examples/nextjs-booking/lib/config.ts b/examples/nextjs-booking/lib/config.ts new file mode 100644 index 0000000..ee2f4db --- /dev/null +++ b/examples/nextjs-booking/lib/config.ts @@ -0,0 +1,18 @@ +/** Edit these to make the page yours. */ +export const booking = { + owner: process.env.BOOKING_OWNER ?? 'Alex Rivera', + title: process.env.BOOKING_TITLE ?? 'Intro call', + /** Meeting length in minutes. */ + duration: 30, + /** How many days ahead people can book. */ + daysAhead: 14, + /** The owner's working hours. Slots fall inside them. */ + workingHours: { + start: '09:00', + end: '17:00', + timeZone: process.env.BOOKING_TIME_ZONE ?? 'Europe/Berlin', + }, + get timeZone() { + return this.workingHours.timeZone; + }, +}; diff --git a/examples/nextjs-booking/lib/slots.ts b/examples/nextjs-booking/lib/slots.ts new file mode 100644 index 0000000..288c193 --- /dev/null +++ b/examples/nextjs-booking/lib/slots.ts @@ -0,0 +1,32 @@ +import 'server-only'; +import { findFreeTime } from '@cal-sdk/core'; +import { calendar } from './calendar'; +import { booking } from './config'; + +const MINUTE_MS = 60_000; + +/** Free start times from an hour from now until `daysAhead` days out. */ +export async function openSlots(from = new Date(Date.now() + 60 * MINUTE_MS)) { + const { slots } = await findFreeTime({ + calendars: [{ calendar }], + timeMin: from, + timeMax: new Date(from.getTime() + booking.daysAhead * 86_400_000), + duration: booking.duration, + workingHours: booking.workingHours, + slotInterval: booking.duration, + }); + return slots.map((slot) => slot.start); +} + +/** Whether the meeting starting at `start` still fits, checked against the live calendar. */ +export async function isStillFree(start: Date) { + const end = new Date(start.getTime() + booking.duration * MINUTE_MS); + const { slots } = await findFreeTime({ + calendars: [{ calendar }], + timeMin: start, + timeMax: end, + duration: booking.duration, + workingHours: booking.workingHours, + }); + return slots.some((slot) => new Date(slot.start).getTime() === start.getTime()); +} diff --git a/examples/nextjs-booking/package.json b/examples/nextjs-booking/package.json new file mode 100644 index 0000000..c30c70f --- /dev/null +++ b/examples/nextjs-booking/package.json @@ -0,0 +1,28 @@ +{ + "name": "calsdk-nextjs-booking", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@cal-sdk/core": "^0.1.0", + "@cal-sdk/google": "^0.1.0", + "@cal-sdk/mock": "^0.1.0", + "server-only": "0.0.1", + "next": "16.4.5", + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "4.3.4", + "@types/node": "22", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "postcss": "8.5.32", + "tailwindcss": "4.3.4", + "typescript": "7.0.2" + } +} diff --git a/examples/nextjs-booking/postcss.config.mjs b/examples/nextjs-booking/postcss.config.mjs new file mode 100644 index 0000000..297374d --- /dev/null +++ b/examples/nextjs-booking/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/examples/nextjs-booking/tsconfig.json b/examples/nextjs-booking/tsconfig.json new file mode 100644 index 0000000..e1d3fce --- /dev/null +++ b/examples/nextjs-booking/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "paths": { + "@/*": ["./*"] + }, + "plugins": [ + { + "name": "next" + } + ], + "allowJs": true, + "esModuleInterop": true + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +}