@acms-subir/core 0.3.7 → 0.3.8
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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/components/EmailSettings.tsx +6 -0
- package/src/components/booking/DurationEditor.astro +156 -0
- package/src/lib/booking/forms.ts +67 -0
- package/src/lib/email/readiness.ts +14 -0
- package/src/lib/email/service.ts +8 -3
- package/src/lib/email/templates.ts +8 -2
- package/src/lib/email/types.ts +1 -0
- package/src/pages/admin/calendar/bookings.astro +11 -5
- package/src/pages/admin/calendar/resources/link.astro +95 -49
- package/src/pages/admin/settings.astro +1 -1
- package/src/pages/api/email/settings.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Read saved email-provider settings on the bookings screen so configured Mailgun and SES connections are recognized. Show actionable Site URL setup warnings separately from provider readiness, report specific booking email preparation errors, and clarify that a successful connection test does not validate email links.
|
|
8
|
+
|
|
9
|
+
Allow HTTP email links for explicitly configured localhost and loopback development URLs, preserving the port. Public sites still require HTTPS.
|
|
10
|
+
|
|
11
|
+
Offer custom booking lengths with hours and minutes, either as one fixed duration or an editable list of choices. Validate lengths before saving and preserve existing duration settings.
|
|
12
|
+
|
|
3
13
|
## 0.3.7
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -278,6 +278,12 @@ export default function EmailSettings() {
|
|
|
278
278
|
|
|
279
279
|
return (
|
|
280
280
|
<div className="ems">
|
|
281
|
+
{data.linkError && !savedOff && (
|
|
282
|
+
<p className="notice warn" role="status">
|
|
283
|
+
{data.linkError} A connection test can succeed before this is set.{' '}
|
|
284
|
+
<a href="/admin/settings#site">Open site settings</a>
|
|
285
|
+
</p>
|
|
286
|
+
)}
|
|
281
287
|
<div className="seg ems-tabs" role="tablist" aria-label="Email settings">
|
|
282
288
|
{TABS.map((t, i) => (
|
|
283
289
|
<button key={t.id} ref={(el) => { tabRefs.current[i] = el; }} type="button" role="tab" id={id(`tab-${t.id}`)}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Booking lengths as Hours + Minutes. "One length" is a single pair; "Several
|
|
3
|
+
// lengths" is a list of pairs people pick from (add/remove rows, plus a few
|
|
4
|
+
// common presets). Each block carries data-mode so the page can show/disable
|
|
5
|
+
// it with the mode switch. Rows are raw text so a failed save shows what was typed.
|
|
6
|
+
import type { DurationRow } from '../../lib/booking/forms';
|
|
7
|
+
import { describeLength } from '../../lib/booking/availability';
|
|
8
|
+
interface Props { single: DurationRow; singleError?: string; several: DurationRow[]; severalError?: string }
|
|
9
|
+
const { single, singleError = '', several, severalError = '' } = Astro.props;
|
|
10
|
+
const PRESETS = [30, 60, 90, 120, 150, 180, 240];
|
|
11
|
+
const rows = several.length ? several : [{ h: '1', m: '0' }];
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<div class="dur" data-mode="slots" data-dur-single>
|
|
15
|
+
<fieldset aria-describedby={singleError ? 'dur-one-err' : undefined}>
|
|
16
|
+
<legend>Length</legend>
|
|
17
|
+
<div class="dur-row">
|
|
18
|
+
<label class="dur-part"><input type="number" name="durH" min="0" max="24" step="1" inputmode="numeric" value={single.h} aria-invalid={singleError ? 'true' : undefined} /> <span>hours</span></label>
|
|
19
|
+
<label class="dur-part"><input type="number" name="durM" min="0" max="59" step="1" inputmode="numeric" value={single.m} aria-invalid={singleError ? 'true' : undefined} /> <span>min</span></label>
|
|
20
|
+
</div>
|
|
21
|
+
{singleError && <p class="dur-err" id="dur-one-err" role="alert">{singleError}</p>}
|
|
22
|
+
<div class="dur-presets" role="group" aria-label="Common lengths">
|
|
23
|
+
{PRESETS.map((n) => <button type="button" class="btn sm" data-preset={n}>{describeLength(n)}</button>)}
|
|
24
|
+
</div>
|
|
25
|
+
</fieldset>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="dur" data-mode="range" data-dur-several>
|
|
29
|
+
<fieldset aria-describedby={`dur-many-hint${severalError ? ' dur-many-err' : ''}`}>
|
|
30
|
+
<legend>Lengths people can pick</legend>
|
|
31
|
+
<ol class="dur-list" data-dur-list>
|
|
32
|
+
{rows.map((r, i) => (
|
|
33
|
+
<li class="dur-item">
|
|
34
|
+
<div class="dur-row">
|
|
35
|
+
<label class="dur-part"><input type="number" name="lenH" min="0" max="24" step="1" inputmode="numeric" value={r.h} aria-label={`Length ${i + 1}, hours`} aria-invalid={r.error ? 'true' : undefined} aria-describedby={r.error ? `dur-row-err-${i}` : undefined} /> <span aria-hidden="true">hours</span></label>
|
|
36
|
+
<label class="dur-part"><input type="number" name="lenM" min="0" max="59" step="1" inputmode="numeric" value={r.m} aria-label={`Length ${i + 1}, minutes`} aria-invalid={r.error ? 'true' : undefined} aria-describedby={r.error ? `dur-row-err-${i}` : undefined} /> <span aria-hidden="true">min</span></label>
|
|
37
|
+
<button type="button" class="icon-btn" data-dur-remove aria-label={`Remove length ${i + 1}`} title="Remove" disabled={rows.length === 1}>×</button>
|
|
38
|
+
</div>
|
|
39
|
+
{r.error && <p class="dur-err" id={`dur-row-err-${i}`}>{r.error}</p>}
|
|
40
|
+
</li>
|
|
41
|
+
))}
|
|
42
|
+
</ol>
|
|
43
|
+
<template data-dur-row>
|
|
44
|
+
<li class="dur-item">
|
|
45
|
+
<div class="dur-row">
|
|
46
|
+
<label class="dur-part"><input type="number" name="lenH" min="0" max="24" step="1" inputmode="numeric" /> <span aria-hidden="true">hours</span></label>
|
|
47
|
+
<label class="dur-part"><input type="number" name="lenM" min="0" max="59" step="1" inputmode="numeric" /> <span aria-hidden="true">min</span></label>
|
|
48
|
+
<button type="button" class="icon-btn" data-dur-remove title="Remove">×</button>
|
|
49
|
+
</div>
|
|
50
|
+
</li>
|
|
51
|
+
</template>
|
|
52
|
+
{severalError && <p class="dur-err" id="dur-many-err" role="alert">{severalError}</p>}
|
|
53
|
+
<div class="dur-presets" role="group" aria-label="Add a common length">
|
|
54
|
+
<button type="button" class="btn sm" data-dur-add>+ Add a length</button>
|
|
55
|
+
{PRESETS.map((n) => <button type="button" class="btn sm ghost" data-preset={n}>+ {describeLength(n)}</button>)}
|
|
56
|
+
</div>
|
|
57
|
+
<p class="hint" id="dur-many-hint">People pick one of these when they book. Duplicates are merged and the list is sorted when you save.</p>
|
|
58
|
+
</fieldset>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<style>
|
|
62
|
+
.dur { margin-bottom: 14px; }
|
|
63
|
+
.dur fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
64
|
+
.dur legend { padding: 0; font-size: 12px; color: var(--muted); margin-bottom: 5px; font-weight: 500; }
|
|
65
|
+
.dur-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
66
|
+
.dur-part { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
|
|
67
|
+
.dur-part input { width: 84px; }
|
|
68
|
+
.dur [aria-invalid='true'] { border-color: var(--red); }
|
|
69
|
+
.dur-err { color: var(--red); font-size: 12.5px; margin: 5px 0 0; }
|
|
70
|
+
.dur-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
|
71
|
+
.dur-item .icon-btn:disabled { opacity: 0.35; cursor: default; }
|
|
72
|
+
.dur-presets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
73
|
+
.dur .hint { margin: 8px 0 0; }
|
|
74
|
+
@media (max-width: 480px) {
|
|
75
|
+
.dur-part input { width: 72px; }
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
78
|
+
|
|
79
|
+
<script>
|
|
80
|
+
const minutesOf = (h: string, m: string) => (Number(h) || 0) * 60 + (Number(m) || 0);
|
|
81
|
+
const clearError = (scope: Element) => {
|
|
82
|
+
scope.querySelectorAll('[aria-invalid]').forEach((i) => i.removeAttribute('aria-invalid'));
|
|
83
|
+
scope.querySelectorAll('.dur-err').forEach((p) => p.remove());
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
document.querySelectorAll<HTMLElement>('[data-dur-single]').forEach((root) => {
|
|
87
|
+
const [h, m] = root.querySelectorAll<HTMLInputElement>('input[type=number]');
|
|
88
|
+
root.addEventListener('input', () => clearError(root));
|
|
89
|
+
root.addEventListener('click', (e) => {
|
|
90
|
+
const b = (e.target as Element).closest<HTMLElement>('[data-preset]');
|
|
91
|
+
if (!b) return;
|
|
92
|
+
const n = Number(b.dataset.preset);
|
|
93
|
+
h.value = String(Math.floor(n / 60));
|
|
94
|
+
m.value = String(n % 60);
|
|
95
|
+
clearError(root);
|
|
96
|
+
h.dispatchEvent(new Event('change', { bubbles: true }));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
document.querySelectorAll<HTMLElement>('[data-dur-several]').forEach((root) => {
|
|
101
|
+
const list = root.querySelector<HTMLOListElement>('[data-dur-list]')!;
|
|
102
|
+
const tpl = root.querySelector<HTMLTemplateElement>('template[data-dur-row]')!;
|
|
103
|
+
const addBtn = root.querySelector<HTMLButtonElement>('[data-dur-add]')!;
|
|
104
|
+
const items = () => [...list.querySelectorAll<HTMLLIElement>(':scope > li')];
|
|
105
|
+
const parts = (li: Element) => li.querySelectorAll<HTMLInputElement>('input[type=number]');
|
|
106
|
+
const renumber = () => {
|
|
107
|
+
const all = items();
|
|
108
|
+
all.forEach((li, i) => {
|
|
109
|
+
const [h, m] = parts(li);
|
|
110
|
+
h.setAttribute('aria-label', `Length ${i + 1}, hours`);
|
|
111
|
+
m.setAttribute('aria-label', `Length ${i + 1}, minutes`);
|
|
112
|
+
const x = li.querySelector<HTMLButtonElement>('[data-dur-remove]')!;
|
|
113
|
+
x.setAttribute('aria-label', `Remove length ${i + 1}`);
|
|
114
|
+
x.disabled = all.length === 1;
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
const add = (n: number) => {
|
|
118
|
+
const li = tpl.content.firstElementChild!.cloneNode(true) as HTMLLIElement;
|
|
119
|
+
const [h, m] = parts(li);
|
|
120
|
+
h.value = String(Math.floor(n / 60));
|
|
121
|
+
m.value = String(n % 60);
|
|
122
|
+
list.append(li);
|
|
123
|
+
renumber();
|
|
124
|
+
return li;
|
|
125
|
+
};
|
|
126
|
+
list.addEventListener('input', (e) => { const li = (e.target as Element).closest('li'); if (li) clearError(li); });
|
|
127
|
+
root.addEventListener('click', (e) => {
|
|
128
|
+
const t = e.target as Element;
|
|
129
|
+
if (t.closest('[data-dur-add]')) {
|
|
130
|
+
const last = items().at(-1);
|
|
131
|
+
const prev = last ? minutesOf(parts(last)[0].value, parts(last)[1].value) : 0;
|
|
132
|
+
// a new row starts an hour past the last one (or at 1 h)
|
|
133
|
+
add(prev ? Math.min(1440, prev + 60) : 60).querySelector('input')!.focus();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const preset = t.closest<HTMLElement>('[data-preset]');
|
|
137
|
+
if (preset) {
|
|
138
|
+
const n = Number(preset.dataset.preset);
|
|
139
|
+
const hit = items().find((li) => minutesOf(parts(li)[0].value, parts(li)[1].value) === n);
|
|
140
|
+
(hit ?? add(n)).querySelector('input')!.focus();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const rm = t.closest<HTMLButtonElement>('[data-dur-remove]');
|
|
144
|
+
if (rm && !rm.disabled) {
|
|
145
|
+
const all = items();
|
|
146
|
+
const i = all.indexOf(rm.closest('li')!);
|
|
147
|
+
all[i].remove();
|
|
148
|
+
renumber();
|
|
149
|
+
const next = items()[Math.min(i, items().length - 1)];
|
|
150
|
+
(next?.querySelector<HTMLButtonElement>('[data-dur-remove]:not(:disabled)') ?? addBtn).focus();
|
|
151
|
+
const msg = root.querySelector('#dur-many-err');
|
|
152
|
+
if (msg && !list.querySelector('[aria-invalid]')) msg.remove();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
</script>
|
package/src/lib/booking/forms.ts
CHANGED
|
@@ -71,6 +71,73 @@ export const int = (v: FormDataEntryValue | null, min: number, max: number, fall
|
|
|
71
71
|
const n = Math.round(Number(v));
|
|
72
72
|
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
73
73
|
};
|
|
74
|
+
|
|
75
|
+
// Booking lengths as Hours + Minutes: `durH`/`durM` for a fixed length, and
|
|
76
|
+
// repeated `lenH`/`lenM` rows for the lengths people choose from. The older
|
|
77
|
+
// `duration` (minutes) and `lengths` (comma-separated decimal hours) fields
|
|
78
|
+
// are still read when the new ones are absent. Rows keep the raw text so a
|
|
79
|
+
// failed save can put back exactly what was typed.
|
|
80
|
+
export const LENGTH_MIN = 5;
|
|
81
|
+
export const LENGTH_MAX = 1440;
|
|
82
|
+
export type DurationRow = { h: string; m: string; error?: string };
|
|
83
|
+
export const durationRow = (minutes: number): DurationRow => ({ h: String(Math.floor(minutes / 60)), m: String(minutes % 60) });
|
|
84
|
+
|
|
85
|
+
const bounded = (minutes: number): string | undefined =>
|
|
86
|
+
minutes < LENGTH_MIN ? `Make it at least ${LENGTH_MIN} minutes.` : minutes > LENGTH_MAX ? 'Keep it to 24 hours or less.' : undefined;
|
|
87
|
+
|
|
88
|
+
export function partsToMinutes(h: string, m: string): { minutes: number } | { error: string } {
|
|
89
|
+
const hs = h.trim(), ms = m.trim();
|
|
90
|
+
if (!hs && !ms) return { error: 'Enter a length.' };
|
|
91
|
+
if (!/^\d{0,4}$/.test(hs) || !/^\d{0,4}$/.test(ms)) return { error: 'Use whole numbers for hours and minutes.' };
|
|
92
|
+
const minutes = Number(hs || 0) * 60 + Number(ms || 0);
|
|
93
|
+
const error = bounded(minutes);
|
|
94
|
+
return error ? { error } : { minutes };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const str = (v: FormDataEntryValue | null | undefined) => (typeof v === 'string' ? v : '');
|
|
98
|
+
|
|
99
|
+
export function durationFromForm(form: FormData): { minutes: number | null; row: DurationRow; error?: string } {
|
|
100
|
+
if (!form.has('durH') && !form.has('durM') && form.has('duration')) {
|
|
101
|
+
const raw = str(form.get('duration')).trim();
|
|
102
|
+
if (!/^\d{1,5}$/.test(raw)) return { minutes: null, row: { h: '', m: raw }, error: raw ? 'Length must be whole minutes.' : 'Enter a length.' };
|
|
103
|
+
const error = bounded(Number(raw));
|
|
104
|
+
return error ? { minutes: null, row: { h: '', m: raw }, error } : { minutes: Number(raw), row: durationRow(Number(raw)) };
|
|
105
|
+
}
|
|
106
|
+
const row = { h: str(form.get('durH')), m: str(form.get('durM')) };
|
|
107
|
+
const r = partsToMinutes(row.h, row.m);
|
|
108
|
+
return 'error' in r ? { minutes: null, row, error: r.error } : { minutes: r.minutes, row };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function lengthsFromForm(form: FormData): { minutes: number[]; rows: DurationRow[]; error?: string } {
|
|
112
|
+
let rows: DurationRow[];
|
|
113
|
+
const found: number[] = [];
|
|
114
|
+
if (!form.has('lenH') && !form.has('lenM') && form.has('lengths')) {
|
|
115
|
+
rows = list(str(form.get('lengths'))).map((x) => {
|
|
116
|
+
const n = Number(x) * 60;
|
|
117
|
+
if (!/^\d*\.?\d+$/.test(x) || Math.abs(n - Math.round(n)) > 1e-6) return { h: x, m: '', error: `“${x}” isn’t a length in hours.` };
|
|
118
|
+
const error = bounded(Math.round(n));
|
|
119
|
+
if (error) return { h: x, m: '', error };
|
|
120
|
+
found.push(Math.round(n));
|
|
121
|
+
return durationRow(Math.round(n));
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
const hs = form.getAll('lenH').map(str), ms = form.getAll('lenM').map(str);
|
|
125
|
+
rows = Array.from({ length: Math.max(hs.length, ms.length) }, (_, i) => {
|
|
126
|
+
const row: DurationRow = { h: hs[i] ?? '', m: ms[i] ?? '' };
|
|
127
|
+
const r = partsToMinutes(row.h, row.m);
|
|
128
|
+
if ('error' in r) return { ...row, error: r.error };
|
|
129
|
+
found.push(r.minutes);
|
|
130
|
+
return row;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const minutes = [...new Set(found)].sort((a, b) => a - b);
|
|
134
|
+
const bad = rows.filter((r) => r.error).length;
|
|
135
|
+
const error = !rows.length ? 'Add at least one length.'
|
|
136
|
+
: bad ? (bad === 1 ? 'One length needs fixing.' : `${bad} lengths need fixing.`)
|
|
137
|
+
: undefined;
|
|
138
|
+
return error ? { minutes: [], rows, error } : { minutes, rows };
|
|
139
|
+
}
|
|
140
|
+
|
|
74
141
|
export const DEFAULT_HOURS: WeeklyHours = {
|
|
75
142
|
su: [], mo: [{ start: '09:00', end: '17:00' }], tu: [{ start: '09:00', end: '17:00' }], we: [{ start: '09:00', end: '17:00' }],
|
|
76
143
|
th: [{ start: '09:00', end: '17:00' }], fr: [{ start: '09:00', end: '17:00' }], sa: [],
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { encryptionAvailable } from './config';
|
|
2
|
+
import { canonicalOrigin } from './templates';
|
|
3
|
+
import type { ResolvedConfig } from './types';
|
|
4
|
+
|
|
5
|
+
// Transport readiness and readiness for messages containing links are separate:
|
|
6
|
+
// a successful connection test does not validate the canonical site address.
|
|
7
|
+
export function emailReadiness(resolved: ResolvedConfig, siteUrl: string, locals?: unknown) {
|
|
8
|
+
const encrypted = encryptionAvailable(locals);
|
|
9
|
+
const error = resolved.error || (!encrypted ? 'Configure EMAIL_ENCRYPTION_KEY for protected email delivery.' : '');
|
|
10
|
+
const configured = !error && resolved.provider !== 'none' && resolved.config.enabled;
|
|
11
|
+
let linkError = '';
|
|
12
|
+
try { canonicalOrigin(siteUrl); } catch (e) { linkError = (e as Error).message; }
|
|
13
|
+
return { configured, error, encryptionAvailable: encrypted, linksReady: configured && !linkError, linkError };
|
|
14
|
+
}
|
package/src/lib/email/service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContentRepository } from '../repo/types';
|
|
2
2
|
import { resolveConfig, decrypt, encrypt, runtimeValue } from './config';
|
|
3
3
|
import { sendWithProvider } from './providers';
|
|
4
|
-
import { accountMail, canonicalOrigin } from './templates';
|
|
4
|
+
import { accountMail, canonicalOrigin, EmailLinkError } from './templates';
|
|
5
5
|
import { bookingMail } from '../booking/service';
|
|
6
6
|
import type { EmailJob, Mail } from './types';
|
|
7
7
|
|
|
@@ -33,6 +33,7 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
33
33
|
for (let i=0;i<limit;i++) {
|
|
34
34
|
const j = await repo.email.claim(); if (!j) break;
|
|
35
35
|
let handedToProvider = false;
|
|
36
|
+
let preparationError = 'Unable to prepare email. Check the message details and Email settings.';
|
|
36
37
|
try {
|
|
37
38
|
let config = await repo.email.config(j.configVersion || undefined);
|
|
38
39
|
const resolved = resolveConfig(config, locals);
|
|
@@ -40,6 +41,9 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
40
41
|
if (!current.config.enabled || current.config.provider === 'none') {
|
|
41
42
|
await repo.email.finish(j,{state:'cancelled',error:'Email is disabled.'}); continue;
|
|
42
43
|
}
|
|
44
|
+
if (resolved.error) {
|
|
45
|
+
await repo.email.finish(j,{state:'failed',error:resolved.error}); continue;
|
|
46
|
+
}
|
|
43
47
|
if (j.fingerprint && j.fingerprint !== resolved.fingerprint) {
|
|
44
48
|
await repo.email.finish(j,{state:'failed',error:'Provider configuration changed. Restore the original environment credentials before retrying this job.'}); continue;
|
|
45
49
|
}
|
|
@@ -56,6 +60,7 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
56
60
|
// Retries must reuse exactly the same request body, including calendar
|
|
57
61
|
// timestamps, for Resend's idempotency key to remain valid. Encrypt the
|
|
58
62
|
// snapshot because account and booking links are bearer credentials.
|
|
63
|
+
preparationError = 'Unable to protect email content. Check EMAIL_ENCRYPTION_KEY and retain any older keys needed for retries.';
|
|
59
64
|
const frozen = j.rendered ? decrypt<Mail>(j.rendered,`job:${j.id}`,locals) : mail;
|
|
60
65
|
if (!await repo.email.prepare(j,j.rendered || encrypt(frozen,`job:${j.id}`,locals))) continue;
|
|
61
66
|
handedToProvider = true;
|
|
@@ -64,8 +69,8 @@ export async function dispatchEmail(repo: ContentRepository, locals?: unknown, l
|
|
|
64
69
|
// Purge the recoverable raw account token once the provider accepted it.
|
|
65
70
|
// The verifier hash remains so the recipient can redeem the link.
|
|
66
71
|
if (result.state === 'accepted') processed++;
|
|
67
|
-
} catch {
|
|
68
|
-
await repo.email.finish(j,{state:handedToProvider?'unknown':'failed',error:handedToProvider?'Provider handoff could not be recorded. Review delivery before retrying.':
|
|
72
|
+
} catch (error) {
|
|
73
|
+
await repo.email.finish(j,{state:handedToProvider?'unknown':'failed',error:handedToProvider?'Provider handoff could not be recorded. Review delivery before retrying.':error instanceof EmailLinkError ? error.message : preparationError});
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
await repo.email.reconcile();
|
|
@@ -11,8 +11,14 @@ export function accountMail(kind: 'invite'|'reset'|'welcome'|'password-changed'|
|
|
|
11
11
|
return { to, subject: `${copy[0]} · ${site}`, text: [copy[0], '', copy[1], ...(url ? ['',`${copy[2]}: ${url}`] : [])].join('\n'),
|
|
12
12
|
html: `<!doctype html><html><body style="margin:0;background:#f6f5f2;color:#24211c;font:16px/1.6 system-ui,sans-serif"><div style="max-width:520px;margin:auto;padding:32px 24px"><p style="color:#7d776e">${escape(site)}</p><h1 style="font-size:24px">${escape(copy[0])}</h1><p>${escape(copy[1])}</p>${url ? `<p><a style="display:inline-block;background:#ee5d20;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none" href="${escape(url)}">${escape(copy[2])}</a></p>` : ''}</div></body></html>` };
|
|
13
13
|
}
|
|
14
|
+
export class EmailLinkError extends Error {}
|
|
14
15
|
export function canonicalOrigin(value: string): string {
|
|
15
|
-
|
|
16
|
-
if (
|
|
16
|
+
const raw = value.trim();
|
|
17
|
+
if (!raw) throw new EmailLinkError('Set Site URL in Settings → General before sending booking or account emails. Use HTTPS for public sites, or HTTP for localhost development.');
|
|
18
|
+
// Match the site settings form's support for a bare domain, while retaining
|
|
19
|
+
// the stricter origin checks needed for links containing bearer tokens.
|
|
20
|
+
let url: URL; try { url = new URL(raw.includes('://') ? raw : `https://${raw}`); } catch { throw new EmailLinkError('Set a valid Site URL in Settings → General before sending booking or account emails.'); }
|
|
21
|
+
const loopback = url.hostname === 'localhost' || url.hostname.endsWith('.localhost') || url.hostname === '[::1]' || /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(url.hostname);
|
|
22
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new EmailLinkError('Site URL in Settings → General must use HTTPS (HTTP is allowed for localhost), without a path, credentials or query.');
|
|
17
23
|
return url.origin;
|
|
18
24
|
}
|
package/src/lib/email/types.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface Invitation { id: string; email: string; name: string; role: Rol
|
|
|
25
25
|
export interface PublicEmailSettings {
|
|
26
26
|
version: number; config: EmailConfig; provider: Provider | 'none'; configured: boolean; error: string;
|
|
27
27
|
encryptionAvailable: boolean; environmentLocked: boolean; credentialFields: string[];
|
|
28
|
+
linksReady: boolean; linkError: string;
|
|
28
29
|
activity: Omit<EmailJob, 'payload' | 'lease' | 'fingerprint' | 'rendered'>[]; heartbeat: number | null;
|
|
29
30
|
}
|
|
30
31
|
export const DEFAULT_EMAIL_CONFIG: EmailConfig = {
|
|
@@ -11,7 +11,8 @@ import { CALENDAR, bookingsEnabled, eventUrl } from '../../../lib/calendar/confi
|
|
|
11
11
|
import { createBlock, createResourceBooking, rescheduleBooking, setBookingStatus, BookingError } from '../../../lib/booking/service';
|
|
12
12
|
import { addDaysYmd, localMidnight, ymd } from '../../../lib/booking/availability';
|
|
13
13
|
import { fmt } from '../../../lib/booking/view';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveConfig } from '../../../lib/email/config';
|
|
15
|
+
import { emailReadiness } from '../../../lib/email/readiness';
|
|
15
16
|
import { siteOrigin } from '../../../lib/seo';
|
|
16
17
|
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
17
18
|
|
|
@@ -54,7 +55,7 @@ if (Astro.request.method === 'POST') {
|
|
|
54
55
|
const b = await repo.getBooking(id);
|
|
55
56
|
if (!b) throw new BookingError(404, 'Booking not found.');
|
|
56
57
|
await setBookingStatus(repo, b, String(form.get('to')) as BookingStatus, flow, String(form.get('message') || '') || undefined);
|
|
57
|
-
return Astro.redirect(keep({ notice: form.get('to') === 'confirmed' ? 'Confirmed — it’s on the calendar
|
|
58
|
+
return Astro.redirect(keep({ notice: form.get('to') === 'confirmed' ? 'Confirmed — it’s on the calendar. Check Email → Activity for notification status.' : 'Updated.' }));
|
|
58
59
|
}
|
|
59
60
|
if (op === 'notes') {
|
|
60
61
|
await repo.updateBooking(id, { notes: String(form.get('notes') || '').slice(0, 4000) });
|
|
@@ -70,7 +71,7 @@ if (Astro.request.method === 'POST') {
|
|
|
70
71
|
const start = toMs(String(form.get('date')), String(form.get('time')));
|
|
71
72
|
if (Number.isNaN(start)) throw new BookingError(400, 'Pick a date and time.');
|
|
72
73
|
await rescheduleBooking(repo, b, start, start + (b.end - b.start), { ...flow, skipRules: true });
|
|
73
|
-
return Astro.redirect(keep({ notice: 'Moved
|
|
74
|
+
return Astro.redirect(keep({ notice: 'Moved. Check Email → Activity for notification status.' }));
|
|
74
75
|
}
|
|
75
76
|
if (op === 'block') {
|
|
76
77
|
const r = resById.get(String(form.get('resourceId')));
|
|
@@ -174,7 +175,12 @@ const pos = (b: Booking) => {
|
|
|
174
175
|
};
|
|
175
176
|
|
|
176
177
|
const selected = Astro.url.searchParams.get('b') ? await repo.getBooking(Astro.url.searchParams.get('b')!) : null;
|
|
177
|
-
const
|
|
178
|
+
const emailConfig = resolveConfig(await repo.email.config(), Astro.locals);
|
|
179
|
+
const emailStatus = emailReadiness(emailConfig, settings.siteUrl, Astro.locals);
|
|
180
|
+
const emailNotice = !emailConfig.config.enabled || emailConfig.config.provider === 'none'
|
|
181
|
+
? 'Emails are turned off. Enable email so guests can receive confirmations and reminders.'
|
|
182
|
+
: !emailStatus.configured ? `Email needs attention: ${emailStatus.error || 'Choose a provider in Email settings.'}`
|
|
183
|
+
: emailStatus.linkError;
|
|
178
184
|
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
179
185
|
const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
|
|
180
186
|
const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
|
|
@@ -196,7 +202,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
196
202
|
{notice && <p class="notice" role="status">{notice}</p>}
|
|
197
203
|
{eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · <a href={keep({ event: null })}>Show all bookings</a></p>}
|
|
198
204
|
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
199
|
-
{
|
|
205
|
+
{emailNotice && <p class="notice warn"><span>{emailNotice} <a href={emailStatus.configured && emailStatus.linkError ? '/admin/settings#site' : '/admin/email'}>{emailStatus.configured && emailStatus.linkError ? 'Site settings' : 'Email settings'}</a></span></p>}
|
|
200
206
|
|
|
201
207
|
{resources.length === 0 && !all.length ? (
|
|
202
208
|
<div class="empty-state">
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../../../layouts/Admin.astro';
|
|
3
3
|
import HoursEditor from '../../../../components/booking/HoursEditor.astro';
|
|
4
|
+
import DurationEditor from '../../../../components/booking/DurationEditor.astro';
|
|
4
5
|
import { getRepo } from '../../../../lib/repo';
|
|
5
|
-
import { slugify, QUESTION_TYPES, type BookingMode } from '../../../../lib/schema';
|
|
6
|
+
import { slugify, QUESTION_TYPES, type BookingLink, type BookingMode } from '../../../../lib/schema';
|
|
6
7
|
import { bookingLinkUrl, bookingsEnabled } from '../../../../lib/calendar/config';
|
|
7
|
-
import { hoursFromForm, int,
|
|
8
|
+
import { durationFromForm, durationRow, hoursFromForm, int, lengthsFromForm, questionsFromForm } from '../../../../lib/booking/forms';
|
|
8
9
|
import { describeLink } from '../../../../lib/booking/availability';
|
|
9
10
|
import { siteOrigin } from '../../../../lib/seo';
|
|
10
11
|
import { APPROVAL_LABEL, linkApproval, siteApproval } from '../../../../lib/booking/approval';
|
|
@@ -21,6 +22,13 @@ if (!resource || !link || link.resourceId !== resource.id) return new Response(n
|
|
|
21
22
|
const settings = await repo.getSettings();
|
|
22
23
|
const origin = siteOrigin(settings, Astro.url);
|
|
23
24
|
let notice = '';
|
|
25
|
+
let failed = false;
|
|
26
|
+
// what the form shows: the saved link, or the submitted values after a failed save
|
|
27
|
+
let view: BookingLink = link;
|
|
28
|
+
let single = durationRow(link.duration);
|
|
29
|
+
let singleError = '';
|
|
30
|
+
let several = (link.lengths.length ? link.lengths : [link.duration]).map(durationRow);
|
|
31
|
+
let severalError = '';
|
|
24
32
|
|
|
25
33
|
if (Astro.request.method === 'POST') {
|
|
26
34
|
const form = await Astro.request.formData();
|
|
@@ -35,7 +43,22 @@ if (Astro.request.method === 'POST') {
|
|
|
35
43
|
let slug = base;
|
|
36
44
|
for (let n = 2; ; n++) { const hit = await repo.getBookingLinkBySlug(resource.id, slug); if (!hit || hit.id === link.id) break; slug = `${base}-${n}`; }
|
|
37
45
|
const mode = (['slots', 'range', 'day'] as const).find((m) => m === form.get('mode')) ?? 'slots';
|
|
38
|
-
|
|
46
|
+
// only the active mode's length is read; the other keeps its saved value
|
|
47
|
+
// (hidden mode inputs are disabled, so they aren't submitted)
|
|
48
|
+
let duration = link.duration;
|
|
49
|
+
let lengths = link.lengths;
|
|
50
|
+
if (mode === 'slots') {
|
|
51
|
+
const d = durationFromForm(form);
|
|
52
|
+
single = d.row;
|
|
53
|
+
singleError = d.error ?? '';
|
|
54
|
+
if (d.minutes !== null) duration = d.minutes;
|
|
55
|
+
} else if (mode === 'range') {
|
|
56
|
+
const l = lengthsFromForm(form);
|
|
57
|
+
several = l.rows;
|
|
58
|
+
severalError = l.error ?? '';
|
|
59
|
+
if (!l.error) lengths = l.minutes;
|
|
60
|
+
}
|
|
61
|
+
const minDays = int(form.get('minDays'), 1, 60, link.minDays);
|
|
39
62
|
const approval = (['default', 'auto', 'rules', 'manual'] as const).find((m) => m === form.get('approval')) ?? 'default';
|
|
40
63
|
const approvalRules = ApprovalRules.parse({
|
|
41
64
|
longerThan: form.has('ruleLonger') ? int(form.get('longerThanHours'), 1, 24 * 60, 4) * 60 : 0,
|
|
@@ -44,14 +67,14 @@ if (Astro.request.method === 'POST') {
|
|
|
44
67
|
deadlineHours: form.has('deadline') ? int(form.get('deadlineHours'), 1, 24 * 60, 48) : 0,
|
|
45
68
|
deadlineAction: form.get('deadlineAction') === 'decline' ? 'decline' : 'approve',
|
|
46
69
|
});
|
|
47
|
-
|
|
70
|
+
const patch: Partial<BookingLink> = {
|
|
48
71
|
name, slug, mode: mode as BookingMode,
|
|
49
72
|
description: String(form.get('description') || '').slice(0, 1000),
|
|
50
|
-
duration
|
|
73
|
+
duration,
|
|
51
74
|
lengths,
|
|
52
|
-
minDays
|
|
53
|
-
maxDays: Math.max(
|
|
54
|
-
step: int(form.get('step'), 5, 240,
|
|
75
|
+
minDays,
|
|
76
|
+
maxDays: Math.max(minDays, int(form.get('maxDays'), 1, 60, link.maxDays)),
|
|
77
|
+
step: int(form.get('step'), 5, 240, link.step),
|
|
55
78
|
bufferBefore: int(form.get('bufferBefore'), 0, 480, 0),
|
|
56
79
|
bufferAfter: int(form.get('bufferAfter'), 0, 480, 0),
|
|
57
80
|
minNotice: int(form.get('minNoticeH'), 0, 24 * 60, 2) * 60,
|
|
@@ -67,17 +90,28 @@ if (Astro.request.method === 'POST') {
|
|
|
67
90
|
confirmMessage: String(form.get('confirmMessage') || '').slice(0, 1000),
|
|
68
91
|
redirectUrl: /^https?:\/\//.test(String(form.get('redirectUrl') || '')) ? String(form.get('redirectUrl')) : '',
|
|
69
92
|
active: Boolean(form.get('active')),
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
};
|
|
94
|
+
if (singleError || severalError) {
|
|
95
|
+
// nothing saved: show everything as submitted, with the length errors inline
|
|
96
|
+
view = { ...link, ...patch };
|
|
97
|
+
failed = true;
|
|
98
|
+
notice = 'Not saved — fix the booking length below.';
|
|
99
|
+
Astro.response.status = 422;
|
|
100
|
+
} else {
|
|
101
|
+
await repo.updateBookingLink(link.id, patch);
|
|
102
|
+
link = view = (await repo.getBookingLink(link.id))!;
|
|
103
|
+
single = durationRow(link.duration);
|
|
104
|
+
several = (link.lengths.length ? link.lengths : [link.duration]).map(durationRow);
|
|
105
|
+
notice = 'Saved.';
|
|
106
|
+
}
|
|
73
107
|
}
|
|
74
108
|
const publicUrl = origin + bookingLinkUrl(resource.slug, link.slug);
|
|
75
109
|
const embed = `<iframe src="${origin}/embed/book/${resource.slug}/${link.slug}" style="width:100%;height:620px;border:0" title="${link.name}"></iframe>`;
|
|
76
|
-
const qRows = [...
|
|
77
|
-
const hours = (
|
|
110
|
+
const qRows = [...view.questions, ...Array.from({ length: 2 }, () => ({ id: '', label: '', type: 'text' as const, required: false, options: [] as string[], help: '' }))];
|
|
111
|
+
const hours = (view.hours ?? resource.hours);
|
|
78
112
|
const site = siteApproval(settings);
|
|
79
|
-
const own =
|
|
80
|
-
const effective = linkApproval(
|
|
113
|
+
const own = view.approval === 'default' ? site.rules : view.approvalRules;
|
|
114
|
+
const effective = linkApproval(view, settings).mode;
|
|
81
115
|
---
|
|
82
116
|
|
|
83
117
|
<Admin title={link.name} section="calendar:bookings">
|
|
@@ -92,7 +126,7 @@ const effective = linkApproval(link, settings).mode;
|
|
|
92
126
|
<button class="btn primary" type="submit" form="link-form">Save</button>
|
|
93
127
|
</div>
|
|
94
128
|
</div>
|
|
95
|
-
{notice && <p class="notice" role="status">{notice}</p>}
|
|
129
|
+
{notice && (failed ? <p class="notice warn" role="alert">{notice}</p> : <p class="notice" role="status">{notice}</p>)}
|
|
96
130
|
|
|
97
131
|
<form method="post" id="link-form" class="res-grid">
|
|
98
132
|
<input type="hidden" name="op" value="save" />
|
|
@@ -100,22 +134,22 @@ const effective = linkApproval(link, settings).mode;
|
|
|
100
134
|
<section class="card card-pad appr" data-approval>
|
|
101
135
|
<div class="card-head"><b>Approval</b><span class="hint">What happens when someone books this link</span></div>
|
|
102
136
|
<div class="appr-choices" role="radiogroup" aria-label="Approval">
|
|
103
|
-
<label class="appr-choice"><input type="radio" name="approval" value="default" checked={
|
|
137
|
+
<label class="appr-choice"><input type="radio" name="approval" value="default" checked={view.approval ==='default'} />
|
|
104
138
|
<span><b>Use the site setting</b><small>Currently: {APPROVAL_LABEL[site.mode]}. <a href="/admin/calendar/settings#bookings">Change it</a></small></span></label>
|
|
105
|
-
<label class="appr-choice"><input type="radio" name="approval" value="auto" checked={
|
|
139
|
+
<label class="appr-choice"><input type="radio" name="approval" value="auto" checked={view.approval ==='auto'} />
|
|
106
140
|
<span><b>{APPROVAL_LABEL.auto}</b><small>Booked means confirmed. It shows on your Schedule right away.</small></span></label>
|
|
107
|
-
<label class="appr-choice"><input type="radio" name="approval" value="rules" checked={
|
|
141
|
+
<label class="appr-choice"><input type="radio" name="approval" value="rules" checked={view.approval ==='rules'} />
|
|
108
142
|
<span><b>{APPROVAL_LABEL.rules}</b><small>Confirm most bookings at once; hold unusual ones for you.</small></span></label>
|
|
109
|
-
<label class="appr-choice"><input type="radio" name="approval" value="manual" checked={
|
|
143
|
+
<label class="appr-choice"><input type="radio" name="approval" value="manual" checked={view.approval ==='manual'} />
|
|
110
144
|
<span><b>{APPROVAL_LABEL.manual}</b><small>Every booking waits under Needs approval.</small></span></label>
|
|
111
145
|
</div>
|
|
112
|
-
<fieldset class="appr-rules" data-rules hidden={
|
|
146
|
+
<fieldset class="appr-rules" data-rules hidden={view.approval !== 'rules'}>
|
|
113
147
|
<legend>Ask me first when a booking is…</legend>
|
|
114
148
|
<label class="appr-check"><input type="checkbox" name="ruleLonger" checked={own.longerThan > 0} /> longer than <input type="number" name="longerThanHours" min="1" max="1440" value={own.longerThan ? Math.round(own.longerThan / 60) : 4} /> hours</label>
|
|
115
149
|
<label class="appr-check"><input type="checkbox" name="ruleFirst" checked={own.firstTime} /> the first booking from that email address</label>
|
|
116
150
|
<label class="appr-check"><input type="checkbox" name="ruleSeats" checked={own.moreSeats > 0} /> for more than <input type="number" name="moreSeats" min="1" max="100000" value={own.moreSeats || 20} /> people</label>
|
|
117
151
|
</fieldset>
|
|
118
|
-
<div class="appr-deadline" data-deadline hidden={
|
|
152
|
+
<div class="appr-deadline" data-deadline hidden={view.approval === 'default' || view.approval === 'auto'}>
|
|
119
153
|
<label class="appr-check"><input type="checkbox" name="deadline" checked={own.deadlineHours > 0} /> If I haven’t answered in <input type="number" name="deadlineHours" min="1" max="1440" value={own.deadlineHours || 48} /> hours,</label>
|
|
120
154
|
<select name="deadlineAction" aria-label="What happens then">
|
|
121
155
|
<option value="approve" selected={own.deadlineAction === 'approve'}>approve it</option>
|
|
@@ -127,39 +161,43 @@ const effective = linkApproval(link, settings).mode;
|
|
|
127
161
|
|
|
128
162
|
<section class="card card-pad">
|
|
129
163
|
<div class="card-head"><b>How people book</b></div>
|
|
130
|
-
<div class="seg block mode-seg" role="radiogroup">
|
|
131
|
-
{[['slots', '
|
|
132
|
-
<label class={
|
|
164
|
+
<div class="seg block mode-seg" role="radiogroup" aria-label="How people book">
|
|
165
|
+
{[['slots', 'One length'], ['range', 'Several lengths'], ['day', 'Whole days']].map(([v, l]) => (
|
|
166
|
+
<label class={view.mode === v ? 'on' : ''}><input type="radio" name="mode" value={v} checked={view.mode === v} /> {l}</label>
|
|
133
167
|
))}
|
|
134
168
|
</div>
|
|
135
|
-
<div class="
|
|
136
|
-
<
|
|
137
|
-
<
|
|
138
|
-
<
|
|
139
|
-
<
|
|
140
|
-
<
|
|
169
|
+
<div class="mode-fields">
|
|
170
|
+
<p class="hint mode-help" data-mode="slots">Every booking is the same length.</p>
|
|
171
|
+
<p class="hint mode-help" data-mode="range">People choose one of the lengths you offer.</p>
|
|
172
|
+
<p class="hint mode-help" data-mode="day">People book one or more whole days.</p>
|
|
173
|
+
<DurationEditor single={single} singleError={singleError} several={several} severalError={severalError} />
|
|
174
|
+
<div class="field-row three">
|
|
175
|
+
<label class="field" data-mode="day"><span>Min days</span><input type="number" name="minDays" min="1" max="60" value={view.minDays} /></label>
|
|
176
|
+
<label class="field" data-mode="day"><span>Max days</span><input type="number" name="maxDays" min="1" max="60" value={view.maxDays} /></label>
|
|
177
|
+
<label class="field" data-mode="slots range"><span>Start every (minutes)</span><input type="number" name="step" min="5" max="240" value={view.step} /></label>
|
|
178
|
+
</div>
|
|
141
179
|
</div>
|
|
142
180
|
<div class="field-row three">
|
|
143
|
-
<label class="field"><span>Setup before (min)</span><input type="number" name="bufferBefore" min="0" max="480" value={
|
|
144
|
-
<label class="field"><span>Cleanup after (min)</span><input type="number" name="bufferAfter" min="0" max="480" value={
|
|
145
|
-
<label class="field"><span>Max per day <em class="hint">0 = no limit</em></span><input type="number" name="perDayCap" min="0" max="100" value={
|
|
146
|
-
<label class="field"><span>Book at least (hours ahead)</span><input type="number" name="minNoticeH" min="0" max="1440" value={Math.round(
|
|
147
|
-
<label class="field"><span>Book up to (days ahead)</span><input type="number" name="maxAdvance" min="1" max="730" value={
|
|
181
|
+
<label class="field"><span>Setup before (min)</span><input type="number" name="bufferBefore" min="0" max="480" value={view.bufferBefore} /></label>
|
|
182
|
+
<label class="field"><span>Cleanup after (min)</span><input type="number" name="bufferAfter" min="0" max="480" value={view.bufferAfter} /></label>
|
|
183
|
+
<label class="field"><span>Max per day <em class="hint">0 = no limit</em></span><input type="number" name="perDayCap" min="0" max="100" value={view.perDayCap} /></label>
|
|
184
|
+
<label class="field"><span>Book at least (hours ahead)</span><input type="number" name="minNoticeH" min="0" max="1440" value={Math.round(view.minNotice / 60)} /></label>
|
|
185
|
+
<label class="field"><span>Book up to (days ahead)</span><input type="number" name="maxAdvance" min="1" max="730" value={view.maxAdvance} /></label>
|
|
148
186
|
</div>
|
|
149
187
|
</section>
|
|
150
188
|
|
|
151
189
|
<section class="card card-pad">
|
|
152
190
|
<div class="card-head"><b>Hours for this link</b></div>
|
|
153
|
-
<label class="check-row"><input type="checkbox" name="customHours" checked={Boolean(
|
|
154
|
-
<div id="lh-box" hidden={!
|
|
191
|
+
<label class="check-row"><input type="checkbox" name="customHours" checked={Boolean(view.hours)} data-toggle="lh-box" /> Narrow the {resource.name.toLowerCase()} hours for this link</label>
|
|
192
|
+
<div id="lh-box" hidden={!view.hours}>
|
|
155
193
|
<HoursEditor hours={hours} prefix="lh" bounds={resource.hours} note={`Green = this link, grey = when ${resource.name} is open. The link can only narrow those hours.`} />
|
|
156
194
|
</div>
|
|
157
|
-
{!
|
|
195
|
+
{!view.hours && <p class="hint">Uses {resource.name}’s open hours.</p>}
|
|
158
196
|
</section>
|
|
159
197
|
|
|
160
198
|
<section class="card card-pad">
|
|
161
199
|
<div class="card-head"><b>Questions</b><span class="hint">Name and email are always asked</span></div>
|
|
162
|
-
<label class="check-row"><input type="checkbox" name="askPhone" checked={
|
|
200
|
+
<label class="check-row"><input type="checkbox" name="askPhone" checked={view.askPhone} /> Ask for a phone number</label>
|
|
163
201
|
<div class="q-list">
|
|
164
202
|
{qRows.map((q, i) => (
|
|
165
203
|
<div class={`q-row${q.label ? '' : ' is-blank'}`}>
|
|
@@ -178,22 +216,22 @@ const effective = linkApproval(link, settings).mode;
|
|
|
178
216
|
<div class="res-side">
|
|
179
217
|
<section class="card card-pad">
|
|
180
218
|
<div class="card-head"><b>Link</b></div>
|
|
181
|
-
<label class="field"><span>Name</span><input type="text" name="name" value={
|
|
182
|
-
<label class="field"><span>URL name</span><input type="text" class="mono" name="slug" value={
|
|
183
|
-
<label class="field"><span>Description</span><textarea name="description" rows="3" placeholder="What this booking is for">{
|
|
219
|
+
<label class="field"><span>Name</span><input type="text" name="name" value={view.name} required /></label>
|
|
220
|
+
<label class="field"><span>URL name</span><input type="text" class="mono" name="slug" value={view.slug} /></label>
|
|
221
|
+
<label class="field"><span>Description</span><textarea name="description" rows="3" placeholder="What this booking is for">{view.description}</textarea></label>
|
|
184
222
|
<label class="field">
|
|
185
223
|
<span>Who can find it</span>
|
|
186
224
|
<select name="visibility">
|
|
187
|
-
<option value="public" selected={
|
|
188
|
-
<option value="unlisted" selected={
|
|
225
|
+
<option value="public" selected={view.visibility === 'public'}>Listed on the {resource.name} page</option>
|
|
226
|
+
<option value="unlisted" selected={view.visibility === 'unlisted'}>Unlisted — share the link directly</option>
|
|
189
227
|
</select>
|
|
190
228
|
</label>
|
|
191
|
-
<label class="check-row"><input type="checkbox" name="active" checked={
|
|
229
|
+
<label class="check-row"><input type="checkbox" name="active" checked={view.active} /> Taking bookings</label>
|
|
192
230
|
</section>
|
|
193
231
|
<section class="card card-pad">
|
|
194
232
|
<div class="card-head"><b>After booking</b></div>
|
|
195
|
-
<label class="field"><span>Confirmation message</span><textarea name="confirmMessage" rows="3" placeholder="Shown on the confirmation page and in the email">{
|
|
196
|
-
<label class="field"><span>Redirect to <em class="hint">optional</em></span><input type="text" class="mono" name="redirectUrl" value={
|
|
233
|
+
<label class="field"><span>Confirmation message</span><textarea name="confirmMessage" rows="3" placeholder="Shown on the confirmation page and in the email">{view.confirmMessage}</textarea></label>
|
|
234
|
+
<label class="field"><span>Redirect to <em class="hint">optional</em></span><input type="text" class="mono" name="redirectUrl" value={view.redirectUrl} placeholder="https://…" /></label>
|
|
197
235
|
</section>
|
|
198
236
|
<section class="card card-pad">
|
|
199
237
|
<div class="card-head"><b>Share</b></div>
|
|
@@ -206,12 +244,20 @@ const effective = linkApproval(link, settings).mode;
|
|
|
206
244
|
</form>
|
|
207
245
|
</Admin>
|
|
208
246
|
|
|
247
|
+
<style>
|
|
248
|
+
.mode-help { margin: -6px 4px 12px; }
|
|
249
|
+
</style>
|
|
250
|
+
|
|
209
251
|
<script>
|
|
210
252
|
const form = document.getElementById('link-form') as HTMLFormElement;
|
|
211
253
|
const sync = () => {
|
|
212
254
|
const mode = (form.querySelector<HTMLInputElement>('input[name=mode]:checked')?.value) ?? 'slots';
|
|
213
255
|
form.querySelectorAll<HTMLElement>('.mode-seg label').forEach((l) => l.classList.toggle('on', l.querySelector('input')?.checked ?? false));
|
|
214
|
-
form.querySelectorAll<HTMLElement>('.mode-fields [data-mode]').forEach((el) => {
|
|
256
|
+
form.querySelectorAll<HTMLElement>('.mode-fields [data-mode]').forEach((el) => {
|
|
257
|
+
el.hidden = !(el.dataset.mode ?? '').split(' ').includes(mode);
|
|
258
|
+
// hidden modes don't submit (or block submit with their own min/max)
|
|
259
|
+
el.querySelectorAll<HTMLInputElement>('input, select, textarea').forEach((i) => (i.disabled = el.hidden));
|
|
260
|
+
});
|
|
215
261
|
const appr = (form.querySelector<HTMLInputElement>('input[name=approval]:checked')?.value) ?? 'default';
|
|
216
262
|
const rules = form.querySelector<HTMLElement>('[data-rules]');
|
|
217
263
|
const deadline = form.querySelector<HTMLElement>('[data-deadline]');
|
|
@@ -288,7 +288,7 @@ const groups = [
|
|
|
288
288
|
<label class="field">
|
|
289
289
|
<span>Site URL</span>
|
|
290
290
|
<input type="text" name="siteUrl" value={settings.siteUrl} placeholder="https://example.com" inputmode="url" />
|
|
291
|
-
<small class="hint">
|
|
291
|
+
<small class="hint">Use your public HTTPS address, or HTTP on localhost for development. Required for booking and account email links; also used for canonical URLs, the sitemap, RSS and social previews. When empty, only website links fall back to the serving address.</small>
|
|
292
292
|
</label>
|
|
293
293
|
<label class="field">
|
|
294
294
|
<span>Footer</span>
|
|
@@ -6,6 +6,7 @@ import { validateSession, SESSION_COOKIE } from '../../../lib/auth';
|
|
|
6
6
|
import { reauthenticate } from '../../../lib/email/accounts';
|
|
7
7
|
import { decrypt, encrypt, encryptionAvailable, resolveConfig, runtimeValue } from '../../../lib/email/config';
|
|
8
8
|
import { dispatchEmail } from '../../../lib/email/service';
|
|
9
|
+
import { emailReadiness } from '../../../lib/email/readiness';
|
|
9
10
|
import type { ContentRepository } from '../../../lib/repo/types';
|
|
10
11
|
import type { Credentials, PublicEmailSettings } from '../../../lib/email/types';
|
|
11
12
|
const Config = z.object({provider:z.enum(['auto','none','resend','mailgun','ses','cloudflare']),source:z.enum(['env','encrypted']),from:z.string().trim().max(320),replyTo:z.string().trim().max(320),domain:z.string().trim().max(253),region:z.string().trim().max(40),configurationSet:z.string().trim().max(200),snsTopicArn:z.string().trim().max(300),enabled:z.boolean(),reminders:z.boolean(),staffNotifications:z.boolean()}).strict();
|
|
@@ -17,7 +18,7 @@ const Input = z.discriminatedUnion('action',[
|
|
|
17
18
|
]);
|
|
18
19
|
export async function publicSettings(repo:ContentRepository,locals?:unknown):Promise<PublicEmailSettings> {
|
|
19
20
|
const record=await repo.email.config(),c=resolveConfig(record,locals);
|
|
20
|
-
return {version:c.version,config:c.config,provider:c.provider,
|
|
21
|
+
return {version:c.version,config:c.config,provider:c.provider,...emailReadiness(c,(await repo.getSettings()).siteUrl,locals),environmentLocked:!!runtimeValue('EMAIL_PROVIDER',locals),credentialFields:Object.entries(c.credentials).filter(([,v])=>!!v).map(([k])=>k),activity:(await repo.email.list()).map(({payload,lease,fingerprint,rendered,...safe})=>safe),heartbeat:await repo.email.heartbeat()};
|
|
21
22
|
}
|
|
22
23
|
export const GET:APIRoute=async({request,cookies,locals})=>{
|
|
23
24
|
try{const repo=await getRepo(locals);await requireSession(request,cookies,repo,true);return json(await publicSettings(repo,locals));}catch(e){return apiError(e);}
|
|
@@ -56,6 +57,7 @@ export const POST:APIRoute=async({request,cookies,locals})=>{
|
|
|
56
57
|
await repo.email.enqueue({id,dedupe:`test:${id}`,kind:'test',recipient:input.to,payload:'{}',configVersion:c.version,expiresAt:Date.now()+3600000});
|
|
57
58
|
await dispatchEmail(repo,locals,5);
|
|
58
59
|
const job=await repo.email.get(id);
|
|
59
|
-
|
|
60
|
+
const readiness=emailReadiness(c,(await repo.getSettings()).siteUrl,locals);
|
|
61
|
+
return json({message:job?.state==='accepted'?`Test accepted by the provider. Delivery has not yet been confirmed.${readiness.linkError ? ` Booking and account emails still need setup: ${readiness.linkError}` : ''}`:`Test status: ${job?.state || 'queued'}. ${job?.error||''}`});
|
|
60
62
|
}catch(e){return apiError(e);}
|
|
61
63
|
};
|