RANSON.N visits customers at home. Visitors pick a day and an hour on the website and send an appointment request. The business decides in its dashboard, in an app called Boekingen: confirm the request, decline it, or propose another moment. The visitor gets a mail each time, sent by the platform in RANSON.N's name.
This page walks through the real code of the RANSON.N site repository. It builds on Declaring an app, Building the page and Server side; code is trimmed where marked, never changed.
The pieces
| File | Role |
|---|---|
cms.config.ts | Declares the app boekingen and the Beschikbaarheid global (weekly schedule, appointment length, closed periods). |
src/app/api/afspraak/route.ts | The website's form endpoint: stores the request as form afspraak with the chosen moment as its slot. |
src/lib/boekingen.ts | Booking logic shared by site and app: submission → Boeking, free moments, the mails. |
src/lib/beheer.ts | One helper around appPlatform for both apps of the site. |
src/app/(beheer)/beheer/boekingen/page.tsx | The app's page, at the declared path. |
src/components/beheer/app-sessie.tsx | Connects to the dashboard once and provides the session. |
src/components/beheer/boekingen.tsx | The desk itself (client component). |
src/app/api/beheer/boekingen/route.ts | GET: the open requests plus the free moments. |
src/app/api/beheer/boekingen/[id]/route.ts | POST: one decision about one request. |
visitor ──POST /api/afspraak──▶ submitForm({ form: "afspraak", slot, confirm }) → status "new"
team opens Apps → Boekingen ──▶ /beheer/boekingen inside the dashboard frame (connectApp)
desk ──GET /api/beheer/boekingen──▶ app.submissions("afspraak") + free moments
desk ──POST /api/beheer/boekingen/<id>──▶ app.updateSubmission(id, { status, slot, notify })The form the app reads
The app owns no data. It works on the submissions of an ordinary form, afspraak, sent with
client.submitForm (Forms). Two extras make it a booking:
slot: the moment the request claims, asYYYY-MM-DDTHH:mm. Only one non-spam submission of the same form on the same site may hold a slot; a second one is refused with409 conflict, even when two visitors click at the same moment.confirm: a mail to the visitor at submit time ("we received your request"), sent in the business's identity.
// src/app/api/afspraak/route.ts (trimmed)
const result = await forwardForm(
"afspraak",
data,
[
{ name: "datum", required: true },
{ name: "tijd", required: true },
{ name: "naam", required: true },
{ name: "email", required: true, email: true },
{ name: "telefoon" },
{ name: "adres", required: true },
{ name: "opmerking" },
],
{ naam: "je naam", email: "je e-mailadres", adres: "het adres van de afspraak" },
{
clientIp: visitorIp(req.headers),
slot: `${datum}T${tijd}`,
confirm: { to: email, ...mails.aangevraagd({ naam, moment: { datum, tijd }, adres, duur }) },
},
);Before this call the route checks the chosen moment against the Beschikbaarheid global, so
a stale calendar cannot book a closed day. forwardForm (src/lib/forms.ts) validates the
fields, calls client.submitForm, and turns a 409 into Dit moment is net door iemand anders
geboekt. Kies een ander uur.
Remember the field names (datum, tijd, naam, email, telefoon, adres, opmerking):
the app reads them back from values. The email value also decides where the mails go later;
the platform mails the address the visitor typed, never one the app chooses.
Free moments
Bookable moments come from the Beschikbaarheid global; taken moments come from
client.formSlots("afspraak", { from }). vrijeMomentenNu() in src/lib/boekingen.ts
subtracts the second from the first and returns { "2026-11-03": ["09:00", "10:00"], … }. The
website calendar and the app use the same function, with the site's own API token: the app
session plays no part in reading content.
Declaring the app
// cms.config.ts
const boekingen = defineApp({
handle: "boekingen",
name: "Boekingen",
description: "Aanvragen van de website bevestigen, afwijzen of een ander moment voorstellen.",
icon: "calendar-check",
path: "/beheer/boekingen",
// Bookings hold names and addresses: the same people who read the form inbox.
area: "website",
level: "edit",
});area: "website" with level: "edit" is deliberate. The apps API hands out submissions only
to people with the form-inbox right (Website-inhoud → Bewerken), so an app that reads
submissions should never open for anyone below that level; it would only show an error. The
agency team always opens it, as manage.
The page
src/app/(beheer)/beheer/boekingen/page.tsx is a Server Component that renders
<AppSessie dashboardOrigin={DASHBOARD_ORIGIN}><Boekingen /></AppSessie> with
dynamic = "force-dynamic". AppSessie calls connectApp, shows Even laden… during the
handshake and Enkel via het dashboard when the page is opened outside the dashboard, and
otherwise provides { session, app } through useAppSessie(). Both files are shown in full in
Building the page. The (beheer) route
group has its own bare layout with noindex.
Reading the requests: GET /api/beheer/boekingen
// src/app/api/beheer/boekingen/route.ts
export async function GET(request: Request) {
const app = await beheer(request, "boekingen");
if (!app) return nietAangemeld();
const [rijen, vrij] = await Promise.all([
app.submissions("afspraak", { status: ["new", "waiting", "accepted"], limit: 300 }),
vrijeMomentenNu(),
]);
const vandaag = new Date().toISOString().slice(0, 10);
const boekingen = rijen
.map(boekingVan)
.filter((b) => b.status !== "accepted" || (b.moment?.datum ?? "") >= vandaag);
return Response.json(
{ boekingen, vrij, kanBeslissen: appLevelAtLeast(app.session.level, "edit") },
{ headers: { "cache-control": "no-store" } },
);
}beheer(request, "boekingen")isappPlatformwith the site's secret and dashboard origin filled in (Server side). It returnsnullfor a missing, forged, expired or other-app session; the route answers401.app.submissionscallsGET /api/apps/v1/submissionsas the person using the app. Declined and closed requests are left out by the status filter; past confirmed appointments are filtered on the site.kanBeslissentells the page whether to show the decision buttons. The server decides; the page never guesses from the claims it read in the browser.
boekingVan (src/lib/boekingen.ts) maps an AppSubmission to a Boeking. It reads the
visitor's values defensively and keeps two moments apart: gevraagd, from the form values
(what the visitor asked, never changes), and moment, from slot (what the request holds now:
the asked moment, a proposed one, or null once released). That is what lets the desk say
Voorgesteld: donderdag 5 november om 14:00 (gevraagd: dinsdag 3 november om 10:00).
Deciding: POST /api/beheer/boekingen/[id]
One route takes the three decisions; the body names the action:
type Actie =
| { actie: "bevestig" }
| { actie: "afwijzen"; reden?: string }
| { actie: "voorstel"; datum: string; tijd: string; uitleg?: string };The route verifies the session and looks the request up again on the platform, so it works on the current state and never on what the browser sent:
const app = await beheer(request, "boekingen");
if (!app) return nietAangemeld();
const { id } = await params;
const body = (await request.json().catch(() => null)) as Actie | null;
const huidige = (await app.submissions("afspraak", { status: ["new", "waiting", "accepted"], limit: 500 }))
.map(boekingVan)
.find((b) => b.id === id);
if (!huidige) return fout(404, "Deze aanvraag bestaat niet meer of is al afgehandeld.");Accept
if (body?.actie === "bevestig") {
const moment = huidige.moment ?? huidige.gevraagd;
if (!moment) return fout(422, "Deze aanvraag heeft geen moment.");
const r = await app.updateSubmission(id, {
status: "accepted",
slot: slotVan(moment),
notify: mails.bevestigd(huidige, moment),
});
return Response.json({ boeking: boekingVan(r.submission), gemaild: r.mailed });
}Accepting confirms the moment the request holds now. Sending slot again makes it safe: if
the slot was released meanwhile and someone else took it, the platform answers 409 and
nothing changes.
Decline
if (body?.actie === "afwijzen") {
const r = await app.updateSubmission(id, {
status: "declined",
notify: mails.afgewezen(huidige, kort(body.reden, 1000)),
});
return Response.json({ boeking: boekingVan(r.submission), gemaild: r.mailed });
}status: "declined" always releases the slot, so the moment is free again on the website
calendar. The desk uses the same branch to cancel a confirmed appointment (Annuleren).
Propose another moment
if (body?.actie === "voorstel") {
const moment: Moment = { datum: kort(body.datum, 10), tijd: kort(body.tijd, 5) };
const eigen = huidige.moment && slotVan(huidige.moment) === slotVan(moment);
const vrij = await vrijeMomentenNu();
if (!eigen && !(vrij[moment.datum] ?? []).includes(moment.tijd))
return fout(409, "Dat moment is niet (meer) vrij. Kies een ander.");
const r = await app.updateSubmission(id, {
status: "waiting",
slot: slotVan(moment),
notify: mails.voorstel(huidige, moment, kort(body.uitleg, 1000)),
});
return Response.json({ boeking: boekingVan(r.submission), gemaild: r.mailed });
}Two checks guard the new moment: the site checks its own availability (never a moment outside
the weekly schedule), the platform checks that no other request holds it (409). Moving the
slot releases the old moment and claims the new one in one step, so the proposed moment stays
reserved while the business waits for an answer (waiting = Wacht op antwoord). When the
visitor agrees by mail or phone, Klant gaat akkoord: bevestigen runs the accept branch.
Errors
} catch (e) {
if (e instanceof KwispleError && e.status === 409)
return fout(409, "Dat moment is net door een andere aanvraag genomen. Kies een ander.");
if (e instanceof KwispleError && e.status === 403)
return fout(403, "Je mag enkel kijken in deze app.");
console.error("[beheer] boekingen", e instanceof KwispleError ? `${e.status} ${e.code}` : e);
return fout(502, "Dat lukte niet. Probeer opnieuw.");
}This route does not call appLevelAtLeast before writing: the apps API refuses
updateSubmission below edit with 403, which the route turns into a message. If your route
does work of its own before calling the platform (another system, a mail of your own), check
appLevelAtLeast(app.session.level, "edit") first.
The desk component
src/components/beheer/boekingen.tsx loads GET /api/beheer/boekingen with app.fetch and
shows three groups: Nieuwe aanvragen (new), Wacht op antwoord (waiting) and
Bevestigd (accepted), each with a count and one card per request (name, moment, address,
mail and phone as links, remark), and Aangemeld als kanBeslissen is true, each card has its actions:
| Button | Shown when | Sends |
|---|---|---|
| Bevestigen / Klant gaat akkoord: bevestigen | not yet confirmed | { actie: "bevestig" } |
| Ander moment voorstellen | always | opens Dag, Uur (free moments only) and an optional explanation; Voorstel mailen sends { actie: "voorstel", datum, tijd, uitleg } |
| Afwijzen / Annuleren | always | opens an optional reason; Afwijzen en mailen / Afspraak annuleren en mailen sends { actie: "afwijzen", reden } |
Every action goes through one function that posts with the session and reloads the list:
async function beslis(body: Record<string, string>) {
setBezig(true);
setMelding(null);
const r = await app.fetch(`/api/beheer/boekingen/${encodeURIComponent(b.id)}`, {
method: "POST",
body: JSON.stringify(body),
});
const j = (await r.json().catch(() => ({}))) as { fout?: string };
setBezig(false);
if (!r.ok) return setMelding(j.fout ?? "Dat lukte niet. Probeer opnieuw.");
setOpen(null);
await onKlaar();
}The server's fout sentence is shown as is, so the component needs no knowledge of slots or
status codes. When kanBeslissen is false the desk shows Je kan hier enkel kijken. and no
buttons; with level: "edit" that never happens for clients, but the desk stays correct if the
app's level is ever lowered to view.
RANSON.N works in Dutch only and formats dates with a fixed Dutch formatter. For a team that
works in several languages, format with session.locale instead
(UI advice).
The mails to the visitor
All texts live in one mails object in src/lib/boekingen.ts, in the business's own voice:
aangevraagd (at submit time, through confirm), and bevestigd, afgewezen and voorstel
(through notify). Each returns { subject, text }:
bevestigd: (b: Boeking, m: Moment) => ({
subject: `Bevestigd: je afspraak bij RANSON.N op ${toonDatum(m.datum)} om ${m.tijd}`,
text: [
`Dag ${voornaam(b.naam)},`,
`Je afspraak staat vast: we komen langs op ${toonDatum(m.datum)} om ${m.tijd}${b.adres ? ` op ${b.adres}` : ""}.`,
"Past het toch niet? Antwoord gewoon op deze mail of bel ons even.",
"Tot dan!\nRANSON.N",
].join("\n\n"),
}),What the platform does with them (Server side):
plain text with blank lines as paragraphs, sent in the business's identity with its contact
address as reply-to (so "answer this mail" works), always to the address from the form.
subject may hold 150 characters and text 4000; the route caps the free-text reason and
explanation at 1000 so a long note never pushes a mail over the limit. mailed: true means
queued, not delivered; without a usable address the status still changes and mailed is
false.
The same submissions under Website → Formulieren
The app and the dashboard's form inbox work on the same rows. Under Website → Formulieren
(Website → Forms) every booking is an afspraak message with the same Opvolging
(follow-up) status the app sets (Nieuw, Wacht op antwoord, Aanvaard,
Afgewezen, Afgehandeld), the Moment it holds, Antwoorden (reply) to mail the
visitor by hand, and Exporteren (CSV).
Whoever changes a status last wins. Someone may set a booking to Afgehandeld in the inbox
after the visit; it then leaves the desk, which lists only new, waiting and accepted.
Every status change, from the app or the inbox, emits the lead.updated webhook for the client
when an endpoint listens to it, so a CRM can follow the bookings
(Outgoing webhooks). Spam submissions never reach the app.
Adapting it to another business
- Another form: change the handle in
app.submissions(...)and in the submit route, and map your own field names in aboekingVan-like function. - Another slot format: any string of up to 60 letters, digits and
: _ . T -works; the platform only compares strings. Keep it sortable soformSlots({ from, to })stays useful. - Several resources (rooms, employees): put the resource in the slot
(
2026-11-03T10:00_zaal-a) so each resource has its own moments. - No mails: leave
notifyout; the status still changes.
Test from the dashboard (the frame loads the production URL): as a team member, then with Bekijk als klant (view as client) for a role without Website-inhoud → Bewerken, which must not see the app.