# Approve bookings in your admin

Build a Next.js admin page that lists pending clndr.pro bookings with the guest's answers, and approves or declines them with server actions.

On a booking page set to *Needs approval*, every booking starts as `pending` and waits for the host. The clndr.pro dashboard has an approve button, but if your team already works in your own admin, the decision can live there. This page builds `/admin/bookings`: a table of pending requests with the guest's answers, an **Approve** button, and a **Decline** dialog that takes a reason.

Everything here runs on your server with the secret key. You'll need shadcn/ui and, for the emails at the end, [Resend](https://resend.com) or any mail API:

```bash
npm install @clndr-pro/sdk server-only resend
npx shadcn@latest add table badge button dialog label textarea
```

```bash title=".env.local"
CLNDR_SECRET_KEY=clndr_sk_...
RESEND_API_KEY=re_...
```

## Who can see this page

The secret key can read every guest's name, email and answers, and confirm or cancel any booking. Whatever renders this page is as privileged as the key, so put it behind your own auth before it goes anywhere near production. The helper below lets you in during `next dev` and nowhere else until you wire it up:

```ts title="lib/auth.ts"
import 'server-only';
import { notFound } from 'next/navigation';

/** Swap the body for your real check: Auth.js, Clerk, Supabase, a session cookie… */
export async function requireAdmin() {
  if (process.env.NODE_ENV === 'development') return;
  notFound();
}
```

```ts title="lib/clndr.ts"
import 'server-only';
import { Clndr } from '@clndr-pro/sdk';

export const clndr = new Clndr(process.env.CLNDR_SECRET_KEY!);
```

## Tell the guest yourself

Approving through the API confirms the booking, and if the page creates Meet links it also creates the Google Calendar event, and Google sends the guest an invite. clndr.pro doesn't send its own confirmation email for API approvals, and a declined request that never had a calendar event produces no email at all. So the admin sends them:

```ts title="lib/notify.ts"
import 'server-only';
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);
const FROM = 'Ada Lovelace <bookings@example.com>';

function when(iso: string, timeZone: string) {
  return new Date(iso).toLocaleString('en-US', {
    timeZone,
    weekday: 'long',
    month: 'long',
    day: 'numeric',
    hour: 'numeric',
    minute: '2-digit',
    timeZoneName: 'short',
  });
}

export async function sendApproved(to: string, name: string, start: string, timeZone: string) {
  await resend.emails.send({
    from: FROM,
    to,
    subject: `Confirmed: ${when(start, timeZone)}`,
    text: `Hi ${name},\n\nYour call on ${when(start, timeZone)} is confirmed. See you then.`,
  });
}

export async function sendDeclined(to: string, name: string, start: string, timeZone: string, reason?: string) {
  await resend.emails.send({
    from: FROM,
    to,
    subject: `About your request for ${when(start, timeZone)}`,
    text: `Hi ${name},\n\nI can't make ${when(start, timeZone)}.${reason ? `\n\n${reason}` : ''}\n\nYou're welcome to pick another time.`,
  });
}
```

## The actions

Two server actions, both re-checking auth, since a server action is a public endpoint whatever page renders it. Both also read the booking first and act only while it's still `pending`: `PATCH` doesn't look at the current status, so approving a request the host already declined in the dashboard would confirm it again.

```ts title="app/admin/bookings/actions.ts"
'use server';

import { revalidatePath } from 'next/cache';
import { clndr } from '@/lib/clndr';
import { requireAdmin } from '@/lib/auth';
import { sendApproved, sendDeclined } from '@/lib/notify';

/** Someone may have handled it in the dashboard since the page loaded. */
async function stillPending(id: string) {
  const booking = await clndr.bookings.get(id);
  return booking.status === 'pending';
}

export async function approveBooking(id: string, timeZone: string) {
  await requireAdmin();
  if (await stillPending(id)) {
    const booking = await clndr.bookings.update(id, { status: 'confirmed' });

    // With a Meet link, Google has already sent the guest a calendar invite.
    if (!booking.google_meet_link) {
      await sendApproved(booking.guest_email, booking.guest_name, booking.start_time, timeZone);
    }
  }
  revalidatePath('/admin/bookings');
}

export async function declineBooking(id: string, timeZone: string, formData: FormData) {
  await requireAdmin();
  if (await stillPending(id)) {
    const reason = String(formData.get('reason') ?? '').trim() || undefined;
    const booking = await clndr.bookings.update(id, { status: 'cancelled', reason });

    await sendDeclined(booking.guest_email, booking.guest_name, booking.start_time, timeZone, reason);
  }
  revalidatePath('/admin/bookings');
}
```

