@acms-subir/core 0.3.55 → 0.3.56

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/docs/calendar.md +75 -2
  3. package/docs/mobile.md +3 -1
  4. package/docs/server.md +51 -0
  5. package/integration.mjs +34 -0
  6. package/mobile-template/client/AGENTS.md +1 -0
  7. package/mobile-template/client/README.md +1 -1
  8. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  9. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  10. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  12. package/mobile-template/recipes/event-request/recipe.json +21 -0
  13. package/package.json +1 -1
  14. package/src/components/MobileApp.tsx +2 -2
  15. package/src/components/editor/event/SingleDates.tsx +12 -2
  16. package/src/layouts/Admin.astro +7 -0
  17. package/src/lib/calendar/request-admin.ts +211 -0
  18. package/src/lib/calendar/request-config.ts +8 -0
  19. package/src/lib/calendar/request-mail.ts +158 -0
  20. package/src/lib/calendar/request-page.ts +52 -0
  21. package/src/lib/calendar/request-service.ts +535 -0
  22. package/src/lib/calendar/request-store.ts +2 -2
  23. package/src/lib/calendar/request-words.ts +263 -0
  24. package/src/lib/email/service.ts +6 -1
  25. package/src/lib/legal/app-privacy.ts +2 -2
  26. package/src/lib/legal/facts.ts +4 -0
  27. package/src/lib/legal/text-en.ts +3 -0
  28. package/src/lib/legal/text-es.ts +3 -0
  29. package/src/lib/mobile/http.ts +2 -1
  30. package/src/lib/mobile/openapi.ts +8 -0
  31. package/src/lib/mobile/requests.ts +27 -0
  32. package/src/lib/mobile/schemas.ts +14 -3
  33. package/src/lib/mobile/site.ts +3 -0
  34. package/src/lib/push/types.ts +1 -1
  35. package/src/lib/scheduler.ts +3 -0
  36. package/src/lib/schema.ts +43 -2
  37. package/src/pages/admin/calendar/events/index.astro +2 -0
  38. package/src/pages/admin/calendar/requests.astro +298 -0
  39. package/src/pages/api/v1/requests/index.ts +6 -0
  40. package/src/pages/api/v1/requests/options.ts +7 -0
  41. package/src/pages/request/index.astro +182 -0
  42. package/src/server/index.ts +56 -1
  43. package/src/styles/site.css +29 -0
  44. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,298 @@
