domma-cms 0.102.1 → 0.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/message-actions.js +56 -0
- package/plugins/_lib/admin/mail/reader-view.js +30 -1
- package/plugins/_lib/call/ice.js +58 -0
- package/plugins/_lib/call/rooms.js +232 -0
- package/plugins/_lib/call/routes.js +88 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +468 -0
- package/plugins/booking/CLAUDE.md +123 -0
- package/plugins/booking/admin/css/index.css +192 -0
- package/plugins/booking/admin/lib/details.js +58 -0
- package/plugins/booking/admin/lib/diary-layout.js +140 -0
- package/plugins/booking/admin/lib/extension-sections.js +160 -0
- package/plugins/booking/admin/lib/kit.js +249 -0
- package/plugins/booking/admin/templates/booking.html +1 -0
- package/plugins/booking/admin/views/booking.js +177 -0
- package/plugins/booking/admin/views/bookings.js +127 -0
- package/plugins/booking/admin/views/diary.js +374 -0
- package/plugins/booking/admin/views/panels.js +516 -0
- package/plugins/booking/admin/views/people.js +281 -0
- package/plugins/booking/admin/views/services.js +222 -0
- package/plugins/booking/admin/views/settings.js +203 -0
- package/plugins/booking/collections/booking-bookables.json +17 -0
- package/plugins/booking/collections/booking-bookings.json +18 -0
- package/plugins/booking/collections/booking-services.json +17 -0
- package/plugins/booking/config.js +36 -0
- package/plugins/booking/docs/guide.md +69 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +58 -0
- package/plugins/booking/lib/extensions.js +150 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +384 -0
- package/plugins/booking/plugin.js +760 -0
- package/plugins/booking/plugin.json +80 -0
- package/plugins/booking/plugin.public.js +157 -0
- package/plugins/booking/public/booking.css +159 -0
- package/plugins/booking/public/booking.js +808 -0
- package/plugins/booking/public/lib/clean.js +141 -0
- package/plugins/booking/public/lib/page-format.js +121 -0
- package/plugins/booking/public/lib/slots.js +247 -0
- package/plugins/booking/public/lib/time.js +131 -0
- package/plugins/booking/templates/booking.html +5 -0
- package/plugins/booking/tests/admin-diary.test.js +73 -0
- package/plugins/booking/tests/api.test.js +356 -0
- package/plugins/booking/tests/public.test.js +71 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +44 -1
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- package/server/services/hooks.js +35 -8
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "booking",
|
|
3
|
+
"displayName": "Booking",
|
|
4
|
+
"version": "1.3.1",
|
|
5
|
+
"tier": "free",
|
|
6
|
+
"description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
|
|
7
|
+
"author": "Domma CMS",
|
|
8
|
+
"date": "2026-09-30",
|
|
9
|
+
"icon": "clipboard-check",
|
|
10
|
+
"uses": {
|
|
11
|
+
"contacts": "Customers become contacts; Book… on a contact and a Bookings panel on the contact card",
|
|
12
|
+
"calendar": "Bookings show in the Calendar, and a person's Calendar events block their booking times"
|
|
13
|
+
},
|
|
14
|
+
"permissions": [
|
|
15
|
+
{
|
|
16
|
+
"key": "booking",
|
|
17
|
+
"label": "Booking",
|
|
18
|
+
"group": "Plugins",
|
|
19
|
+
"icon": "clipboard-check",
|
|
20
|
+
"description": "See the diary and bookings (read) or run Booking: make, move and cancel bookings, services, people and rooms, and settings (manage).",
|
|
21
|
+
"actions": [
|
|
22
|
+
{
|
|
23
|
+
"key": "read",
|
|
24
|
+
"label": "See",
|
|
25
|
+
"description": "Open Booking and see the diary and its bookings"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"key": "manage",
|
|
29
|
+
"label": "Manage",
|
|
30
|
+
"description": "Make, move and cancel bookings; edit services, people and rooms, and Booking's settings"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"grant": [
|
|
34
|
+
"admin"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"inject": {
|
|
39
|
+
"bodyEnd": "inject/body.html"
|
|
40
|
+
},
|
|
41
|
+
"admin": {
|
|
42
|
+
"css": [
|
|
43
|
+
"admin/css/index.css"
|
|
44
|
+
],
|
|
45
|
+
"sidebar": [
|
|
46
|
+
{
|
|
47
|
+
"id": "booking",
|
|
48
|
+
"text": "Booking",
|
|
49
|
+
"icon": "clipboard-check",
|
|
50
|
+
"url": "#/plugins/booking",
|
|
51
|
+
"section": "#/plugins/booking",
|
|
52
|
+
"permission": "booking",
|
|
53
|
+
"countUrl": "/api/plugins/booking/count"
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"routes": [
|
|
57
|
+
{
|
|
58
|
+
"path": "/plugins/booking",
|
|
59
|
+
"view": "plugin-booking",
|
|
60
|
+
"title": "Booking - Domma CMS"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "/plugins/booking/:tab",
|
|
64
|
+
"view": "plugin-booking",
|
|
65
|
+
"title": "Booking - Domma CMS"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "/plugins/booking/:tab/:id",
|
|
69
|
+
"view": "plugin-booking",
|
|
70
|
+
"title": "Booking - Domma CMS"
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"views": {
|
|
74
|
+
"plugin-booking": {
|
|
75
|
+
"entry": "booking/admin/views/booking.js?v=1.1.0",
|
|
76
|
+
"exportName": "bookingView"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking - the booking page, at the address in Settings ("/booking" by
|
|
3
|
+
* default):
|
|
4
|
+
*
|
|
5
|
+
* /booking every service that can be booked, then the flow
|
|
6
|
+
* /booking/<service-slug> straight into one service
|
|
7
|
+
* /booking/manage/<id>?t= the customer's own booking: change or cancel it
|
|
8
|
+
* /booking/call/<session>?b=&t= an online booking's video call (h= instead of t= for the host)
|
|
9
|
+
*
|
|
10
|
+
* Like the Shop, the pages are answered from ONE onRequest hook matching the
|
|
11
|
+
* address as it is saved NOW, so moving the page needs no restart. Every page
|
|
12
|
+
* is complete HTML without script (the services, the booking's details);
|
|
13
|
+
* public/booking.js makes it live, and so does the [booking] shortcode.
|
|
14
|
+
*/
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import {fileURLToPath} from 'url';
|
|
17
|
+
import {renderBlogPage} from '../../server/services/renderer.js';
|
|
18
|
+
import {allBookables, allServices, getBooking, liveSettings, publicBooking, tokenOk} from './lib/store.js';
|
|
19
|
+
import {callWindow, whoIsCalling} from './lib/call.js';
|
|
20
|
+
import {dayLabel, lengthLabel} from './public/lib/page-format.js';
|
|
21
|
+
|
|
22
|
+
const TEMPLATE = path.join(fileURLToPath(new URL('.', import.meta.url)), 'templates', 'booking.html');
|
|
23
|
+
export const ASSET_V = '1.3.1';
|
|
24
|
+
|
|
25
|
+
/** Which page a path is, under the booking page's base. */
|
|
26
|
+
export function matchBookingPath(pathname, base) {
|
|
27
|
+
if (pathname === base) return {name: 'index', params: {}};
|
|
28
|
+
if (!pathname.startsWith(`${base}/`)) return null;
|
|
29
|
+
let parts;
|
|
30
|
+
try { parts = pathname.slice(base.length + 1).split('/').map(decodeURIComponent); } catch { return null; }
|
|
31
|
+
if (parts.length === 1 && parts[0] === '') return {name: 'slash', params: {}};
|
|
32
|
+
if (parts.length === 2 && parts[0] === 'manage' && /^[\w-]{8,64}$/.test(parts[1])) return {name: 'manage', params: {id: parts[1]}};
|
|
33
|
+
if (parts.length === 2 && parts[0] === 'call' && /^[\w-]{10,40}$/.test(parts[1])) return {name: 'call', params: {room: parts[1]}};
|
|
34
|
+
if (parts.length === 1 && /^[a-z0-9-]{1,60}$/.test(parts[0]) && parts[0] !== 'manage' && parts[0] !== 'call') return {name: 'service', params: {slug: parts[0]}};
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
39
|
+
|
|
40
|
+
/** Plain text with blank lines between paragraphs, as HTML. */
|
|
41
|
+
const paras = (text, cls = '') => String(text || '').split(/\n\s*\n/).map(p => p.trim()).filter(Boolean)
|
|
42
|
+
.map(p => `<p${cls ? ` class="${cls}"` : ''}>${esc(p).replace(/\n/g, '<br>')}</p>`).join('');
|
|
43
|
+
|
|
44
|
+
/** The mount point the browser script takes over; `inner` is what shows without script. */
|
|
45
|
+
const app = (cfg, inner) => `<div class="bk-app" data-booking='${esc(JSON.stringify(cfg))}'>`
|
|
46
|
+
+ `<noscript><p class="bk-note">Booking needs JavaScript switched on in your browser. You can also contact us to book.</p></noscript>${inner}</div>`;
|
|
47
|
+
|
|
48
|
+
/** Services a customer may book, with at least one active person or room. */
|
|
49
|
+
async function bookableServices() {
|
|
50
|
+
const [services, bookables] = await Promise.all([allServices(), allBookables()]);
|
|
51
|
+
const live = new Set(bookables.filter(b => b.active !== false).map(b => b.id));
|
|
52
|
+
return services.filter(s => s.active !== false && s.public !== false && (s.bookables || []).some(id => live.has(id)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function serviceCards(services, base) {
|
|
56
|
+
if (!services.length) return '<p class="bk-note">Nothing can be booked online just now. Please contact us.</p>';
|
|
57
|
+
return `<ul class="bk-services">${services.map(s => `<li><a class="bk-service" href="${esc(`${base}/${s.slug}`)}">`
|
|
58
|
+
+ `<span class="bk-service-name">${esc(s.name)}</span><span class="bk-service-meta">${esc(lengthLabel(s.duration))}</span>`
|
|
59
|
+
+ (s.description ? `<span class="bk-service-desc">${esc(s.description)}</span>` : '') + '</a></li>').join('')}</ul>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default async function bookingPublic(fastify) {
|
|
63
|
+
async function send(reply, request, s, {body, title, description = '', urlPath, noindex = false, page}) {
|
|
64
|
+
const html = await renderBlogPage(TEMPLATE, {body, page, assetV: ASSET_V}, {
|
|
65
|
+
title, description, urlPath, noindex,
|
|
66
|
+
baseUrl: `${request.protocol}://${request.host || request.headers.host || request.hostname}`
|
|
67
|
+
});
|
|
68
|
+
return reply.type('text/html; charset=utf-8').header('Cache-Control', 'no-store').send(html);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pages = {
|
|
72
|
+
async index(request, reply, s) {
|
|
73
|
+
const services = await bookableServices();
|
|
74
|
+
return send(reply, request, s, {
|
|
75
|
+
page: 'index',
|
|
76
|
+
title: s.pageTitle || 'Book',
|
|
77
|
+
description: String(s.intro || '').slice(0, 200),
|
|
78
|
+
urlPath: s.bookingPath,
|
|
79
|
+
body: `<header class="bk-hero"><h1>${esc(s.pageTitle || 'Book')}</h1>${paras(s.intro, 'bk-intro')}</header>`
|
|
80
|
+
+ app({base: s.bookingPath, service: '', with: '', embedded: false}, serviceCards(services, s.bookingPath))
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async slash(request, reply, s) {
|
|
85
|
+
return reply.redirect(s.bookingPath, 301);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async service(request, reply, s, {slug}) {
|
|
89
|
+
const svc = (await bookableServices()).find(x => x.slug === slug);
|
|
90
|
+
if (!svc) return null; // the CMS's own 404, or a page at that address
|
|
91
|
+
return send(reply, request, s, {
|
|
92
|
+
page: 'service',
|
|
93
|
+
title: `${svc.name} - ${s.pageTitle || 'Book'}`,
|
|
94
|
+
description: String(svc.description || '').slice(0, 200),
|
|
95
|
+
urlPath: `${s.bookingPath}/${slug}`,
|
|
96
|
+
body: `<header class="bk-hero"><p class="bk-crumbs"><a href="${esc(s.bookingPath)}">${esc(s.pageTitle || 'Book')}</a></p>`
|
|
97
|
+
+ `<h1>${esc(svc.name)}</h1><p class="bk-intro">${esc(lengthLabel(svc.duration))}${svc.description ? ` · ${esc(svc.description)}` : ''}</p></header>`
|
|
98
|
+
+ app({base: s.bookingPath, service: svc.id, with: '', embedded: false}, '')
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async manage(request, reply, s, {id}) {
|
|
103
|
+
const b = await getBooking(id);
|
|
104
|
+
const t = String(request.query?.t || '');
|
|
105
|
+
if (!b || !tokenOk(b, t)) return null;
|
|
106
|
+
const p = publicBooking(b, s);
|
|
107
|
+
const rows = [['What', p.serviceName], ['When', `${dayLabel(p.date)}, ${p.time}`], ['With', p.bookableName], ['Name', p.name], ['Email', p.email]]
|
|
108
|
+
.filter(([, v]) => v).map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('');
|
|
109
|
+
const state = p.status === 'cancelled' ? '<p class="bk-status is-cancelled">This booking has been cancelled.</p>' : '';
|
|
110
|
+
return send(reply, request, s, {
|
|
111
|
+
page: 'manage',
|
|
112
|
+
title: `Your booking - ${s.pageTitle || 'Book'}`,
|
|
113
|
+
urlPath: `${s.bookingPath}/manage/${id}`,
|
|
114
|
+
noindex: true,
|
|
115
|
+
body: `<header class="bk-hero"><p class="bk-crumbs"><a href="${esc(s.bookingPath)}">${esc(s.pageTitle || 'Book')}</a></p><h1>Your booking</h1></header>`
|
|
116
|
+
+ app({base: s.bookingPath, mode: 'manage', id: b.id, t, embedded: false}, `${state}<dl class="bk-details">${rows}</dl>`)
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* An online booking's call. The link is checked here only to decide
|
|
123
|
+
* between the call and a plain "not valid" page; the call's own join
|
|
124
|
+
* checks it again (and the time window) before anyone is let in.
|
|
125
|
+
*/
|
|
126
|
+
pages.call = async function call(request, reply, s, {room}) {
|
|
127
|
+
const q = request.query || {};
|
|
128
|
+
const cred = {b: String(q.b || '').slice(0, 64), ...(q.h ? {h: String(q.h).slice(0, 128)} : {t: String(q.t || '').slice(0, 128)})};
|
|
129
|
+
const b = await getBooking(cred.b).catch(() => null);
|
|
130
|
+
const who = whoIsCalling(b, room, cred);
|
|
131
|
+
const head = `<header class="bk-hero"><p class="bk-crumbs"><a href="${esc(s.bookingPath)}">${esc(s.pageTitle || 'Book')}</a></p>`;
|
|
132
|
+
if (!who) {
|
|
133
|
+
return send(reply, request, s, {page: 'call', title: `Call - ${s.pageTitle || 'Book'}`, urlPath: `${s.bookingPath}/call/${room}`, noindex: true,
|
|
134
|
+
body: `${head}<h1>This call link is not valid</h1></header><p class="bk-note">The booking may have been cancelled or moved. Check the latest email we sent you.</p>`});
|
|
135
|
+
}
|
|
136
|
+
const {closesAt} = callWindow(b);
|
|
137
|
+
const cfg = {api: '/api/plugins/booking/call', room, cred, title: `${b.serviceName}${who.host ? `: ${b.name}` : b.bookableName ? ` with ${b.bookableName}` : ''}`,
|
|
138
|
+
askName: false, recordingAllowed: who.recordingAllowed, closesAt};
|
|
139
|
+
return send(reply, request, s, {page: 'call', title: `${b.serviceName} - online`, urlPath: `${s.bookingPath}/call/${room}`, noindex: true,
|
|
140
|
+
body: `<link rel="stylesheet" href="/plugins/_lib/public/call/call.css?v=${ASSET_V}">`
|
|
141
|
+
+ `<div data-dm-call='${esc(JSON.stringify(cfg))}'><noscript><p class="bk-note">The call needs JavaScript switched on.</p></noscript></div>`
|
|
142
|
+
+ `<script type="module" src="/plugins/_lib/public/call/call.js?v=${ASSET_V}"></script>`});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
146
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
|
147
|
+
const s = await liveSettings();
|
|
148
|
+
const hit = matchBookingPath(request.url.split('?')[0], s.bookingPath);
|
|
149
|
+
if (!hit) return;
|
|
150
|
+
const sent = await pages[hit.name](request, reply, s, hit.params);
|
|
151
|
+
if (sent === null) return; // not ours after all
|
|
152
|
+
return sent;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The onRequest hook must see every request, not only routes registered here (it registers none).
|
|
157
|
+
bookingPublic[Symbol.for('skip-override')] = true;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Booking - the booking page and the [booking] shortcode.
|
|
3
|
+
*
|
|
4
|
+
* Built on the site's theme tokens (--dm-*), each with a neutral fallback, so
|
|
5
|
+
* the page wears whatever theme the site wears - light, dark or custom. Only
|
|
6
|
+
* .bk-* classes are styled; nothing here touches a bare element outside them.
|
|
7
|
+
*
|
|
8
|
+
* The tokens are resolved on body, not :root: a theme sets its --dm-* on the
|
|
9
|
+
* body's dm-theme-* class, and a property defined on :root would freeze the
|
|
10
|
+
* root's (light) values for every descendant.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
body {
|
|
14
|
+
--bk-accent: var(--dm-primary, #4f46e5);
|
|
15
|
+
--bk-accent-ink: var(--dm-primary-text, #fff);
|
|
16
|
+
--bk-bg: var(--dm-surface, #fff);
|
|
17
|
+
--bk-text: var(--dm-text, #16181d);
|
|
18
|
+
--bk-muted: var(--dm-text-muted, #6b7280);
|
|
19
|
+
--bk-line: var(--dm-border, rgba(127, 127, 127, .28));
|
|
20
|
+
--bk-soft: color-mix(in srgb, var(--bk-text) 5%, transparent);
|
|
21
|
+
--bk-danger: var(--dm-danger, #dc2626);
|
|
22
|
+
--bk-good: var(--dm-success, #16a34a);
|
|
23
|
+
--bk-radius: var(--dm-radius-lg, 14px);
|
|
24
|
+
--bk-radius-sm: var(--dm-radius-md, 10px);
|
|
25
|
+
--bk-ease: cubic-bezier(.2, .8, .2, 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.bk-page { padding-top: 1.5rem; padding-bottom: 4rem; color: var(--bk-text); }
|
|
29
|
+
.bk-page h1, .bk-app h2, .bk-app h3 { color: var(--bk-text); }
|
|
30
|
+
.bk-page [hidden], .bk-app [hidden] { display: none !important; }
|
|
31
|
+
|
|
32
|
+
.bk-hero { margin: .5rem 0 1.5rem; }
|
|
33
|
+
.bk-hero h1 { font-size: clamp(1.9rem, 4vw, 2.8rem); letter-spacing: -.02em; line-height: 1.1; margin: 0 0 .5rem; }
|
|
34
|
+
.bk-intro { max-width: 62ch; font-size: 1.08rem; color: var(--bk-muted); margin: 0 0 .5rem; }
|
|
35
|
+
.bk-crumbs { margin: 0 0 .4rem; font-size: .9rem; }
|
|
36
|
+
.bk-crumbs a { color: var(--bk-muted); }
|
|
37
|
+
|
|
38
|
+
.bk-app { max-width: 46rem; color: var(--bk-text); }
|
|
39
|
+
.bk-app.is-embedded { margin: 1.5rem 0; }
|
|
40
|
+
.bk-step-title { font-size: 1.35rem; margin: 0 0 1rem; outline: none; }
|
|
41
|
+
/* Focused by script so a screen reader starts each step there; not a control, so no ring. */
|
|
42
|
+
.bk-app .bk-step-title:focus, .bk-app .bk-step-title:focus-visible { outline: none !important; box-shadow: none !important; border: 0 !important; }
|
|
43
|
+
.bk-times-title { font-size: 1rem; margin: 1.25rem 0 .6rem; }
|
|
44
|
+
.bk-note, .bk-zone, .bk-hint { color: var(--bk-muted); }
|
|
45
|
+
.bk-zone { font-size: .9rem; margin: 1rem 0 0; }
|
|
46
|
+
.bk-hint { font-size: .85rem; margin: 0 0 .3rem; }
|
|
47
|
+
.bk-lead { font-size: 1.05rem; }
|
|
48
|
+
.bk-summary { margin: 0 0 1rem; padding: .55rem .8rem; border-radius: var(--bk-radius-sm); background: var(--bk-soft); font-size: .95rem; }
|
|
49
|
+
|
|
50
|
+
.bk-live { min-height: 0; margin: 0; font-size: .95rem; }
|
|
51
|
+
.bk-live:not(:empty) { margin: 0 0 1rem; padding: .55rem .8rem; border-radius: var(--bk-radius-sm); background: var(--bk-soft); }
|
|
52
|
+
.bk-live.is-error { color: var(--bk-danger); background: color-mix(in srgb, var(--bk-danger) 10%, transparent); }
|
|
53
|
+
|
|
54
|
+
/* ---- Buttons ---- */
|
|
55
|
+
|
|
56
|
+
.bk-app button { font: inherit; }
|
|
57
|
+
.bk-primary, .bk-secondary, .bk-danger {
|
|
58
|
+
display: inline-flex; align-items: center; justify-content: center; gap: .4rem; min-height: 2.75rem;
|
|
59
|
+
padding: .6rem 1.2rem; border-radius: var(--bk-radius-sm); border: 1px solid transparent; cursor: pointer;
|
|
60
|
+
font-weight: 600; text-decoration: none !important; transition: all .18s var(--bk-ease); }
|
|
61
|
+
.bk-primary { background: var(--bk-accent); color: var(--bk-accent-ink) !important; }
|
|
62
|
+
.bk-primary:hover { filter: brightness(1.08); }
|
|
63
|
+
.bk-secondary { background: transparent; border-color: var(--bk-line); color: var(--bk-text) !important; }
|
|
64
|
+
.bk-secondary:hover { border-color: var(--bk-accent); color: var(--bk-accent) !important; }
|
|
65
|
+
.bk-danger { background: transparent; border-color: color-mix(in srgb, var(--bk-danger) 55%, transparent); color: var(--bk-danger) !important; }
|
|
66
|
+
.bk-danger:hover { background: color-mix(in srgb, var(--bk-danger) 10%, transparent); }
|
|
67
|
+
.bk-app button:disabled { opacity: .45; cursor: not-allowed; }
|
|
68
|
+
.bk-link, .bk-back { background: none; border: 0; padding: .25rem 0; color: var(--bk-accent); cursor: pointer; text-decoration: underline; }
|
|
69
|
+
.bk-back { display: inline-block; margin: -.4rem 0 .8rem; text-decoration: none; color: var(--bk-muted); font-size: .92rem; }
|
|
70
|
+
.bk-back:hover { color: var(--bk-accent); }
|
|
71
|
+
.bk-actions { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 1.25rem; align-items: center; }
|
|
72
|
+
.bk-app :focus-visible { outline: 3px solid color-mix(in srgb, var(--bk-accent) 55%, transparent); outline-offset: 2px; }
|
|
73
|
+
|
|
74
|
+
/* ---- Services and who ---- */
|
|
75
|
+
|
|
76
|
+
.bk-services, .bk-whos { list-style: none; margin: 0; padding: 0; display: grid; gap: .7rem; }
|
|
77
|
+
.bk-page .bk-services, .bk-app .bk-services, .bk-app .bk-whos { padding-left: 0 !important; margin-left: 0 !important; }
|
|
78
|
+
.bk-services > li, .bk-whos > li { margin: 0; padding: 0; }
|
|
79
|
+
.bk-services > li::marker, .bk-whos > li::marker { content: none; }
|
|
80
|
+
@media (min-width: 640px) { .bk-services { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
81
|
+
.bk-service, .bk-who {
|
|
82
|
+
display: flex; flex-direction: column; align-items: flex-start; gap: .2rem; width: 100%; height: 100%; text-align: left;
|
|
83
|
+
padding: 1rem 1.1rem; border: 1px solid var(--bk-line); border-radius: var(--bk-radius); background: var(--bk-bg);
|
|
84
|
+
color: var(--bk-text) !important; cursor: pointer; text-decoration: none !important; transition: all .18s var(--bk-ease); }
|
|
85
|
+
.bk-service:hover, .bk-who:hover { border-color: var(--bk-accent); box-shadow: 0 0 0 1px var(--bk-accent); }
|
|
86
|
+
.bk-service-name, .bk-who-name { font-weight: 600; font-size: 1.05rem; }
|
|
87
|
+
.bk-service-meta, .bk-who-meta { color: var(--bk-muted); font-size: .9rem; }
|
|
88
|
+
.bk-service-desc { color: var(--bk-muted); font-size: .92rem; margin-top: .2rem; }
|
|
89
|
+
|
|
90
|
+
/* ---- Week strip and times ---- */
|
|
91
|
+
|
|
92
|
+
.bk-week { display: flex; align-items: stretch; gap: .35rem; }
|
|
93
|
+
.bk-days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: .3rem; flex: 1; min-width: 0; }
|
|
94
|
+
.bk-week-move { flex: none; width: 2.25rem; border: 1px solid var(--bk-line); border-radius: var(--bk-radius-sm); background: var(--bk-bg);
|
|
95
|
+
color: var(--bk-text); cursor: pointer; font-size: 1.3rem; line-height: 1; }
|
|
96
|
+
.bk-week-move:not(:disabled):hover { border-color: var(--bk-accent); color: var(--bk-accent); }
|
|
97
|
+
.bk-day { display: flex; flex-direction: column; align-items: center; gap: .05rem; padding: .45rem 0; min-width: 0;
|
|
98
|
+
border: 1px solid var(--bk-line); border-radius: var(--bk-radius-sm); background: var(--bk-bg); color: var(--bk-text); cursor: pointer; }
|
|
99
|
+
.bk-day:not(:disabled):hover { border-color: var(--bk-accent); }
|
|
100
|
+
.bk-day.is-on { background: var(--bk-accent); border-color: var(--bk-accent); color: var(--bk-accent-ink); }
|
|
101
|
+
.bk-app .bk-day:disabled { opacity: .38; }
|
|
102
|
+
.bk-day-name { font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; }
|
|
103
|
+
.bk-day-num { font-size: 1.15rem; font-weight: 700; line-height: 1.2; }
|
|
104
|
+
.bk-day-month { font-size: .7rem; opacity: .75; }
|
|
105
|
+
.bk-times { display: grid; grid-template-columns: repeat(auto-fill, minmax(5.2rem, 1fr)); gap: .45rem; }
|
|
106
|
+
.bk-time { min-height: 2.75rem; border: 1px solid var(--bk-line); border-radius: var(--bk-radius-sm); background: var(--bk-bg);
|
|
107
|
+
color: var(--bk-text); cursor: pointer; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
108
|
+
.bk-time:hover { border-color: var(--bk-accent); color: var(--bk-accent); }
|
|
109
|
+
|
|
110
|
+
/* ---- Details form ---- */
|
|
111
|
+
|
|
112
|
+
.bk-form { display: grid; gap: 1rem; margin-top: 1rem; }
|
|
113
|
+
.bk-field label { display: block; font-weight: 600; margin-bottom: .3rem; }
|
|
114
|
+
.bk-req { color: var(--bk-danger); }
|
|
115
|
+
.bk-opt { color: var(--bk-muted); font-weight: 400; font-size: .88rem; }
|
|
116
|
+
.bk-app .bk-input { display: block; width: 100%; max-width: 100%; box-sizing: border-box; padding: .6rem .75rem; font: inherit;
|
|
117
|
+
border: 1px solid var(--bk-line); border-radius: var(--bk-radius-sm); background: var(--bk-bg); color: var(--bk-text); }
|
|
118
|
+
.bk-app .bk-input:focus { border-color: var(--bk-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--bk-accent) 20%, transparent); outline: none; }
|
|
119
|
+
.bk-field.has-error .bk-input { border-color: var(--bk-danger); }
|
|
120
|
+
.bk-error { color: var(--bk-danger); font-size: .9rem; margin: .3rem 0 0; }
|
|
121
|
+
.bk-terms { font-size: .9rem; color: var(--bk-muted); margin: 0; }
|
|
122
|
+
.bk-hp { position: absolute !important; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
|
|
123
|
+
|
|
124
|
+
/* ---- Booking details ---- */
|
|
125
|
+
|
|
126
|
+
.bk-details { display: grid; gap: .1rem; margin: 0 0 .5rem; padding: .75rem 1rem; border: 1px solid var(--bk-line); border-radius: var(--bk-radius); background: var(--bk-bg); }
|
|
127
|
+
.bk-details > div { display: grid; grid-template-columns: 5.5rem minmax(0, 1fr); gap: .75rem; padding: .3rem 0; }
|
|
128
|
+
.bk-details dt { color: var(--bk-muted); margin: 0; }
|
|
129
|
+
.bk-details dd { margin: 0; overflow-wrap: anywhere; }
|
|
130
|
+
.bk-status.is-cancelled { color: var(--bk-danger); font-weight: 600; }
|
|
131
|
+
|
|
132
|
+
/* The site's own link and form styles reach in here. */
|
|
133
|
+
.bk-app a.bk-secondary, .bk-app a.bk-primary, .bk-service, .bk-crumbs a { text-decoration: none !important; }
|
|
134
|
+
.bk-app .bk-input { min-height: 0; margin: 0; }
|
|
135
|
+
.bk-app textarea.bk-input { resize: vertical; }
|
|
136
|
+
|
|
137
|
+
@media (max-width: 480px) {
|
|
138
|
+
.bk-week { gap: .15rem; }
|
|
139
|
+
.bk-days { gap: .15rem; }
|
|
140
|
+
.bk-week-move { width: 1.6rem; font-size: 1.1rem; }
|
|
141
|
+
.bk-day { padding: .4rem 0 .35rem; }
|
|
142
|
+
.bk-day-name { font-size: .6rem; letter-spacing: 0; }
|
|
143
|
+
.bk-day-num { font-size: 1rem; }
|
|
144
|
+
.bk-day-month { display: none; }
|
|
145
|
+
.bk-actions > * { flex: 1 1 100%; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ---- 1.1: places, full classes, the extra questions ---- */
|
|
149
|
+
|
|
150
|
+
.bk-time small { display: block; font-size: .72rem; font-weight: 400; color: var(--bk-muted); line-height: 1.2; }
|
|
151
|
+
.bk-times:has(small) { grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); }
|
|
152
|
+
.bk-time.is-full { border-style: dashed; color: var(--bk-muted); background: transparent; }
|
|
153
|
+
.bk-time.is-full small { color: inherit; }
|
|
154
|
+
.bk-time.is-full:hover { border-color: var(--bk-accent); color: var(--bk-accent); }
|
|
155
|
+
.bk-app .bk-questions { border: 1px solid var(--bk-line); border-radius: var(--bk-radius-sm); padding: .9rem 1rem .2rem; margin: 0 0 1rem; min-width: 0; }
|
|
156
|
+
.bk-app .bk-questions legend { font-weight: 600; padding: 0 .35rem; color: var(--bk-text); }
|
|
157
|
+
.bk-app select.bk-input { appearance: auto; }
|
|
158
|
+
.bk-check-field .bk-check { display: flex; align-items: flex-start; gap: .5rem; font-weight: 500; cursor: pointer; }
|
|
159
|
+
.bk-check-field input[type="checkbox"] { width: 1.15rem; height: 1.15rem; margin: .15rem 0 0; flex: none; accent-color: var(--bk-accent); }
|