`reason` is stored on the booking as `cancellation_reason`. The clndr.pro dashboard doesn't display it, which is why the decline email carries it. Cancelling also frees the slot: pending bookings hold their time until someone decides.

## The page

The list endpoint doesn't include answers, so the page reads each pending booking once more for its `meeting_responses`, and each booking page once for its question text. That's `2 + pages + pending` requests per load, well inside the secret key's 300 a minute for a normal queue. If yours runs to hundreds, load answers on a detail page instead.

```tsx title="app/admin/bookings/page.tsx"
import { clndr } from '@/lib/clndr';
import { requireAdmin } from '@/lib/auth';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { approveBooking } from './actions';
import { DeclineButton } from './decline-button';
import { SubmitButton } from './submit-button';

export const dynamic = 'force-dynamic';
export const metadata = { title: 'Booking requests' };

type Answer = { question_id: string; answer: string };

export default async function BookingRequestsPage() {
  await requireAdmin();

  const [pending, pages] = await Promise.all([
    clndr.bookings.list({ status: 'pending', limit: 100 }),
    clndr.bookingPages.list(),
  ]);
  // The API returns the latest start first; put the next decision on top.
  pending.sort((a, b) => Date.parse(a.start_time) - Date.parse(b.start_time));

  const pageById = new Map(pages.map((p) => [p.id, p]));
  const slugs = [...new Set(pending.map((b) => pageById.get(b.booking_page_id)?.slug))].filter(
    (s): s is string => !!s,
  );

  const [details, answers] = await Promise.all([
    // get() 404s for an inactive page; its requests still list, just without question text.
    Promise.all(slugs.map((slug) => clndr.bookingPages.get(slug).catch(() => null))),
    Promise.all(
      pending.map(async (b) => {
        const full = (await clndr.bookings.get(b.id)) as unknown as { meeting_responses?: Answer[] };
        return full.meeting_responses ?? [];
      }),
    ),
  ]);

  const questionText = new Map<string, string>();
  for (const d of details) for (const q of d?.questions ?? []) questionText.set(q.id, q.question_text);
  const timeZone = details.find(Boolean)?.userProfile.timezone ?? 'UTC';

  const when = (iso: string) =>
    new Date(iso).toLocaleString('en-US', {
      timeZone,
      weekday: 'short',
      month: 'short',
      day: 'numeric',
      hour: 'numeric',
      minute: '2-digit',
    });

  return (
    <main className="mx-auto max-w-5xl space-y-6 px-4 py-10">
      <div>
        <h1 className="text-2xl font-semibold">Booking requests</h1>
        <p className="text-sm text-muted-foreground">
          {pending.length === 0 ? 'Nothing waiting on you.' : `${pending.length} waiting. Times in ${timeZone}.`}
        </p>
      </div>

      {pending.length > 0 ? (
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead>Guest</TableHead>
              <TableHead>When</TableHead>
              <TableHead>Answers</TableHead>
              <TableHead className="text-right">Decision</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {pending.map((b, i) => {
              const past = Date.parse(b.start_time) < Date.now();
              return (
                <TableRow key={b.id} className="align-top">
                  <TableCell>
                    <div className="font-medium">{b.guest_name}</div>
                    <a href={`mailto:${b.guest_email}`} className="text-sm text-muted-foreground underline-offset-4 hover:underline">
                      {b.guest_email}
                    </a>
                  </TableCell>
                  <TableCell className="whitespace-nowrap">
                    <div>{when(b.start_time)}</div>
                    <div className="text-sm text-muted-foreground">{pageById.get(b.booking_page_id)?.title}</div>
                    {past ? <Badge variant="destructive" className="mt-1">Time has passed</Badge> : null}
                  </TableCell>
                  <TableCell className="max-w-sm">
                    {answers[i].length === 0 ? (
                      <span className="text-sm text-muted-foreground">No answers</span>
                    ) : (
                      <dl className="space-y-2 text-sm">
                        {answers[i].map((a) => (
                          <div key={a.question_id}>
                            <dt className="text-muted-foreground">{questionText.get(a.question_id) ?? 'Removed question'}</dt>
                            <dd className="whitespace-pre-wrap">{a.answer}</dd>
                          </div>
                        ))}
                      </dl>
                    )}
                  </TableCell>
                  <TableCell>
                    <div className="flex justify-end gap-2">
                      <DeclineButton id={b.id} guestName={b.guest_name} timeZone={timeZone} />
                      {past ? null : (
                        <form action={approveBooking.bind(null, b.id, timeZone)}>
                          <SubmitButton>Approve</SubmitButton>
                        </form>
                      )}
                    </div>
                  </TableCell>
                </TableRow>
              );
            })}
          </TableBody>
        </Table>
      ) : null}
    </main>
  );
}
```