1
+ ---
2
+ import Admin from '../../../layouts/Admin.astro';
3
+ import Modal from '../../../components/Modal.astro';
4
+ import { getRepo } from '../../../lib/repo';
5
+ import { ApiError } from '../../../lib/api';
6
+ import { extraLocales, localeName, localePath } from '../../../lib/i18n';
7
+ import { PUSH } from '../../../lib/push/config';
8
+ import { accessFor, forbiddenUnless } from '../../../lib/permissions/access';
9
+ import type { Permission } from '../../../lib/permissions/catalog';
10
+ import { REQUESTS } from '../../../lib/calendar/request-config';
11
+ import { MEMBERS } from '../../../lib/calendar/members-config';
12
+ import { approveRequest, declineRequest, deleteRequest, replyToRequest, requestRecipients, requestableRooms, requestSettings } from '../../../lib/calendar/request-service';
13
+ import { pendingCount, requestRows, settingsFromForm, type RequestTab } from '../../../lib/calendar/request-admin';
14
+ import { approveEventRequest, declineEventRequest } from '../../../lib/calendar/requests';
15
+
16
+ // Calendar → Requests (acms({ requests })): requests from the app, the
17
+ // website and hosts in one list, with Pending, Approved and Declined tabs and
18
+ // what takes the rooms on each row. Approve opens a filled-in draft (or the
19
+ // event a change is about); Reply and Decline email the person in their
20
+ // language. Reading needs "Create and edit content", answering "Publish
21
+ // content", the settings "Manage rooms and calendars". The rules are
22
+ // lib/calendar/request-service.ts; the list lib/calendar/request-admin.ts.
23
+ if (!REQUESTS.enabled) return new Response(null, { status: 404 });
24
+ const repo = await getRepo(Astro.locals);
25
+ const denied = await forbiddenUnless(Astro.request, Astro.cookies, repo, 'content.edit');
26
+ if (denied) return denied;
27
+ const access = (await accessFor(Astro.request, Astro.cookies, repo))!;
28
+ const who = { id: access.userId, can: (p: string) => access.can(p as Permission) };
29
+ const hooks = { url: Astro.url, locals: Astro.locals };
30
+ const back = (notice: string, tab = Astro.url.searchParams.get('tab') ?? '') => {
31
+ const p = new URLSearchParams();
32
+ if (tab) p.set('tab', tab);
33
+ if (notice) p.set('notice', notice);
34
+ const q = p.toString();
35
+ return Astro.redirect(q ? '/admin/calendar/requests?' + q : '/admin/calendar/requests');
36
+ };
37
+
38
+ if (Astro.request.method === 'POST') {
39
+ const form = await Astro.request.formData();
40
+ const op = String(form.get('op') ?? '');
41
+ const id = String(form.get('id') ?? '');
42
+ const seen = Number(form.get('at')) || undefined;
43
+ const nope = await forbiddenUnless(Astro.request, Astro.cookies, repo, op === 'settings' ? 'schedule.manage' : 'content.publish');
44
+ if (nope) return nope;
45
+ try {
46
+ if (op === 'approve') {
47
+ const done = await approveRequest(repo, who, id, hooks, seen, { message: String(form.get('message') ?? ''), email: form.get('email') === '1' });
48
+ return Astro.redirect(done.next);
49
+ }
50
+ if (op === 'decline') {
51
+ // the email choice is only offered for requests from the app or the website (hosts are always told)
52
+ const email = form.has('email-offered') ? form.get('email') === '1' : true;
53
+ await declineRequest(repo, who, id, String(form.get('reason') ?? ''), hooks, seen, { email });
54
+ return back(email ? 'Declined. The person was emailed.' : 'Declined, without an email.');
55
+ }
56
+ if (op === 'delete') {
57
+ await deleteRequest(repo, who, id);
58
+ return back('Deleted as spam. Nobody was emailed.');
59
+ }
60
+ if (op === 'reply') {
61
+ await replyToRequest(repo, who, id, String(form.get('message') ?? ''), hooks);
62
+ return back('Reply sent.');
63
+ }
64
+ // members' requests staged on their events (My events)
65
+ if (op === 'member-approve') {
66
+ const e = await approveEventRequest(repo, who, id, hooks, seen);
67
+ return back(`Approved “${e.title}”.`);
68
+ }
69
+ if (op === 'member-decline') {
70
+ const e = await declineEventRequest(repo, who, id, String(form.get('reason') ?? ''), hooks, seen);
71
+ return back(`Declined “${e.title}”.`);
72
+ }
73
+ if (op === 'settings') {
74
+ const settings = await repo.getSettings();
75
+ const calendars = (await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id);
76
+ const requests = settingsFromForm(form, requestSettings(settings), calendars);
77
+ await repo.updateSettings({ calendar: { ...settings.calendar, requests } });
78
+ return back('Request settings saved.');
79
+ }
80
+ return back('');
81
+ } catch (err) {
82
+ return back(err instanceof ApiError ? err.message : 'Couldn’t do that. Try again.');
83
+ }
84
+ }
85
+
86
+ const settings = await repo.getSettings();
87
+ const rq = requestSettings(settings);
88
+ const tab = (['pending', 'approved', 'declined'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'pending';
89
+ const page = Math.min(1000, Math.max(1, Number.parseInt(Astro.url.searchParams.get('page') ?? '', 10) || 1));
90
+ const [{ rows, more }, waiting, rooms, calendars, recipients] = await Promise.all([
91
+ requestRows(repo, settings, tab as RequestTab, Date.now(), page), pendingCount(repo), requestableRooms(repo),
92
+ repo.listCalendars().then((cs) => cs.filter((c) => c.kind === 'native')), requestRecipients(repo, settings),
93
+ ]);
94
+ const notice = Astro.url.searchParams.get('notice') ?? '';
95
+ const canAnswer = access.can('content.publish');
96
+ const canSet = access.can('schedule.manage');
97
+ const langs = extraLocales();
98
+ // the kinds there are, plus a few empty rows to add more (12 at most)
99
+ const typeRows = [...rq.types, ...Array.from({ length: Math.min(3, 12 - rq.types.length) }, () => null)];
100
+ const TABS = [['pending', 'Pending'], ['approved', 'Approved'], ['declined', 'Declined']] as const;
101
+ const formUrl = REQUESTS.page ? REQUESTS.path : '';
102
+ const pageHref = (n: number) => '/admin/calendar/requests?' + new URLSearchParams({ ...(tab === 'pending' ? {} : { tab }), ...(n > 1 ? { page: String(n) } : {}) }).toString();
103
+ const empty = { pending: 'Nothing waiting. Requests from the app, the website and hosts show up here.', approved: 'No approved requests yet.', declined: 'No declined requests.' }[tab];
104
+ ---
105
+
106
+ <Admin title="Requests" section="calendar:requests">
107
+ <div class="page-head">
108
+ <div>
109
+ <h1>Requests</h1>
110
+ <p class="sub">From the app, the website{MEMBERS.enabled ? ' and hosts' : ''}, in one list{formUrl && <> · the form is at <a href={formUrl} target="_blank" rel="noopener">{formUrl}</a>{langs.map((l) => <> and <a href={localePath(formUrl, l)} target="_blank" rel="noopener">{localePath(formUrl, l)}</a></>)}</>}</p>
111
+ </div>
112
+ <div class="page-actions">
113
+ {!rq.open && <span class="rq-closed">Closed</span>}
114
+ {canSet && <button class="btn" type="button" data-modal="rq-settings">Settings</button>}
115
+ </div>
116
+ </div>
117
+
118
+ {notice && <p class="notice" role="status">{notice}</p>}
119
+
120
+ <nav class="seg rq-tabs" aria-label="Show">
121
+ {TABS.map(([k, l]) => (
122
+ <a href={k === 'pending' ? '/admin/calendar/requests' : '/admin/calendar/requests?tab=' + k} class={tab === k ? 'on' : ''} aria-current={tab === k ? 'page' : undefined}>
123
+ {l}{k === 'pending' && waiting > 0 && <span class="rq-count">{waiting}</span>}
124
+ </a>
125
+ ))}
126
+ </nav>
127
+
128
+ {rows.length === 0 ? <p class="hint rq-empty">{empty}</p> : (
129
+ <section class="card rq-list">
130
+ {rows.map((r) => (
131
+ <article class="rq-row" id={r.dom}>
132
+ <small class="rq-head">{r.head}</small>
133
+ <b class="rq-title">{r.href ? <a href={r.href}>{r.title}</a> : r.title}</b>
134
+ {r.line && <span class="rq-line">{r.line}</span>}
135
+ <span class="rq-who">{r.who}</span>
136
+ {r.note && <p class="rq-note">“{r.note}”</p>}
137
+ {r.clash && <p class={r.clash.taken ? 'rq-clash is-taken' : 'rq-clash'}>{r.clash.text}</p>}
138
+ {r.answer && <p class="rq-answer">{r.answer}{r.reply && <> — “{r.reply}”</>}</p>}
139
+ {tab === 'pending' && canAnswer && (
140
+ <div class="rq-acts">
141
+ {(r.kind === 'new' || r.kind === 'change') && <button class="btn sm primary" type="button" data-modal={'approve-' + r.dom}>Approve…</button>}
142
+ {r.kind === 'move' && <form method="post"><input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>}
143
+ {(r.kind === 'move' || r.kind === 'from-date') && r.href && <a class="btn sm ghost" href={r.href}>Open the event</a>}
144
+ {r.kind === 'member-new' || r.kind === 'member-changes' ? (
145
+ <>
146
+ <a class="btn sm ghost" href={r.href}>Open</a>
147
+ <form method="post"><input type="hidden" name="op" value="member-approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>
148
+ </>
149
+ ) : r.canReply && r.email && <button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button>}
150
+ {r.kind !== 'from-date' && <button class="btn sm ghost" type="button" data-modal={'decline-' + r.dom}>Decline…</button>}
151
+ </div>
152
+ )}
153
+ {tab !== 'pending' && r.canReply && r.email && canAnswer && <div class="rq-acts"><button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button></div>}
154
+ </article>
155
+ ))}
156
+ </section>
157
+ )}
158
+ {(page > 1 || more) && (
159
+ <nav class="rq-pager" aria-label="Pages">
160
+ {page > 1 && <a class="btn sm ghost" href={pageHref(page - 1)}>Previous</a>}
161
+ <span class="hint">Page {page}</span>
162
+ {more && <a class="btn sm ghost" href={pageHref(page + 1)}>Next</a>}
163
+ </nav>
164
+ )}
165
+ {tab === 'pending' && rows.length > 0 && !canAnswer && <p class="hint">Someone whose role can publish content answers these.</p>}
166
+
167
+ {canAnswer && rows.map((r) => (
168
+ <>
169
+ {tab === 'pending' && (r.kind === 'new' || r.kind === 'change') && (
170
+ <Modal id={'approve-' + r.dom} title={r.kind === 'new' ? 'Approve and make a draft' : 'Approve this change'}>
171
+ <form method="post">
172
+ <input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
173
+ <p class="hint">{r.kind === 'new'
174
+ ? 'You’ll get a draft with the title, time, repeat, rooms held and the kind’s tag and calendar. Check it, then publish it. Their contact details stay here, never on the event.'
175
+ : 'The event opens (or that date’s Move… dialog) so you can make the change.'}</p>
176
+ <label class="field"><span>Message <em class="hint">optional</em></span><textarea name="message" rows="3" maxlength="2000" placeholder="See you then!"></textarea></label>
177
+ <label class="check-row"><input type="checkbox" name="email" value="1" checked /> Email them that it’s approved, in their language</label>
178
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Approve</button></div>
179
+ </form>
180
+ </Modal>
181
+ )}
182
+ {r.canReply && r.email && (
183
+ <Modal id={'reply-' + r.dom} title="Reply">
184
+ <form method="post">
185
+ <input type="hidden" name="op" value="reply" /><input type="hidden" name="id" value={r.id} />
186
+ <p class="hint">Emailed to {r.email} with a short line in their language. The request stays {tab === 'pending' ? 'waiting' : tab}.</p>
187
+ <label class="field"><span>Your reply</span><textarea name="message" rows="5" maxlength="2000" required placeholder="Could you come at 3 instead? The room is free then."></textarea></label>
188
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Send reply</button></div>
189
+ </form>
190
+ </Modal>
191
+ )}
192
+ {tab === 'pending' && r.kind !== 'from-date' && (
193
+ <Modal id={'decline-' + r.dom} title="Decline this request">
194
+ <form method="post">
195
+ <input type="hidden" name="op" value={r.kind === 'member-new' || r.kind === 'member-changes' ? 'member-decline' : 'decline'} /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
196
+ <p class="hint">{r.kind === 'member-new' || r.kind === 'member-changes' || r.kind === 'move'
197
+ ? 'They’re emailed that it can’t go ahead' + (r.kind === 'member-new' ? '; the draft stays for them to change and send again.' : r.kind === 'move' ? '; the date stays as it is.' : '.')
198
+ : 'It moves to Declined, and is deleted with the rest after the time you keep requests.'}</p>
199
+ <label class="field"><span>Reason <em class="hint">optional</em></span><textarea name="reason" rows="3" maxlength="2000" placeholder="The Sanctuary is booked that weekend."></textarea></label>
200
+ {(r.kind === 'new' || r.kind === 'change') && <label class="check-row"><input type="hidden" name="email-offered" value="1" /><input type="checkbox" name="email" value="1" checked /> Email them, in their language</label>}
201
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Decline</button></div>
202
+ </form>
203
+ {(r.kind === 'new' || r.kind === 'change') && (
204
+ <form method="post" class="rq-spam">
205
+ <input type="hidden" name="op" value="delete" /><input type="hidden" name="id" value={r.id} />
206
+ <p class="hint">Spam? Delete it now, without an email to the address it gave.</p>
207
+ <button class="btn sm ghost" type="submit">Delete as spam</button>
208
+ </form>
209
+ )}
210
+ </Modal>
211
+ )}
212
+ </>
213
+ ))}
214
+
215
+ {canSet && (
216
+ <Modal id="rq-settings" title="Request settings" open={Astro.url.searchParams.has('settings')}>
217
+ <form method="post" class="rq-settings">
218
+ <input type="hidden" name="op" value="settings" />
219
+ <div>
220
+ <label class="check-row"><input type="checkbox" name="open" value="1" checked={rq.open} /> Taking requests</label>
221
+ <p class="hint">When off, the form and the app say so and show the office phone. They never disappear.</p>
222
+ </div>
223
+ <fieldset>
224
+ <legend>Kinds people choose from</legend>
225
+ <p class="hint">Approving a request makes a draft with the kind’s tag, in its calendar (else the default calendar).</p>
226
+ <div class="rq-type rq-type-h" aria-hidden="true"><span>Kind</span>{langs.map((l) => <span>{localeName(l, true)}</span>)}<span>Tag</span><span>Calendar</span></div>
227
+ {typeRows.map((t, i) => (
228
+ <div class="rq-type">
229
+ {t && <input type="hidden" name={'type-id-' + i} value={t.id} />}
230
+ <input name={'type-label-' + i} value={t?.label ?? ''} placeholder={t || i > rq.types.length ? '' : rq.types.length ? 'Add a kind' : 'Class'} aria-label="Kind" maxlength="60" />
231
+ {langs.map((l) => <input name={'type-' + l + '-' + i} value={t?.translations[l] ?? ''} placeholder={localeName(l, true)} aria-label={'Kind in ' + localeName(l)} maxlength="60" />)}
232
+ <input name={'type-tag-' + i} value={t?.tag ?? ''} placeholder="Tag" aria-label="Tag" maxlength="60" />
233
+ <select name={'type-cal-' + i} aria-label="Calendar">
234
+ <option value="">Default calendar</option>
235
+ {calendars.map((c) => <option value={c.id} selected={t?.calendarId === c.id}>{c.name}</option>)}
236
+ </select>
237
+ </div>
238
+ ))}
239
+ </fieldset>
240
+ <label class="field"><span>Reply time <em class="hint">“We usually answer within …”</em></span><input name="replyTime" value={rq.replyTime} placeholder="3 days" maxlength="60" /></label>
241
+ <label class="field"><span>Notice at the top of the form <em class="hint">optional</em></span><textarea name="notice" rows="2" maxlength="400" placeholder="The church office reviews every request. It isn’t on the calendar until they confirm it.">{rq.notice}</textarea></label>
242
+ {langs.map((l) => (
243
+ <fieldset>
244
+ <legend>{localeName(l, true)}</legend>
245
+ <label class="field"><span>Reply time</span><input name={'replyTime-' + l} value={rq.translations[l]?.replyTime ?? ''} maxlength="60" /></label>
246
+ <label class="field"><span>Notice</span><textarea name={'notice-' + l} rows="2" maxlength="400">{rq.translations[l]?.notice ?? ''}</textarea></label>
247
+ </fieldset>
248
+ ))}
249
+ <label class="field"><span>Office phone <em class="hint">shown when requests are closed, and after sending</em></span><input name="phone" inputmode="tel" autocomplete="off" value={rq.phone} maxlength="40" /></label>
250
+ <label class="field"><span>Who hears about new requests</span><textarea name="notify" rows="2" placeholder={recipients.join(', ')}>{rq.notify.join(', ')}</textarea><em class="hint">Email addresses. Empty: {MEMBERS.notify.length ? 'the members notify list in the site’s config' : 'the owners'} ({recipients.join(', ') || 'nobody yet'}).</em></label>
251
+ {PUSH.enabled && (
252
+ <div>
253
+ <label class="check-row"><input type="hidden" name="phones" value="0" /><input type="checkbox" name="phones" value="1" checked={rq.phones} /> Also notify their linked phones</label>
254
+ <p class="hint">A push saying a request arrived and for when, without names.</p>
255
+ </div>
256
+ )}
257
+ <div class="field-row">
258
+ <label class="field"><span>New requests a day <em class="hint">0 = no limit</em></span><input name="perDay" type="number" min="0" max="10000" value={rq.perDay} /></label>
259
+ <label class="field"><span>Keep requests for (days) <em class="hint">after the answer, or after they arrive if nobody answers</em></span><input name="keepDays" type="number" min="7" max="1095" value={rq.keepDays} /></label>
260
+ </div>
261
+ <p class="hint">Rooms people can ask for: {rooms.length ? rooms.map((r) => r.name).join(', ') : 'none yet'}. Turn on “Anyone can check if it’s free” on a room to offer it.</p>
262
+ <div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Save</button></div>
263
+ </form>
264
+ </Modal>
265
+ )}
266
+ </Admin>
267
+
268
+ <style>
269
+ .rq-tabs { margin: 0 0 14px; }
270
+ .rq-count { font-size: 11px; font-weight: 700; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 0 6px; margin-left: 6px; }
271
+ .rq-closed { font-size: 12.5px; font-weight: 600; color: var(--amber); background: var(--amber-soft); border-radius: var(--r-full); padding: 3px 10px; }
272
+ .rq-empty { padding: 24px 8px; }
273
+ .rq-list { padding: 0; }
274
+ .rq-row { display: grid; gap: 3px; padding: 14px 18px; border-top: 1px solid var(--line); scroll-margin-top: 80px; }
275
+ .rq-row:first-child { border-top: 0; }
276
+ .rq-row:target { background: var(--amber-soft); }
277
+ .rq-head { color: var(--text-2); font-size: 12.5px; }
278
+ .rq-title { font-size: 15.5px; }
279
+ .rq-title a { color: inherit; }
280
+ .rq-line, .rq-who { font-size: 13.5px; color: var(--text-2); }
281
+ .rq-note { margin: 4px 0 0; font-style: italic; font-size: 14px; }
282
+ .rq-clash { margin: 4px 0 0; font-size: 13px; color: var(--green); }
283
+ .rq-clash.is-taken { color: var(--amber); }
284
+ .rq-answer { margin: 4px 0 0; font-size: 13px; color: var(--text-2); }
285
+ .rq-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
286
+ .rq-acts form { margin: 0; }
287
+ .rq-pager { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; }
288
+ .rq-spam { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); margin-top: 14px; padding-top: 12px; }
289
+ .rq-spam .hint { margin: 0; }
290
+ .rq-settings { display: grid; gap: 12px; }
291
+ .rq-settings fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin: 0; display: grid; gap: 8px; }
292
+ .rq-settings legend { font-weight: 600; font-size: 13.5px; padding: 0 4px; }
293
+ .rq-settings .hint { margin: 2px 0 0; }
294
+ .rq-type { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; }
295
+ .rq-type > * { min-width: 0; }
296
+ .rq-type-h span { font-size: 12px; color: var(--text-2); }
297
+ :global(dialog#rq-settings.modal) { width: min(680px, calc(100vw - 32px)); max-width: none; }
298
+ </style>
@@ -0,0 +1,6 @@
1
+ import { postRequest } from '../../../../lib/mobile/requests';
2
+ import { privateRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ export const POST = privateRoute(postRequest, { write: true, status: 201 });
6
+ export const ALL = () => methodNotAllowed('POST, OPTIONS');
@@ -0,0 +1,7 @@
1
+ import { getRequestOptions } from '../../../../lib/mobile/requests';
2
+ import { privateRoute } from '../../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../../lib/mobile/http';
5
+ // never cached: closing requests or marking a room requestable shows at once
6
+ export const GET = privateRoute(getRequestOptions);
7
+ export const ALL = () => methodNotAllowed('GET, OPTIONS');
@@ -0,0 +1,182 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { ApiError } from '../../lib/api';
5
+ import { readForm } from '../../lib/forms/http';
6
+ import { I18N, localePath, requestLocale as pageLocale } from '../../lib/i18n';
7
+ import { siteLang, siteLocale } from '../../lib/dates';
8
+ import { LEGAL } from '../../lib/legal/config';
9
+ import { fill } from '../../lib/live/words';
10
+ import { REQUESTS } from '../../lib/calendar/request-config';
11
+ import { requestOptions, submitRequest, requestLocale, type RequestRefusal } from '../../lib/calendar/request-service';
12
+ import { requestStrings } from '../../lib/calendar/request-words';
13
+ import { formRequest, sentSummary } from '../../lib/calendar/request-page';
14
+
15
+ // Request an event (acms({ requests })): the same request as the app's
16
+ // screen, for anyone, without an account, at /request and under each
17
+ // language's prefix (/es/request). The page posts to itself; the rules,
18
+ // limits and emails are lib/calendar/request-service.ts. Themed through the
19
+ // site's tokens (the booking form's pc-bk-* fields); a site file at this path
20
+ // replaces it, and acms({ requests: { page: false } }) drops it.
21
+ if (!REQUESTS.enabled) return new Response(null, { status: 404 });
22
+ Astro.response.headers.set('Cache-Control', 'private, no-store');
23
+
24
+ const repo = await getRepo(Astro.locals);
25
+ const settings = await repo.getSettings();
26
+ const locale = I18N.enabled ? requestLocale(pageLocale(Astro.url, Astro.locals)) : '';
27
+ const lang = locale || (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
28
+ const dateLocale = lang.split('-')[0] === siteLang(settings) ? siteLocale(settings) : lang;
29
+ const w = requestStrings(lang);
30
+ const options = await requestOptions(repo, locale);
31
+
32
+ let error = '';
33
+ let field = '';
34
+ let values: Record<string, string | string[]> = { kind: 'new', repeat: 'once' };
35
+ let key: string = crypto.randomUUID();
36
+ let sent: { email: string; title: string; line: string } | null = null;
37
+ if (Astro.request.method === 'POST') {
38
+ try {
39
+ const data = await readForm(Astro.request);
40
+ values = Object.fromEntries([...new Set(data.keys())].map((k) => [k, k === 'rooms' ? data.getAll(k).map(String) : String(data.get(k) ?? '')]));
41
+ key = String(data.get('requestId') ?? '');
42
+ const { request } = await submitRequest(repo, formRequest(data, locale), { source: 'web', key, address: Astro.clientAddress, locals: Astro.locals });
43
+ sent = { email: request.requester?.email ?? '', ...await sentSummary(repo, request, settings, lang, dateLocale) };
44
+ } catch (e) {
45
+ const code = (e as { code?: RequestRefusal }).code;
46
+ field = code ?? '';
47
+ error = code ? w.errors[code] : e instanceof ApiError && e.status !== 500 ? e.message : w.errors.failed;
48
+ Astro.response.status = e instanceof ApiError ? e.status : 500;
49
+ // a refused request can be fixed and sent again with the same key; one that went through can't
50
+ if (code === 'changed') key = crypto.randomUUID();
51
+ }
52
+ }
53
+ const v = (k: string) => { const x = values[k]; return Array.isArray(x) ? x[0] ?? '' : x ?? ''; };
54
+ const chosenRooms = Array.isArray(values.rooms) ? values.rooms : values.rooms ? [values.rooms] : [];
55
+ const today = new Intl.DateTimeFormat('en-CA', { timeZone: settings.timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(Date.now());
56
+ const privacy = LEGAL.enabled ? localePath('/privacy', locale) : '';
57
+ const self = localePath(REQUESTS.path, locale);
58
+ const tel = (p: string) => 'tel:' + p.replace(/[^\d+]/g, '');
59
+ const invalid = (...codes: string[]) => (codes.includes(field) ? 'true' : undefined);
60
+ const fieldWords = { free: w.free, taken: w.taken };
61
+ ---
62
+ <Site title={w.heading}>
63
+ <section class="pc-simple-form pc-rq">
64
+ <h1>{sent ? w.sent : w.heading}</h1>
65
+ {sent ? (
66
+ <div class="pc-rq-sent" role="status">
67
+ <p>{options.replyTime ? fill(w.answerWithin, { time: options.replyTime, email: sent.email }) : fill(w.answerBy, { email: sent.email })}</p>
68
+ <div class="pc-rq-card"><small>{w.yours}</small><b>{sent.title}</b><span>{sent.line}</span></div>
69
+ <p class="pc-bk-hint">{w.notYet}{options.phone && <> <a href={tel(options.phone)}>{fill(w.sooner, { phone: options.phone })}</a></>}</p>
70
+ <p><a href={self}>{w.another}</a></p>
71
+ </div>
72
+ ) : !options.open ? (
73
+ <div class="pc-rq-closed" role="status">
74
+ <p>{w.closed}</p>
75
+ {options.phone && <p><a href={tel(options.phone)}>{fill(w.call, { phone: options.phone })}</a></p>}
76
+ </div>
77
+ ) : (
78
+ <form method="post" action={self} class="pc-rq-form" data-tz={options.timezone} data-words={JSON.stringify(fieldWords)}>
79
+ <p class="pc-rq-notice">{options.notice || w.notice}</p>
80
+ {error && <p class="pc-bk-error" role="alert">{error}</p>}
81
+ <input type="hidden" name="requestId" value={key} />
82
+ <div class="pc-form-trap" aria-hidden="true"><label>Leave empty<input name="website" tabindex="-1" autocomplete="off" /></label></div>
83
+ <fieldset class="pc-rq-kind">
84
+ <legend class="pc-rq-sr">{w.heading}</legend>
85
+ <label><input type="radio" name="kind" value="new" checked={v('kind') !== 'change'} /><span>{w.somethingNew}</span></label>
86
+ <label><input type="radio" name="kind" value="change" checked={v('kind') === 'change'} /><span>{w.change}</span></label>
87
+ </fieldset>
88
+
89
+ <div class="pc-rq-new">
90
+ <label class="pc-bk-field"><span>{w.what}</span><input name="title" maxlength="200" value={v('title')} aria-invalid={invalid('title')} /></label>
91
+ {options.types.length > 0 && (
92
+ <fieldset class="pc-bk-field pc-bk-choices"><legend>{w.type}</legend>
93
+ <div class="pc-rq-chips">{options.types.map((t) => <label class="pc-bk-option"><input type="radio" name="type" value={t.id} checked={v('type') === t.id} /><span>{t.label}</span></label>)}</div>
94
+ </fieldset>
95
+ )}
96
+ <div class="pc-rq-when">
97
+ <label class="pc-bk-field"><span>{w.date}</span><input type="date" name="date" min={today} value={v('date')} aria-invalid={invalid('start', 'past', 'far')} /></label>
98
+ <label class="pc-bk-field"><span>{w.starts}</span><input type="time" name="start" step="300" value={v('start')} aria-invalid={invalid('start', 'past')} /></label>
99
+ <label class="pc-bk-field"><span>{w.ends}</span><input type="time" name="end" step="300" value={v('end')} aria-invalid={invalid('end')} /></label>
100
+ </div>
101
+ <fieldset class="pc-bk-field pc-bk-choices"><legend>{w.repeats}</legend>
102
+ <div class="pc-rq-chips">
103
+ <label class="pc-bk-option"><input type="radio" name="repeat" value="once" checked={v('repeat') !== 'weekly' && v('repeat') !== 'other'} /><span>{w.once}</span></label>
104
+ <label class="pc-bk-option"><input type="radio" name="repeat" value="weekly" checked={v('repeat') === 'weekly'} /><span>{w.weekly}</span></label>
105
+ <label class="pc-bk-option"><input type="radio" name="repeat" value="other" checked={v('repeat') === 'other'} /><span>{w.other}</span></label>
106
+ </div>
107
+ <label class="pc-bk-field pc-rq-other"><span>{w.otherHint}</span><input name="repeatText" maxlength="200" value={v('repeatText')} aria-invalid={invalid('repeat')} /></label>
108
+ </fieldset>
109
+ {options.rooms.length > 0 && (
110
+ <fieldset class="pc-bk-field pc-bk-choices"><legend>{w.room}</legend>
111
+ <div class="pc-rq-chips">
112
+ {options.rooms.map((r) => <label class="pc-bk-option"><input type="checkbox" name="rooms" value={r.slug} data-name={r.name} checked={chosenRooms.includes(r.slug)} /><span>{r.name}</span></label>)}
113
+ <label class="pc-bk-option"><input type="checkbox" name="roomsUnsure" value="1" checked={v('roomsUnsure') === '1'} /><span>{w.notSure}</span></label>
114
+ </div>
115
+ <p class="pc-rq-free" aria-live="polite"></p>
116
+ </fieldset>
117
+ )}
118
+ <label class="pc-bk-field"><span>{w.people} <em>{w.optional}</em></span><input type="number" name="people" min="1" max="100000" inputmode="numeric" value={v('people')} aria-invalid={invalid('people')} /></label>
119
+ <label class="pc-bk-field"><span>{w.message} <em>{w.optional}</em></span><textarea name="message" rows="3" maxlength="2000">{v('message')}</textarea></label>
120
+ </div>
121
+
122
+ <div class="pc-rq-change">
123
+ <label class="pc-bk-field"><span>{w.event}</span>
124
+ <select name="event" aria-invalid={invalid('event')}>
125
+ <option value="">{w.pickEvent}</option>
126
+ {options.events.map((e) => <option value={e.slug} selected={v('event') === e.slug}>{e.title}</option>)}
127
+ </select>
128
+ </label>
129
+ <label class="pc-bk-field"><span>{w.onDate} <em>{w.optional}</em></span><input type="date" name="onDate" min={today} value={v('onDate')} aria-invalid={invalid('date')} /><small>{w.onDateHint}</small></label>
130
+ <label class="pc-bk-field"><span>{w.whatChange}</span><textarea name="whatChange" rows="3" maxlength="2000" aria-invalid={invalid('what')}>{v('whatChange')}</textarea></label>
131
+ </div>
132
+
133
+ <fieldset class="pc-rq-contact">
134
+ <legend>{w.contact}</legend>
135
+ <label class="pc-bk-field"><span>{w.name}</span><input name="name" required maxlength="120" autocomplete="name" value={v('name')} aria-invalid={invalid('name')} /></label>
136
+ <div class="pc-form-row">
137
+ <label class="pc-bk-field"><span>{w.email}</span><input type="email" name="email" required maxlength="254" autocomplete="email" value={v('email')} aria-invalid={invalid('email')} /></label>
138
+ <label class="pc-bk-field"><span>{w.phone} <em>{w.optional}</em></span><input type="tel" name="phone" maxlength="40" autocomplete="tel" value={v('phone')} aria-invalid={invalid('phone')} /></label>
139
+ </div>
140
+ <p class="pc-bk-hint">{w.contactWhy}{privacy && <> <a href={privacy}>{w.privacy}</a></>}</p>
141
+ </fieldset>
142
+ <button class="pc-bk-submit" type="submit">{w.send}</button>
143
+ </form>
144
+ )}
145
+ </section>
146
+ </Site>
147
+
148
+ <script>
149
+ // "Sanctuary looks free then": free or busy for the chosen rooms
150
+ // (GET /api/v1/rooms/free, no names), in the site's zone
151
+ const form = document.querySelector<HTMLFormElement>('.pc-rq-form');
152
+ const out = form?.querySelector<HTMLElement>('.pc-rq-free');
153
+ if (form && out) {
154
+ const tz = form.dataset.tz || 'UTC';
155
+ const words = JSON.parse(form.dataset.words || '{}') as { free: string; taken: string };
156
+ const wall = (iso: string) => {
157
+ const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
158
+ .formatToParts(new Date(iso)).map((x) => [x.type, x.value]));
159
+ return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
160
+ };
161
+ const next = (day: string) => { const d = new Date(day + 'T12:00:00Z'); d.setUTCDate(d.getUTCDate() + 1); return d.toISOString().slice(0, 10); };
162
+ let run = 0;
163
+ const check = async () => {
164
+ const mine = ++run;
165
+ const get = (n: string) => (form.elements.namedItem(n) as HTMLInputElement | null)?.value || '';
166
+ const day = get('date'), start = get('start'), end = get('end');
167
+ const rooms = [...form.querySelectorAll<HTMLInputElement>('input[name=rooms]:checked')];
168
+ if (!day || !start || !end || end <= start || !rooms.length) { out.textContent = ''; return; }
169
+ const from = day + 'T' + start, to = day + 'T' + end;
170
+ const lines = await Promise.all(rooms.map(async (r) => {
171
+ const res = await fetch('/api/v1/rooms/free?room=' + encodeURIComponent(r.value) + '&start=' + day + '&end=' + next(day)).catch(() => null);
172
+ if (!res?.ok) return '';
173
+ const { data } = await res.json() as { data: { busy: { start: string; end: string }[] } };
174
+ const taken = data.busy.some((b) => wall(b.start) < to && wall(b.end) > from);
175
+ return (taken ? words.taken : words.free).replace('{room}', r.dataset.name || r.value);
176
+ }));
177
+ if (mine === run) out.textContent = lines.filter(Boolean).join(' ');
178
+ };
179
+ form.addEventListener('change', (e) => { if ((e.target as HTMLInputElement).name !== 'roomsUnsure') void check(); });
180
+ void check();
181
+ }
182
+ </script>
@@ -38,6 +38,8 @@ export type { CalEvent, Occurrence };
38
38
  export type { EventRequestInput } from '../lib/calendar/requests';
39
39
  export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
40
40
  export type { MoveInput, MoveResult } from '../lib/calendar/moves';
41
+ export type { RequestRecord, RequestKind, RequestState, RequestSource, Requester } from '../lib/schema';
42
+ export type { RequestInput, RequestRefusal, NewEventDetails, Answered, RequestClash } from '../lib/calendar/request-service';
41
43
 
42
44
  export interface ListEventsOptions {
43
45
  /** only published events (default: every event except archived ones) */
@@ -360,7 +362,6 @@ export async function setHostedOccurrence(repo: Repo, who: Who, eventId: string,
360
362
 
361
363
  const hostRequests = () => import('../lib/calendar/host-requests');
362
364
  export type { HostMoveResult, MoveRequestData } from '../lib/calendar/host-requests';
363
- export type { RequestRecord } from '../lib/schema';
364
365
 
365
366
  /** A host moves one date: `{ moved }` at once, or `{ request }` when moves need approval. Errors as moveOccurrence's, plus `code` room-not-allowed (a room the event doesn't use and nobody may request). */
366
367
  export async function moveHostedDate(repo: Repo, who: Who, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext): Promise<import('../lib/calendar/host-requests').HostMoveResult> {
@@ -402,3 +403,57 @@ export async function approveDateRequest(repo: Repo, who: Who, requestId: string
402
403
  export async function declineDateRequest(repo: Repo, who: Who, requestId: string, reply: string, context: WriteContext, seen?: number): Promise<import('../lib/schema').RequestRecord> {
403
404
  return (await hostRequests()).declineDateRequest(internal(repo), who, requestId, reply, context, seen);
404
405
  }
406
+
407
+ // ---- event requests without an account ---------------------------------------
408
+ // The rules behind the built-in /request form, POST /api/v1/requests and
409
+ // Calendar → Requests (acms({ requests })), for a site that builds its own
410
+ // form or list. Asking needs no account; reading the list needs "Create and
411
+ // edit content", answering "Publish content". Refusals carry a `status` and a
412
+ // `code` (RequestRefusal: which field, closed, busy…) with an English message;
413
+ // the built-in form shows each code in the visitor's language.
414
+
415
+ const asks = () => import('../lib/calendar/request-service');
416
+
417
+ /** What the request form offers: open or closed, the types and requestable rooms, and the site's words in this language. */
418
+ export async function requestOptions(repo: Repo, locale = '') {
419
+ return (await asks()).requestOptions(internal(repo), locale);
420
+ }
421
+
422
+ /**
423
+ * Send a request from the site's own form. `key` is a random UUID v4 the page
424
+ * kept for this form (a retry with it never makes a second request); `address`
425
+ * is the client address (Astro.clientAddress) for the rate limit. The person
426
+ * is emailed that it arrived, and staff get an alert.
427
+ */
428
+ export async function submitRequest(repo: Repo, input: import('../lib/calendar/request-service').RequestInput, opts: { key: string; address: string; context: WriteContext }): Promise<{ id: string; created: boolean }> {
429
+ const { id, created } = await (await asks()).submitRequest(internal(repo), input, { source: 'web', key: opts.key, address: opts.address, locals: opts.context.locals });
430
+ return { id, created };
431
+ }
432
+
433
+ /** Requests for staff ("Create and edit content"): filter by state, kind, event or member; pending ones oldest first, the rest newest first. */
434
+ export async function listRequests(repo: Repo, who: Who, q: import('../lib/calendar/request-store').RequestQuery = {}): Promise<import('../lib/schema').RequestRecord[]> {
435
+ if (!who) throw Object.assign(new Error('Sign in to continue.'), { status: 401 });
436
+ if (!who.can('content.edit')) throw Object.assign(new Error('Your role doesn’t include “Create and edit content”.'), { status: 403 });
437
+ const pending = q.states?.length === 1 && q.states[0] === 'pending';
438
+ return internal(repo).requests.list({ order: pending ? 'oldest' : 'newest', ...q });
439
+ }
440
+
441
+ /** Staff approve: a new event becomes a filled-in draft; a change points at its event. `next` is where to go in the admin. */
442
+ export async function approveRequest(repo: Repo, who: Who, id: string, context: WriteContext, seen?: number, opts: { message?: string; email?: boolean } = {}) {
443
+ return (await asks()).approveRequest(internal(repo), who, id, context, seen, opts);
444
+ }
445
+
446
+ /** Staff decline, with an optional reason emailed to the person in their language (`email: false` sends nothing). */
447
+ export async function declineRequest(repo: Repo, who: Who, id: string, reason: string, context: WriteContext, seen?: number, opts: { email?: boolean } = {}) {
448
+ return (await asks()).declineRequest(internal(repo), who, id, reason, context, seen, opts);
449
+ }
450
+
451
+ /** Staff delete a request from the app or the website as spam, without emailing anyone. */
452
+ export async function deleteRequest(repo: Repo, who: Who, id: string): Promise<void> {
453
+ return (await asks()).deleteRequest(internal(repo), who, id);
454
+ }
455
+
456
+ /** Staff email the person about their request (it stays as it is). */
457
+ export async function replyToRequest(repo: Repo, who: Who, id: string, message: string, context: WriteContext) {
458
+ return (await asks()).replyToRequest(internal(repo), who, id, message, context);
459
+ }
@@ -1226,6 +1226,35 @@ li::marker { color: var(--muted); }
1226
1226
  .pc-simple-form form { display: grid; gap: 16px; }
1227
1227
  .pc-simple-form .pc-bk-field { margin: 0; }
1228
1228
  .pc-form-trap { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
1229
+ /* Request an event (pages/request/index.astro). The kind toggle shows one set
1230
+ of fields; without :has() both show and the server reads the chosen kind's. */
1231
+ .pc-rq .pc-rq-notice { color: var(--muted); margin: 0; }
1232
+ .pc-rq-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1233
+ .pc-rq-kind { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; border: 1px solid var(--line-2, var(--line)); border-radius: 12px; padding: 4px; margin: 0; background: var(--soft); }
1234
+ .pc-rq-kind label { display: block; position: relative; }
1235
+ .pc-rq-kind input { position: absolute; opacity: 0; pointer-events: none; }
1236
+ .pc-rq-kind span { display: block; text-align: center; padding: 8px 10px; border-radius: 9px; font-size: 14.5px; font-weight: 550; cursor: pointer; color: var(--muted); }
1237
+ .pc-rq-kind input:checked + span { background: var(--panel); color: var(--ink); box-shadow: var(--sh-1); }
1238
+ .pc-rq-kind input:focus-visible + span { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
1239
+ .pc-rq-new, .pc-rq-change { display: grid; gap: 16px; }
1240
+ .pc-rq-form:has(input[name=kind][value=change]:checked) .pc-rq-new,
1241
+ .pc-rq-form:has(input[name=kind][value=new]:checked) .pc-rq-change { display: none; }
1242
+ .pc-rq-when { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 12px; }
1243
+ .pc-rq-when .pc-bk-field { margin: 0; }
1244
+ @media (max-width: 520px) { .pc-rq-when { grid-template-columns: 1fr 1fr; } .pc-rq-when > :first-child { grid-column: 1 / -1; } }
1245
+ .pc-rq-chips { display: flex; flex-wrap: wrap; gap: 6px; }
1246
+ .pc-rq-chips .pc-bk-option { min-height: 38px; padding: 6px 12px; }
1247
+ .pc-rq .pc-rq-other { margin: 8px 0 0; }
1248
+ .pc-rq-form:not(:has(input[name=repeat][value=other]:checked)) .pc-rq-other { display: none; }
1249
+ .pc-rq-free { margin: 8px 0 0; font-size: 14px; color: var(--muted); }
1250
+ .pc-rq-free:empty { display: none; }
1251
+ .pc-rq-contact { border: 0; padding: 0; margin: 0; display: grid; gap: 12px; min-width: 0; }
1252
+ .pc-rq-contact legend { font-weight: 650; font-size: 17px; padding: 0; margin-bottom: 8px; }
1253
+ .pc-rq-contact .pc-bk-hint { margin: 0; }
1254
+ .pc-rq-card { display: grid; gap: 2px; border: 1px solid var(--line); border-radius: var(--r); background: var(--panel); padding: 14px 16px; margin: 16px 0; }
1255
+ .pc-rq-card small { color: var(--muted); font-size: 12.5px; }
1256
+ .pc-rq-card span { color: var(--muted); font-size: 14.5px; }
1257
+ .pc-rq [aria-invalid=true] { border-color: var(--danger); }
1229
1258
  .pc-bk-check { display: flex; gap: 8px; align-items: start; margin-block: 12px; }
1230
1259
  .pc-bk-check input { width: auto; margin-top: 5px; }
1231
1260
  .pc-bk-check small, .pc-bk-field small { display: block; font-size: .85em; opacity: .8; }