Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions examples/nextjs-booking/.env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions examples/nextjs-booking/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.next/
next-env.d.ts
41 changes: 41 additions & 0 deletions examples/nextjs-booking/README.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions examples/nextjs-booking/app/actions.ts
Original file line number Diff line number Diff line change
@@ -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<BookingState> {
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 };
}
1 change: 1 addition & 0 deletions examples/nextjs-booking/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "tailwindcss";
17 changes: 17 additions & 0 deletions examples/nextjs-booking/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body className="bg-white text-neutral-900 antialiased">{children}</body>
</html>
);
}
36 changes: 36 additions & 0 deletions examples/nextjs-booking/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto grid max-w-5xl gap-10 px-4 py-10 sm:px-6 sm:py-16 md:grid-cols-[18rem_1fr]">
<header className="md:sticky md:top-16 md:self-start">
<p className="text-sm text-neutral-500">{booking.owner}</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{booking.title}</h1>
<dl className="mt-6 space-y-2 text-sm text-neutral-600">
<div className="flex gap-2">
<dt className="text-neutral-400">Length</dt>
<dd>{booking.duration} minutes</dd>
</div>
<div className="flex gap-2">
<dt className="text-neutral-400">Times in</dt>
<dd>{booking.timeZone.replace('_', ' ')}</dd>
</div>
</dl>
{isDemo && (
<p className="mt-6 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">
Demo calendar. Set your Google credentials to take real bookings.
</p>
)}
</header>
<Picker slots={slots} timeZone={booking.timeZone} />
</main>
);
}
125 changes: 125 additions & 0 deletions examples/nextjs-booking/app/picker.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();
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<string>();
const [state, action, pending] = useActionState<BookingState, FormData>(book, { status: 'idle' });

if (state.status === 'booked') {
const when = new Date(state.start);
return (
<section className="self-start rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-semibold">You're booked</h2>
<p className="mt-2 text-neutral-600">
{dayLabel.format(when)} at {timeLabel.format(when)}. An invite is on its way to{' '}
{state.email}.
</p>
</section>
);
}

if (dayKeys.length === 0) {
return <p className="text-neutral-600">No open times right now. Check back soon.</p>;
}

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

return (
<section className="min-w-0">
<h2 className="text-sm font-medium text-neutral-500">Pick a day</h2>
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-5">
{dayKeys.map((key) => {
const first = days.get(key)?.[0] ?? '';
return (
<button
key={key}
type="button"
onClick={() => {
setDay(key);
setStart(undefined);
}}
aria-pressed={key === selectedDay}
className="min-h-11 rounded-lg border border-neutral-200 px-2 py-2 text-sm hover:border-neutral-400 aria-pressed:border-neutral-900 aria-pressed:bg-neutral-900 aria-pressed:text-white"
>
{dayLabel.format(new Date(first))}
</button>
);
})}
</div>

<h2 className="mt-8 text-sm font-medium text-neutral-500">Pick a time</h2>
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
{(days.get(selectedDay ?? '') ?? []).map((slot) => (
<button
key={slot}
type="button"
onClick={() => setStart(slot)}
aria-pressed={slot === start}
className="min-h-11 rounded-lg border border-neutral-200 px-2 py-2 text-sm tabular-nums hover:border-neutral-400 aria-pressed:border-neutral-900 aria-pressed:bg-neutral-900 aria-pressed:text-white"
>
{timeLabel.format(new Date(slot))}
</button>
))}
</div>

{start && (
<form action={action} className="mt-8 grid max-w-md gap-3">
<input type="hidden" name="start" value={start} />
<label className="grid gap-1 text-sm">
Name
<input
name="name"
required
autoComplete="name"
className="min-h-11 rounded-lg border border-neutral-300 px-3"
/>
</label>
<label className="grid gap-1 text-sm">
Email
<input
name="email"
type="email"
required
autoComplete="email"
className="min-h-11 rounded-lg border border-neutral-300 px-3"
/>
</label>
{state.status === 'error' && <p className="text-sm text-red-700">{state.message}</p>}
<button
type="submit"
disabled={pending}
className="mt-2 min-h-11 rounded-lg bg-neutral-900 px-4 font-medium text-white disabled:opacity-60"
>
{pending
? 'Booking...'
: `Book ${dayLabel.format(new Date(start))}, ${timeLabel.format(new Date(start))}`}
</button>
</form>
)}
</section>
);
}
Loading
Loading