## The buttons

A submit button that disables itself while its form's action runs:

```tsx title="app/admin/bookings/submit-button.tsx"
'use client';

import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';

export function SubmitButton({ children, ...props }: React.ComponentProps<typeof Button>) {
  const { pending } = useFormStatus();
  return (
    <Button type="submit" size="sm" disabled={pending} {...props}>
      {pending ? 'Saving…' : children}
    </Button>
  );
}
```

And the decline dialog. Write the reason as if the guest will read it, because they will:

```tsx title="app/admin/bookings/decline-button.tsx"
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { declineBooking } from './actions';
import { SubmitButton } from './submit-button';

export function DeclineButton({ id, guestName, timeZone }: { id: string; guestName: string; timeZone: string }) {
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="outline" size="sm">
          Decline
        </Button>
      </DialogTrigger>
      <DialogContent>
        <form
          action={async (formData) => {
            await declineBooking(id, timeZone, formData);
            setOpen(false);
          }}
        >
          <DialogHeader>
            <DialogTitle>Decline {guestName}'s request?</DialogTitle>
            <DialogDescription>They'll get an email with your note, and the time opens up again.</DialogDescription>
          </DialogHeader>
          <div className="space-y-2 py-4">
            <Label htmlFor={`reason-${id}`}>Note to the guest (optional)</Label>
            <Textarea id={`reason-${id}`} name="reason" placeholder="I'm travelling that week. Could you pick a time after the 20th?" />
          </div>
          <DialogFooter>
            <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
              Keep it
            </Button>
            <SubmitButton variant="destructive">Decline</SubmitButton>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

## Check it

Book a test meeting on an approval page from its public URL, then open `/admin/bookings` under `next dev`. The request is there with your answers. Approve it: it disappears from the table, turns *Confirmed* under **Meetings** in the clndr.pro dashboard, and either Google's invite or your email arrives. Decline a second one and look for the note in your inbox.

A booking someone already handled in the dashboard is skipped by the `stillPending` check and drops off the list on reload. If an action throws (a deleted booking returns `404`, say), Next.js shows your nearest `error.tsx`. Add one under `app/admin/` with a "Reload" button and you're covered.

**Build an approvals screen for clndr.pro bookings**

```text
Add an admin page at /admin/bookings to this Next.js App Router project that lists pending clndr.pro bookings and lets me approve or decline them.

Read first: https://docs.clndr.pro/tutorials/approvals-dashboard.md and https://docs.clndr.pro/sdk.md

Facts:
- @clndr-pro/sdk 0.1.5+, server only: lib/clndr.ts with `import 'server-only'` and new Clndr(process.env.CLNDR_SECRET_KEY!). Never NEXT_PUBLIC_ for the secret key.
- clndr.bookings.list({ status: 'pending', limit }) returns latest start_time first, max 200, no cursor. It doesn't include answers: clndr.bookings.get(id) adds meeting_responses: [{ question_id, answer }] (not in the SDK's Booking type, so cast). Question text comes from clndr.bookingPages.get(slug).questions; map booking_page_id to slug with clndr.bookingPages.list().
- Approve: clndr.bookings.update(id, { status: 'confirmed' }). Decline: clndr.bookings.update(id, { status: 'cancelled', reason }). PATCH doesn't check the current status, so in each action call clndr.bookings.get(id) first and do nothing unless status === 'pending' (otherwise a booking the host already declined in the dashboard gets confirmed again).
- API approvals don't send clndr.pro's confirmation email; Google invites the guest only when the page creates Meet links (booking.google_meet_link is set after confirming). Declining a pending booking sends nothing. So send our own emails.
- Secret key budget: 300 requests per minute.

Steps:
1. Find how this project does auth and admin checks, and how it sends email. Tell me what you'll use. If there's no admin check, create requireAdmin() that only allows NODE_ENV=development and tell me to replace it.
2. Build the page (server component, shadcn Table if available), server actions for approve and decline (each calls requireAdmin), a decline dialog with an optional note, and pending-state submit buttons.
3. Send the guest an email on approve (when there's no Meet link) and on decline, using the project's email setup.
4. Add the env vars to .env.example. Run the type checker and build; fix what breaks.

Done when: the secret key is only imported by server files, both actions check admin access, past requests can't be approved, and times show in the host's timezone with the zone named.
```
