@acms-subir/core 0.3.6 → 0.3.7
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 +17 -0
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +146 -2
- package/src/components/EventPage.astro +25 -5
- package/src/components/calendar/BookingsHead.astro +4 -3
- package/src/components/calendar/RoomHolds.tsx +13 -8
- package/src/components/editor/EventPanel.tsx +9 -383
- package/src/components/editor/LivePane.tsx +16 -3
- package/src/components/editor/PostEditor.tsx +59 -46
- package/src/components/editor/PreviewOverlay.tsx +7 -1
- package/src/components/editor/event/EventSheet.tsx +136 -0
- package/src/components/editor/event/LocationField.tsx +266 -0
- package/src/components/editor/event/OrganizeCard.tsx +87 -0
- package/src/components/editor/event/RegistrationCard.tsx +114 -0
- package/src/components/editor/event/RegistrationFormEditor.tsx +142 -0
- package/src/components/editor/event/Section.tsx +27 -0
- package/src/components/editor/event/WhenCard.tsx +217 -0
- package/src/components/editor/event/dates.ts +28 -0
- package/src/components/editor/event/registration.ts +39 -0
- package/src/components/editor/extensions/form.tsx +60 -0
- package/src/components/editor/extensions/slash.tsx +5 -0
- package/src/components/editor/frameNav.ts +20 -0
- package/src/components/forms/Canvas.tsx +149 -0
- package/src/components/forms/FormBuildPage.tsx +155 -0
- package/src/components/forms/FormBuilder.tsx +283 -0
- package/src/components/forms/FormHeader.astro +31 -0
- package/src/components/forms/InsertMenu.tsx +77 -0
- package/src/components/forms/Inspector.tsx +232 -0
- package/src/components/forms/Outline.tsx +93 -0
- package/src/components/forms/ResponsesTable.tsx +123 -0
- package/src/components/forms/TypeIcon.tsx +24 -0
- package/src/components/forms/dnd.ts +3 -0
- package/src/components/forms/meta.ts +84 -0
- package/src/components/forms/useFormHistory.ts +41 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/lib/backup/content.ts +10 -5
- package/src/lib/booking/service.ts +6 -2
- package/src/lib/calendar/agent-schema.ts +7 -1
- package/src/lib/calendar/agent.ts +28 -3
- package/src/lib/calendar/api.ts +2 -0
- package/src/lib/calendar/ics-write.ts +2 -0
- package/src/lib/calendar/ics.ts +4 -0
- package/src/lib/calendar/import.ts +3 -3
- package/src/lib/calendar/schedule.ts +6 -2
- package/src/lib/calendar/seo.ts +4 -1
- package/src/lib/content-service.ts +30 -4
- package/src/lib/drafts.ts +9 -1
- package/src/lib/dynamicBlocks.ts +14 -3
- package/src/lib/editor/documents.ts +12 -3
- package/src/lib/editor/schemas.ts +4 -3
- package/src/lib/forms/events.ts +40 -0
- package/src/lib/forms/export.ts +62 -0
- package/src/lib/forms/fields.ts +50 -0
- package/src/lib/forms/http.ts +29 -0
- package/src/lib/forms/render.ts +11 -0
- package/src/lib/forms/schema.ts +56 -0
- package/src/lib/forms/service.ts +38 -0
- package/src/lib/forms/store.ts +78 -0
- package/src/lib/forms/validate.ts +71 -0
- package/src/lib/maps/config.ts +64 -0
- package/src/lib/maps/migration.ts +12 -0
- package/src/lib/maps/parse.ts +180 -0
- package/src/lib/maps/places.ts +109 -0
- package/src/lib/maps/providers/geoapify.ts +36 -0
- package/src/lib/maps/providers/google.ts +54 -0
- package/src/lib/maps/providers/http.ts +96 -0
- package/src/lib/maps/providers/index.ts +37 -0
- package/src/lib/maps/providers/locationiq.ts +35 -0
- package/src/lib/maps/providers/mapbox.ts +38 -0
- package/src/lib/maps/providers/osm.ts +59 -0
- package/src/lib/maps/providers/pelias.ts +51 -0
- package/src/lib/maps/service.ts +281 -0
- package/src/lib/maps/types.ts +35 -0
- package/src/lib/mobile/bookings.ts +1 -1
- package/src/lib/mobile/schemas.ts +2 -2
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +99 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +38 -0
- package/src/lib/umami.ts +8 -1
- package/src/pages/admin/calendar/bookings.astro +12 -7
- package/src/pages/admin/calendar/events/[id].astro +5 -2
- package/src/pages/admin/calendar/events/index.astro +3 -0
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/admin/calendar/settings.astro +140 -3
- package/src/pages/admin/forms/[id]/share.astro +121 -0
- package/src/pages/admin/forms/[id]/submissions.astro +143 -0
- package/src/pages/admin/forms/[id].astro +27 -0
- package/src/pages/admin/forms/index.astro +198 -0
- package/src/pages/admin/integrations.astro +57 -0
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -0
- package/src/pages/api/calendar/events/[id].ts +1 -1
- package/src/pages/api/calendar/events/index.ts +1 -1
- package/src/pages/api/mcp.ts +1 -1
- package/src/pages/book/manage/[token].astro +4 -4
- package/src/pages/events/[slug].astro +24 -10
- package/src/pages/forms/[id].astro +27 -0
- package/src/styles/admin.css +2 -2
- package/src/styles/editor.css +275 -34
- package/src/styles/form-block.css +38 -0
- package/src/styles/forms-admin.css +184 -0
- package/src/styles/forms.css +281 -0
- package/src/styles/site.css +43 -0
- package/src/virtual.d.ts +2 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import FormHeader from '../../../../components/forms/FormHeader.astro';
|
|
4
|
+
import { ResponsesTable, type ResponseRow } from '../../../../components/forms/ResponsesTable';
|
|
5
|
+
import { getRepo } from '../../../../lib/repo';
|
|
6
|
+
import { readForm } from '../../../../lib/forms/http';
|
|
7
|
+
import { csv, displayAnswers, formVersion } from '../../../../lib/forms/service';
|
|
8
|
+
import { answerFields, relativeTime, versionLabeler, wideRows } from '../../../../lib/forms/export';
|
|
9
|
+
import type { FormSubmission } from '../../../../lib/forms/schema';
|
|
10
|
+
import '../../../../styles/forms-admin.css';
|
|
11
|
+
|
|
12
|
+
const repo = await getRepo(Astro.locals);
|
|
13
|
+
const form = await repo.forms.get(Astro.params.id || '');
|
|
14
|
+
if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
|
|
15
|
+
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
16
|
+
|
|
17
|
+
const DAY = 86_400_000;
|
|
18
|
+
const range = (['7', '30'] as const).find(r => r === Astro.url.searchParams.get('range')) ?? '';
|
|
19
|
+
const q = (Astro.url.searchParams.get('q') ?? '').trim().slice(0, 100);
|
|
20
|
+
const since = range ? Date.now() - Number(range) * DAY : undefined;
|
|
21
|
+
const [versions, current] = await Promise.all([repo.forms.versions(form.id), formVersion(form.definition)]);
|
|
22
|
+
const version = versionLabeler(versions, current);
|
|
23
|
+
const download = (rows: FormSubmission[]) => {
|
|
24
|
+
const slug = form.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'form';
|
|
25
|
+
return new Response(csv(wideRows(form.definition.fields, rows, version)), { headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="${slug}-responses.csv"`, 'Cache-Control': 'private, no-store' } });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let error = '';
|
|
29
|
+
if (Astro.request.method === 'POST') {
|
|
30
|
+
try {
|
|
31
|
+
const data = await readForm(Astro.request);
|
|
32
|
+
const op = String(data.get('op') || 'delete');
|
|
33
|
+
const ids = data.getAll('ids').map(String).filter(Boolean).slice(0, 500);
|
|
34
|
+
if (op === 'export') return download(await repo.forms.searchSubmissions(form.id, { ids, limit: 500 }));
|
|
35
|
+
if (op === 'bulkDelete') await repo.forms.deleteSubmissions(form.id, ids);
|
|
36
|
+
else if (op === 'delete') await repo.forms.deleteSubmission(form.id, String(data.get('id') || ''));
|
|
37
|
+
else throw new Error('Unknown action.');
|
|
38
|
+
const n = op === 'bulkDelete' ? ids.length : 1;
|
|
39
|
+
const back = new URL(Astro.url); back.searchParams.set('notice', `${n} response${n === 1 ? '' : 's'} deleted.`);
|
|
40
|
+
return Astro.redirect(back.pathname + back.search, 303);
|
|
41
|
+
} catch (e) { error = e instanceof Error ? e.message : 'Could not update responses.'; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const page = Math.max(0, Math.floor(Number(Astro.url.searchParams.get('page')) || 0));
|
|
45
|
+
if (Astro.url.searchParams.has('export')) {
|
|
46
|
+
const rows = await repo.forms.searchSubmissions(form.id, { since, q, limit: 10001 });
|
|
47
|
+
if (rows.length > 10000) return new Response('Export is limited to 10,000 responses. Use a content backup for the complete archive.', { status: 413 });
|
|
48
|
+
return download(rows);
|
|
49
|
+
}
|
|
50
|
+
const [rows, stats, settings] = await Promise.all([
|
|
51
|
+
repo.forms.searchSubmissions(form.id, { since, q, limit: 101, offset: page * 100 }),
|
|
52
|
+
repo.forms.submissionStats(form.id, Date.now() - 7 * DAY),
|
|
53
|
+
repo.getSettings(),
|
|
54
|
+
]);
|
|
55
|
+
const tz = settings.timezone || undefined;
|
|
56
|
+
const notice = Astro.url.searchParams.get('notice')?.slice(0, 120) ?? '';
|
|
57
|
+
const columns = answerFields(form.definition.fields).map(f => ({ id: f.id, label: f.label }));
|
|
58
|
+
const currentIds = new Set(columns.map(c => c.id));
|
|
59
|
+
const fmt = (ts: number, o: Intl.DateTimeFormatOptions) => { try { return new Date(ts).toLocaleString('en-US', { ...o, timeZone: tz }); } catch { return new Date(ts).toLocaleString('en-US', o); } };
|
|
60
|
+
const shortWhen = (ts: number) => {
|
|
61
|
+
const day = (t: number) => fmt(t, { year: 'numeric', month: 'numeric', day: 'numeric' });
|
|
62
|
+
const time = fmt(ts, { hour: 'numeric', minute: '2-digit' });
|
|
63
|
+
if (day(ts) === day(Date.now())) return `Today ${time}`;
|
|
64
|
+
if (day(ts) === day(Date.now() - DAY)) return `Yesterday ${time}`;
|
|
65
|
+
return fmt(ts, { month: 'short', day: 'numeric', ...(new Date(ts).getFullYear() !== new Date().getFullYear() ? { year: 'numeric' } : {}) });
|
|
66
|
+
};
|
|
67
|
+
// displayAnswers owns per-type formatting; call it on subsets of the snapshot.
|
|
68
|
+
const show = (r: FormSubmission, keep: (id: string) => boolean) => {
|
|
69
|
+
const fields = answerFields(r.response.definition.fields).filter(f => keep(f.id));
|
|
70
|
+
return fields.length ? Object.entries(displayAnswers({ ...r.response, definition: { ...r.response.definition, fields } })) : [];
|
|
71
|
+
};
|
|
72
|
+
const data: ResponseRow[] = rows.slice(0, 100).map(r => ({
|
|
73
|
+
id: r.id,
|
|
74
|
+
when: shortWhen(r.createdAt),
|
|
75
|
+
full: fmt(r.createdAt, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }),
|
|
76
|
+
version: version(r.response.version),
|
|
77
|
+
cells: columns.map(c => r.response.definition.fields.some(f => f.id === c.id) ? (show(r, id => id === c.id)[0]?.[1] ?? '') : ''),
|
|
78
|
+
answers: show(r, id => currentIds.has(id)),
|
|
79
|
+
older: show(r, id => !currentIds.has(id)),
|
|
80
|
+
}));
|
|
81
|
+
const keep = (patch: Record<string, string | null>) => {
|
|
82
|
+
const u = new URL(Astro.url); u.searchParams.delete('notice');
|
|
83
|
+
for (const [k, v] of Object.entries(patch)) v === null ? u.searchParams.delete(k) : u.searchParams.set(k, v);
|
|
84
|
+
return u.pathname + u.search;
|
|
85
|
+
};
|
|
86
|
+
const filtered = !!(q || range);
|
|
87
|
+
---
|
|
88
|
+
<Admin title={`${form.name} · Responses`} section="forms" wide>
|
|
89
|
+
<FormHeader form={form} active="responses" responses={stats.total}>
|
|
90
|
+
<a slot="actions" class="btn" href={keep({ export: '1', page: null })} download>Export CSV</a>
|
|
91
|
+
</FormHeader>
|
|
92
|
+
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
93
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
94
|
+
|
|
95
|
+
{!stats.total ? (
|
|
96
|
+
<div class="empty-state">
|
|
97
|
+
<b>No responses yet</b>
|
|
98
|
+
<p>{form.status === 'published' ? 'Share the form or add it to a page. New responses show up here.' : 'Publish the form so people can respond.'}</p>
|
|
99
|
+
<a class="btn primary" href={form.status === 'published' ? `/admin/forms/${form.id}/share` : `/admin/forms/${form.id}`}>{form.status === 'published' ? 'Share form' : 'Open builder'}</a>
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<>
|
|
103
|
+
<div class="kpis fr-kpis">
|
|
104
|
+
<div class="kpi"><small>Responses</small><b>{stats.total.toLocaleString('en-US')}</b></div>
|
|
105
|
+
<div class="kpi"><small>Last 7 days</small><b>{stats.recent.toLocaleString('en-US')}</b></div>
|
|
106
|
+
<div class="kpi"><small>Latest</small><b>{stats.last ? relativeTime(stats.last, Date.now(), tz) : '—'}</b></div>
|
|
107
|
+
<div class="kpi"><small>Current version</small><b>{version(current)}</b></div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<form method="get" class="fr-tools" role="search">
|
|
111
|
+
<label class="fr-search">
|
|
112
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" /></svg>
|
|
113
|
+
<input type="search" name="q" value={q} placeholder="Search responses" aria-label="Search responses" maxlength="100" />
|
|
114
|
+
</label>
|
|
115
|
+
<select name="range" aria-label="Date range" data-autosubmit>
|
|
116
|
+
<option value="" selected={!range}>All time</option>
|
|
117
|
+
<option value="7" selected={range === '7'}>Last 7 days</option>
|
|
118
|
+
<option value="30" selected={range === '30'}>Last 30 days</option>
|
|
119
|
+
</select>
|
|
120
|
+
<noscript><button class="btn" type="submit">Apply</button></noscript>
|
|
121
|
+
{filtered && <a class="btn ghost sm" href={keep({ q: null, range: null, page: null })}>Clear</a>}
|
|
122
|
+
<span class="fr-count">{filtered ? `${rows.length > 100 ? '100+' : data.length} matching` : `${stats.total.toLocaleString('en-US')} total`}</span>
|
|
123
|
+
</form>
|
|
124
|
+
|
|
125
|
+
{data.length ? (
|
|
126
|
+
<ResponsesTable client:load columns={columns} rows={data} formName={form.name} />
|
|
127
|
+
) : (
|
|
128
|
+
<p class="fr-none hint">No responses match. <a href={keep({ q: null, range: null, page: null })}>Clear filters</a></p>
|
|
129
|
+
)}
|
|
130
|
+
{(page > 0 || rows.length > 100) && (
|
|
131
|
+
<nav class="fr-pager" aria-label="Pages">
|
|
132
|
+
{page > 0 ? <a class="btn sm" href={keep({ page: page > 1 ? String(page - 1) : null })}>‹ Newer</a> : <span />}
|
|
133
|
+
<span class="hint">Page {page + 1}</span>
|
|
134
|
+
{rows.length > 100 ? <a class="btn sm" href={keep({ page: String(page + 1) })}>Older ›</a> : <span />}
|
|
135
|
+
</nav>
|
|
136
|
+
)}
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</Admin>
|
|
140
|
+
|
|
141
|
+
<script>
|
|
142
|
+
document.querySelector<HTMLSelectElement>('[data-autosubmit]')?.addEventListener('change', (e) => (e.target as HTMLSelectElement).form?.requestSubmit());
|
|
143
|
+
</script>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import FormHeader from '../../../components/forms/FormHeader.astro';
|
|
4
|
+
import FormBuildPage from '../../../components/forms/FormBuildPage';
|
|
5
|
+
import { getRepo } from '../../../lib/repo';
|
|
6
|
+
import '../../../styles/forms.css';
|
|
7
|
+
// Build tab. Edits autosave as a draft through the `forms.save` action;
|
|
8
|
+
// "Publish changes" makes them live. See components/forms/FormBuildPage.tsx.
|
|
9
|
+
const repo = await getRepo(Astro.locals);
|
|
10
|
+
const form = await repo.forms.get(Astro.params.id || '');
|
|
11
|
+
if (!form) return new Response(null, { status: 404 });
|
|
12
|
+
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
13
|
+
const responses = form.kind === 'form' && form.purpose === 'general' ? (await repo.forms.submissions(form.id, 1)).length : 0;
|
|
14
|
+
---
|
|
15
|
+
<Admin title={form.name} section="forms" wide>
|
|
16
|
+
<FormHeader form={form} active="build">
|
|
17
|
+
<div slot="actions" id="fb-actions" class="fb-actions-slot"></div>
|
|
18
|
+
</FormHeader>
|
|
19
|
+
{form.status === 'archived' && <p class="notice warn">This form is archived and doesn’t accept responses. You can still edit it.</p>}
|
|
20
|
+
<noscript><p class="notice warn">Enable JavaScript to edit this form.</p></noscript>
|
|
21
|
+
<FormBuildPage
|
|
22
|
+
client:load
|
|
23
|
+
form={{ id: form.id, name: form.name, kind: form.kind, purpose: form.purpose, status: form.status, revision: form.revision, hasDraft: Boolean(form.draft) }}
|
|
24
|
+
definition={form.draft ?? form.definition}
|
|
25
|
+
responses={responses}
|
|
26
|
+
/>
|
|
27
|
+
</Admin>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../../lib/repo';
|
|
5
|
+
import { FormDefinition, starter, type SavedForm } from '../../../lib/forms/schema';
|
|
6
|
+
import { readForm } from '../../../lib/forms/http';
|
|
7
|
+
import { relativeTime } from '../../../lib/forms/export';
|
|
8
|
+
import '../../../styles/forms-admin.css';
|
|
9
|
+
|
|
10
|
+
const repo = await getRepo(Astro.locals);
|
|
11
|
+
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
12
|
+
const BLANK = { title: '', intro: '', submitLabel: '', confirmation: 'Thank you. Your response has been received.', fields: [] };
|
|
13
|
+
const STARTERS = [
|
|
14
|
+
{ key: 'contact', name: 'Contact', desc: 'Name, email, message', def: starter('contact') },
|
|
15
|
+
{ key: 'interest', name: 'Interest sign-up', desc: 'Name, email', def: starter('interest') },
|
|
16
|
+
{ key: 'blank', name: 'Blank', desc: 'Start empty', def: FormDefinition.parse(BLANK) },
|
|
17
|
+
{ key: 'simple', name: 'Simple RSVP', desc: 'Event template · name, email, seats', def: starter('simple') },
|
|
18
|
+
{ key: 'event', name: 'Event registration', desc: 'Event template · phone, dietary, notes', def: starter('event') },
|
|
19
|
+
];
|
|
20
|
+
let error = '';
|
|
21
|
+
if (Astro.request.method === 'POST') {
|
|
22
|
+
try {
|
|
23
|
+
const data = await readForm(Astro.request);
|
|
24
|
+
const op = String(data.get('op') || 'create');
|
|
25
|
+
if (op === 'archive' || op === 'restore') {
|
|
26
|
+
const form = await repo.forms.get(String(data.get('id') || ''));
|
|
27
|
+
if (!form) throw new Error('Form not found.');
|
|
28
|
+
if (op === 'archive') await repo.forms.save(form.id, form.revision, form.name, form.draft ?? form.definition, 'archive');
|
|
29
|
+
else await repo.forms.restore(form.id);
|
|
30
|
+
return Astro.redirect(op === 'archive' ? '/admin/forms?tab=archived' : `/admin/forms${form.kind === 'template' ? '?tab=templates' : ''}`, 303);
|
|
31
|
+
}
|
|
32
|
+
// The gallery posts `pick` = starter:<key> | source:<id>; row menus post `source`.
|
|
33
|
+
const pick = String(data.get('pick') || '');
|
|
34
|
+
const sourceId = pick.startsWith('source:') ? pick.slice(7) : String(data.get('source') || '');
|
|
35
|
+
const source = sourceId ? await repo.forms.get(sourceId) : null;
|
|
36
|
+
if (sourceId && !source) throw new Error('That template no longer exists.');
|
|
37
|
+
const key = pick.startsWith('starter:') ? pick.slice(8) : String(data.get('starter') || 'contact');
|
|
38
|
+
const kind = data.get('kind') === 'template' ? 'template' : 'form';
|
|
39
|
+
const purpose = source?.purpose ?? (['simple', 'event'].includes(key) ? 'event' : 'general');
|
|
40
|
+
const definition = source ? (source.draft ?? source.definition) : key === 'blank' ? FormDefinition.parse(BLANK) : starter(key);
|
|
41
|
+
const created = await repo.forms.create(String(data.get('name') || '').trim() || source?.name || 'Untitled form', definition, purpose === 'event' ? 'template' : kind, purpose);
|
|
42
|
+
return Astro.redirect(`/admin/forms/${created.id}`, 303);
|
|
43
|
+
} catch (e) { error = e instanceof Error ? e.message : 'Could not create the form.'; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const settings = await repo.getSettings();
|
|
47
|
+
const tz = settings.timezone || undefined;
|
|
48
|
+
const [forms, stats] = await Promise.all([repo.forms.list(), repo.forms.stats()]);
|
|
49
|
+
const groups = {
|
|
50
|
+
forms: forms.filter(f => f.kind === 'form' && f.status !== 'archived'),
|
|
51
|
+
templates: forms.filter(f => f.kind === 'template' && f.status !== 'archived'),
|
|
52
|
+
archived: forms.filter(f => f.status === 'archived'),
|
|
53
|
+
};
|
|
54
|
+
const tab = (['templates', 'archived'] as const).find(t => t === Astro.url.searchParams.get('tab')) ?? 'forms';
|
|
55
|
+
const rows = groups[tab];
|
|
56
|
+
const templates = groups.templates;
|
|
57
|
+
const openNew = Astro.url.searchParams.has('new') || (!!error && Astro.request.method === 'POST');
|
|
58
|
+
const status = (f: SavedForm) => f.status === 'archived' ? ['Archived', 'muted'] : f.status === 'published' ? (f.draft ? ['Edited', 'amber'] : ['Published', 'green']) : ['Draft', 'muted'];
|
|
59
|
+
const questions = (f: SavedForm) => { const n = (f.draft ?? f.definition).fields.length; return `${n} question${n === 1 ? '' : 's'}`; };
|
|
60
|
+
const sub = (f: SavedForm) => f.kind === 'template'
|
|
61
|
+
? `${f.purpose === 'event' ? 'Event' : 'General'} template · ${questions(f)}`
|
|
62
|
+
: f.draft ? 'Unpublished changes' : f.status === 'published' ? `/forms/${f.id}` : f.status === 'archived' ? 'Not accepting responses' : `Not published yet · ${questions(f)}`;
|
|
63
|
+
const skeleton = (def: FormDefinition) => def.fields.filter(f => (f.type as string) !== 'heading').slice(0, 3).map(f => f.type === 'textarea' ? 'tall' : f.type === 'checkbox' ? 'check' : 'line');
|
|
64
|
+
const EMPTY = {
|
|
65
|
+
forms: { title: 'No forms yet', text: 'Start from Contact or Interest sign-up, or a blank form, then publish it and add it to a page.' },
|
|
66
|
+
templates: { title: 'No templates yet', text: 'Templates are starting points. Event templates appear in Events → Registration. Save any form as a template from its Build tab.' },
|
|
67
|
+
archived: { title: 'Nothing archived', text: 'Archived forms stop accepting responses. Their responses are kept.' },
|
|
68
|
+
};
|
|
69
|
+
const dots = 'M5 12h.01M12 12h.01M19 12h.01';
|
|
70
|
+
---
|
|
71
|
+
<Admin title="Forms" section="forms">
|
|
72
|
+
<div class="page-head">
|
|
73
|
+
<div><h1>Forms</h1><p class="sub">Collect sign-ups, messages and requests. Publish a form, then share its link or add it to a page.</p></div>
|
|
74
|
+
<div class="page-actions"><a class="btn primary" href="?new=1" data-modal="fl-new" data-fl-open>+ New form</a></div>
|
|
75
|
+
</div>
|
|
76
|
+
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
77
|
+
<nav class="ptabs" aria-label="Forms">
|
|
78
|
+
<a href="/admin/forms" aria-current={tab === 'forms' ? 'page' : undefined}>Forms<small>{groups.forms.length}</small></a>
|
|
79
|
+
<a href="/admin/forms?tab=templates" aria-current={tab === 'templates' ? 'page' : undefined}>Templates<small>{groups.templates.length}</small></a>
|
|
80
|
+
<a href="/admin/forms?tab=archived" aria-current={tab === 'archived' ? 'page' : undefined}>Archived{groups.archived.length > 0 && <small>{groups.archived.length}</small>}</a>
|
|
81
|
+
</nav>
|
|
82
|
+
|
|
83
|
+
{!rows.length ? (
|
|
84
|
+
<div class="empty-state">
|
|
85
|
+
<b>{EMPTY[tab].title}</b>
|
|
86
|
+
<p>{EMPTY[tab].text}</p>
|
|
87
|
+
{tab !== 'archived' && <a class="btn primary" href="?new=1" data-modal="fl-new" data-fl-open>New form</a>}
|
|
88
|
+
</div>
|
|
89
|
+
) : (
|
|
90
|
+
<div class="fl" role="table" aria-label={tab === 'templates' ? 'Templates' : 'Forms'}>
|
|
91
|
+
<div class="fl-head" role="row">
|
|
92
|
+
<span role="columnheader">Name</span><span role="columnheader">{tab === 'templates' ? 'Use' : 'Status'}</span>
|
|
93
|
+
<span role="columnheader" class="fl-num">Responses</span><span role="columnheader">Last response</span><span />
|
|
94
|
+
</div>
|
|
95
|
+
{rows.map(f => {
|
|
96
|
+
const [label, tone] = f.kind === 'template' && f.status !== 'archived' ? [f.purpose === 'event' ? 'Event' : 'General', 'muted'] : status(f);
|
|
97
|
+
const st = stats.get(f.id);
|
|
98
|
+
const isForm = f.kind === 'form';
|
|
99
|
+
return (
|
|
100
|
+
<div class="fl-row" role="row">
|
|
101
|
+
<a class="fl-main" href={`/admin/forms/${f.id}`} role="cell">
|
|
102
|
+
<b>{f.name}</b>
|
|
103
|
+
<small class={f.draft && f.kind === 'form' ? 'is-attn' : ''}>{sub(f)}</small>
|
|
104
|
+
</a>
|
|
105
|
+
<span role="cell" class={`fst fst-${tone}`}><i aria-hidden="true" />{label}</span>
|
|
106
|
+
<span role="cell" class="fl-num fl-meta">{isForm ? (st ? st.count.toLocaleString('en-US') : '0') : '—'}</span>
|
|
107
|
+
<span role="cell" class="fl-meta">{st?.last ? <time datetime={new Date(st.last).toISOString()} title={new Date(st.last).toLocaleString('en-US', { timeZone: tz })}>{relativeTime(st.last, Date.now(), tz)}</time> : '—'}</span>
|
|
108
|
+
<details class="rmenu fl-menu" role="cell">
|
|
109
|
+
<summary class="icon-btn" title="More actions" aria-label={`Actions for ${f.name}`}>
|
|
110
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d={dots} /></svg>
|
|
111
|
+
</summary>
|
|
112
|
+
<div class="rmenu-pop">
|
|
113
|
+
<a class="menu-item" href={`/admin/forms/${f.id}`}>Open</a>
|
|
114
|
+
<form method="post">
|
|
115
|
+
<input type="hidden" name="source" value={f.id} />
|
|
116
|
+
<input type="hidden" name="name" value={f.kind === 'template' ? f.name.replace(/ template$/i, '').slice(0, 120) : `${f.name.slice(0, 115)} copy`} />
|
|
117
|
+
<input type="hidden" name="kind" value="form" />
|
|
118
|
+
<button class="menu-item" type="submit">{f.kind === 'template' ? 'Use template' : 'Duplicate'}</button>
|
|
119
|
+
</form>
|
|
120
|
+
{isForm && <a class="menu-item" href={`/admin/forms/${f.id}/submissions`}>Responses</a>}
|
|
121
|
+
{isForm && f.status !== 'archived' && <a class="menu-item" href={`/admin/forms/${f.id}/share`}>Share</a>}
|
|
122
|
+
<div class="menu-sep" />
|
|
123
|
+
<form method="post">
|
|
124
|
+
<input type="hidden" name="id" value={f.id} />
|
|
125
|
+
{f.status === 'archived'
|
|
126
|
+
? <button class="menu-item" name="op" value="restore">Restore as draft</button>
|
|
127
|
+
: <button class="menu-item danger" name="op" value="archive">Archive</button>}
|
|
128
|
+
</form>
|
|
129
|
+
</div>
|
|
130
|
+
</details>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<Modal id="fl-new" title="Start a form" open={openNew}>
|
|
138
|
+
<form method="post" class="fl-new">
|
|
139
|
+
<input type="hidden" name="op" value="create" />
|
|
140
|
+
<fieldset class="fl-gallery">
|
|
141
|
+
<legend class="sr-only">Start with</legend>
|
|
142
|
+
{STARTERS.slice(0, 3).map((s, i) => (
|
|
143
|
+
<label class="fl-tile">
|
|
144
|
+
<input type="radio" name="pick" value={`starter:${s.key}`} checked={i === 0} data-name={s.key === 'blank' ? '' : s.def.title} />
|
|
145
|
+
<span class={`fl-skel${s.key === 'blank' ? ' is-blank' : ''}`} aria-hidden="true">{s.key === 'blank' ? <em>+</em> : <>{skeleton(s.def).map(k => <i class={k} />)}<u /></>}</span>
|
|
146
|
+
<b>{s.name}</b><small>{s.desc}</small>
|
|
147
|
+
</label>
|
|
148
|
+
))}
|
|
149
|
+
</fieldset>
|
|
150
|
+
<p class="fl-gallery-title">Event templates <small>Used in Events → Registration; responses land in Bookings.</small></p>
|
|
151
|
+
<fieldset class="fl-gallery">
|
|
152
|
+
<legend class="sr-only">Event templates</legend>
|
|
153
|
+
{STARTERS.slice(3).map(s => (
|
|
154
|
+
<label class="fl-tile">
|
|
155
|
+
<input type="radio" name="pick" value={`starter:${s.key}`} data-name={`${s.name} template`} />
|
|
156
|
+
<span class="fl-skel" aria-hidden="true">{(s.key === 'simple' ? ['line', 'line'] : skeleton(s.def)).map(k => <i class={k} />)}<u /></span>
|
|
157
|
+
<b>{s.name}</b><small>{s.desc}</small>
|
|
158
|
+
</label>
|
|
159
|
+
))}
|
|
160
|
+
{templates.map(t => (
|
|
161
|
+
<label class="fl-tile">
|
|
162
|
+
<input type="radio" name="pick" value={`source:${t.id}`} data-name={t.purpose === 'event' ? `${t.name} copy` : t.name.replace(/ template$/i, '')} />
|
|
163
|
+
<span class="fl-skel" aria-hidden="true">{skeleton(t.draft ?? t.definition).map(k => <i class={k} />)}<u /></span>
|
|
164
|
+
<b>{t.name}</b><small>Your {t.purpose === 'event' ? 'event' : ''} template · {questions(t)}</small>
|
|
165
|
+
</label>
|
|
166
|
+
))}
|
|
167
|
+
</fieldset>
|
|
168
|
+
<label class="field fl-name"><span>Name</span><input type="text" name="name" required maxlength="120" placeholder="Contact us" autocomplete="off" /></label>
|
|
169
|
+
<div class="modal-foot">
|
|
170
|
+
<button class="btn" type="button" data-close>Cancel</button>
|
|
171
|
+
<button class="btn primary" type="submit">Create form</button>
|
|
172
|
+
</div>
|
|
173
|
+
</form>
|
|
174
|
+
</Modal>
|
|
175
|
+
</Admin>
|
|
176
|
+
|
|
177
|
+
<script>
|
|
178
|
+
// Progressive enhancement: the link works without JS (?new=1 renders the
|
|
179
|
+
// dialog open); with JS it opens in place.
|
|
180
|
+
document.addEventListener('click', (e) => {
|
|
181
|
+
const a = e.target instanceof Element ? e.target.closest('a[data-fl-open]') : null;
|
|
182
|
+
if (a) e.preventDefault();
|
|
183
|
+
document.querySelectorAll<HTMLDetailsElement>('details.rmenu[open]').forEach((d) => {
|
|
184
|
+
if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
// Suggest a name from the chosen starter until the user types one.
|
|
188
|
+
const name = document.querySelector<HTMLInputElement>('.fl-new input[name=name]');
|
|
189
|
+
const suggest = (r: HTMLInputElement | null) => {
|
|
190
|
+
if (!name || !r || name.dataset.touched) return;
|
|
191
|
+
name.value = r.dataset.name ?? '';
|
|
192
|
+
};
|
|
193
|
+
name?.addEventListener('input', () => { name.dataset.touched = name.value ? '1' : ''; });
|
|
194
|
+
document.querySelector('.fl-new')?.addEventListener('change', (e) => {
|
|
195
|
+
if (e.target instanceof HTMLInputElement && e.target.name === 'pick') suggest(e.target);
|
|
196
|
+
});
|
|
197
|
+
suggest(document.querySelector<HTMLInputElement>('.fl-new input[name=pick]:checked'));
|
|
198
|
+
</script>
|
|
@@ -9,6 +9,8 @@ import { issueApiKey } from '../../lib/api-keys';
|
|
|
9
9
|
import { attemptDelivery, generateWebhookSecret, validateWebhookUrl } from '../../lib/webhooks';
|
|
10
10
|
import { resolveConfig } from '../../lib/email/config';
|
|
11
11
|
import { env } from '../../lib/env';
|
|
12
|
+
import { mapsStatus, MAPS_SETUP } from '../../lib/maps/config';
|
|
13
|
+
import { PROVIDERS } from '../../lib/maps/providers';
|
|
12
14
|
|
|
13
15
|
// Site-wide integrations. Owners manage keys and webhook subscriptions;
|
|
14
16
|
// calendar-specific feeds and API help appear only when enabled.
|
|
@@ -79,6 +81,8 @@ const ago = (ts: number | null) => {
|
|
|
79
81
|
const s = Math.floor((Date.now() - ts) / 1000);
|
|
80
82
|
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : `${Math.floor(s / 86400)} d ago`;
|
|
81
83
|
};
|
|
84
|
+
const maps = mapsStatus(Astro.locals);
|
|
85
|
+
const mapsNeeds = maps.provider ? (PROVIDERS[maps.provider].selfHosted ? 'MAPS_URL' : 'MAPS_API_KEY') : '';
|
|
82
86
|
const SCOPE_LABEL: Record<string, string> = {
|
|
83
87
|
'events:read': 'Read events (incl. private calendars)', 'events:write': 'Create and edit events',
|
|
84
88
|
'bookings:read': 'Read bookings and guest details', 'bookings:write': 'Create, confirm and cancel bookings',
|
|
@@ -179,6 +183,34 @@ const SCOPE_LABEL: Record<string, string> = {
|
|
|
179
183
|
<p class="hint">Choose Resend, Mailgun, Amazon SES or Cloudflare in <a href="/admin/email">Email settings</a>. Configure a sender and review delivery activity there.</p>
|
|
180
184
|
) : <p class="hint">Sending as <code>{emailConfig.config.from}</code>. Guests get confirmations, updates and a reminder the day before.</p>}
|
|
181
185
|
</section>
|
|
186
|
+
{CALENDAR.enabled && <section class="card card-pad int-maps">
|
|
187
|
+
<div class="card-head"><b>Maps</b><span class={`int-status ${maps.enabled ? 'ok' : 'bad'}`}>{maps.enabled ? `${maps.label} · Connected` : maps.provider ? `${maps.label} · Not set up` : 'Not set up'}</span></div>
|
|
188
|
+
{maps.enabled ? (
|
|
189
|
+
<ul class="int-list">
|
|
190
|
+
<li><b>Address suggestions</b> while typing an event’s place</li>
|
|
191
|
+
<li><b>{maps.staticMaps ? 'Map images' : 'No map images'}</b> {maps.staticMaps ? 'on event pages, made once per place' : `— ${maps.label} doesn’t draw maps`}</li>
|
|
192
|
+
</ul>
|
|
193
|
+
) : (
|
|
194
|
+
<p class="hint">Bring your own maps provider for address suggestions and a map on event pages. Without one, saved places and pasted map links still work.</p>
|
|
195
|
+
)}
|
|
196
|
+
{!maps.enabled && maps.provider && <p class="hint">Set <code>{mapsNeeds}</code> in the environment to finish connecting {maps.label}.</p>}
|
|
197
|
+
<details class="int-log">
|
|
198
|
+
<summary>{maps.enabled ? 'Change provider' : 'How to set it up'}</summary>
|
|
199
|
+
<p class="hint">Name the provider in <code>astro.config</code> and put the key in the environment (a Worker secret or your server’s <code>.env</code>). Keys stay on the server.</p>
|
|
200
|
+
<pre class="int-code">{`acms({ maps: { provider: 'geoapify' } })
|
|
201
|
+
MAPS_API_KEY=…`}</pre>
|
|
202
|
+
<ul class="int-providers">
|
|
203
|
+
{MAPS_SETUP.map((p) => <li><b>{PROVIDERS[p.id].label}</b> <code>{p.id}</code> · <code>{p.env}</code>{PROVIDERS[p.id].staticMaps ? '' : ' · suggestions only'}</li>)}
|
|
204
|
+
</ul>
|
|
205
|
+
<p class="hint">Self-hosted Photon, Nominatim or Pelias: set <code>MAPS_URL</code> to your server. Don’t use the public nominatim.openstreetmap.org — its rules forbid suggestions-as-you-type.</p>
|
|
206
|
+
</details>
|
|
207
|
+
{maps.enabled && (
|
|
208
|
+
<div class="int-test">
|
|
209
|
+
<button class="btn sm" type="button" data-maps-test>Test</button>
|
|
210
|
+
<span class="hint" data-maps-result role="status"></span>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</section>}
|
|
182
214
|
{CALENDAR.enabled && <section class="card card-pad">
|
|
183
215
|
<div class="card-head"><b>Calendar share links</b></div>
|
|
184
216
|
<p class="hint">The .ics links people paste into Google, Apple or Outlook live in one place.</p>
|
|
@@ -218,7 +250,32 @@ const SCOPE_LABEL: Record<string, string> = {
|
|
|
218
250
|
</Modal>
|
|
219
251
|
</Admin>
|
|
220
252
|
|
|
253
|
+
<style>
|
|
254
|
+
.int-maps .card-head { flex-wrap: wrap; gap: 6px 10px; }
|
|
255
|
+
.int-list { margin: 0 0 10px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-2); }
|
|
256
|
+
.int-list li::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 2px; background: var(--green); margin-right: 8px; vertical-align: 2px; }
|
|
257
|
+
.int-code { margin: 8px 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); font-size: 12px; overflow-x: auto; white-space: pre; }
|
|
258
|
+
.int-providers { margin: 6px 0 8px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; color: var(--text-2); }
|
|
259
|
+
.int-providers code { font-size: 11.5px; }
|
|
260
|
+
.int-test { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
|
|
261
|
+
.int-test .hint { min-width: 0; overflow-wrap: anywhere; }
|
|
262
|
+
</style>
|
|
263
|
+
|
|
221
264
|
<script>
|
|
265
|
+
import { actions } from 'astro:actions';
|
|
266
|
+
|
|
267
|
+
// Maps → Test: one real suggestion request through the configured provider
|
|
268
|
+
document.querySelector<HTMLButtonElement>('[data-maps-test]')?.addEventListener('click', async (e) => {
|
|
269
|
+
const b = e.currentTarget as HTMLButtonElement;
|
|
270
|
+
const out = document.querySelector<HTMLElement>('[data-maps-result]')!;
|
|
271
|
+
b.disabled = true;
|
|
272
|
+
out.textContent = 'Searching for “Eiffel Tower”…';
|
|
273
|
+
const { data, error } = await actions.maps.search({ q: 'Eiffel Tower, Paris' });
|
|
274
|
+
b.disabled = false;
|
|
275
|
+
const first = data?.results[0];
|
|
276
|
+
out.textContent = error ? error.message : first ? `Working — first result: ${[first.name, first.address].filter(Boolean).join(', ')}` : 'The provider answered, but found nothing. Check the key’s permissions.';
|
|
277
|
+
});
|
|
278
|
+
|
|
222
279
|
document.addEventListener('click', async (e) => {
|
|
223
280
|
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
224
281
|
if (c && !confirm(c.dataset.confirm)) { e.preventDefault(); return; }
|
|
@@ -16,6 +16,6 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
|
16
16
|
try {
|
|
17
17
|
const repo = await getRepo(locals);
|
|
18
18
|
const agent = await requireAgent(request, repo);
|
|
19
|
-
return json(await runAgentTool(repo, await readJson(request), agent.allowPublish, { origin: url.origin, waitUntil: waitUntilFrom(locals) }));
|
|
19
|
+
return json(await runAgentTool(repo, await readJson(request), agent.allowPublish, { origin: url.origin, waitUntil: waitUntilFrom(locals), locals }));
|
|
20
20
|
} catch (error) { return apiError(error); }
|
|
21
21
|
};
|
|
@@ -26,6 +26,8 @@ POST /api/calendar/events (events:write) → creates a DRAFT
|
|
|
26
26
|
"content": "Markdown…", "excerpt": "…", "location": { "name": "Back lawn", "address": "…", "url": "" },
|
|
27
27
|
"cta": { "label": "Volunteer", "href": "https://…", "style": "primary" },
|
|
28
28
|
"rrule": "FREQ=WEEKLY;BYDAY=TU", "calendars": ["community"], "tags": ["family"], "publish": false }
|
|
29
|
+
location also takes "lat"/"lng" (a pin: map image + GEO in feeds), "placeId" (a saved place,
|
|
30
|
+
copied in) and "showMap": false (hide the map image). Unchanged text on PATCH keeps the pin.
|
|
29
31
|
PATCH /api/calendar/events/<id|slug> (events:write) same fields; live events stage edits unless "publish": true
|
|
30
32
|
DELETE /api/calendar/events/<id|slug> (events:write)
|
|
31
33
|
GET /api/calendar/calendars → calendars + subscribe feed URLs
|
|
@@ -43,7 +43,7 @@ export const PATCH: APIRoute = async ({ request, locals, url, params }) => {
|
|
|
43
43
|
const e = await find(repo, params.id!);
|
|
44
44
|
if (!e) throw new ApiError(404, 'Event not found.');
|
|
45
45
|
const { publish, ...body } = (await readJson(request)) as Record<string, unknown>;
|
|
46
|
-
const ctx = { origin: siteOrigin(await repo.getSettings(), url), waitUntil: waitUntilFrom(locals) };
|
|
46
|
+
const ctx = { origin: siteOrigin(await repo.getSettings(), url), waitUntil: waitUntilFrom(locals), locals };
|
|
47
47
|
if (Object.keys(body).length) await runAgentTool(repo, { tool: 'update_event', arguments: { ...body, id: e.id } }, true, ctx);
|
|
48
48
|
if (publish === true) await runAgentTool(repo, { tool: 'publish_event', arguments: { id: e.id } }, true, ctx);
|
|
49
49
|
return withCors(json(await runAgentTool(repo, { tool: 'get_event', arguments: { id: e.id } }, true, ctx)));
|
|
@@ -102,7 +102,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
|
102
102
|
const repo = await getRepo(locals);
|
|
103
103
|
await requireScope(request, repo, 'events:write');
|
|
104
104
|
const { publish, ...body } = (await readJson(request)) as Record<string, unknown>;
|
|
105
|
-
const ctx = { origin: siteOrigin(await repo.getSettings(), url), waitUntil: waitUntilFrom(locals) };
|
|
105
|
+
const ctx = { origin: siteOrigin(await repo.getSettings(), url), waitUntil: waitUntilFrom(locals), locals };
|
|
106
106
|
const created = (await runAgentTool(repo, { tool: 'create_event', arguments: body }, true, ctx)) as { id: string };
|
|
107
107
|
if (publish === true) await runAgentTool(repo, { tool: 'publish_event', arguments: { id: created.id } }, true, ctx);
|
|
108
108
|
return withCors(json(await runAgentTool(repo, { tool: 'get_event', arguments: { id: created.id } }, true, ctx), 201));
|
package/src/pages/api/mcp.ts
CHANGED
|
@@ -43,7 +43,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
43
43
|
if (!params.success) return error(-32602, 'Invalid tool parameters');
|
|
44
44
|
if (!agentTools(agent.allowPublish, agent.allowSections).some((t) => t.name === params.data.name)) return error(-32602, 'Unknown or unavailable tool');
|
|
45
45
|
try {
|
|
46
|
-
const value = await runAgentTool(repo, { tool: params.data.name, arguments: params.data.arguments }, agent.allowPublish, { origin: new URL(request.url).origin, waitUntil: waitUntilFrom(locals) });
|
|
46
|
+
const value = await runAgentTool(repo, { tool: params.data.name, arguments: params.data.arguments }, agent.allowPublish, { origin: new URL(request.url).origin, waitUntil: waitUntilFrom(locals), locals });
|
|
47
47
|
return result({ content: [{ type: 'text', text: JSON.stringify(value) }], isError: false });
|
|
48
48
|
} catch (cause) {
|
|
49
49
|
const response = await apiError(cause).json() as { error: string };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../../../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../../../lib/repo';
|
|
4
|
-
import { bookingLinkUrl, bookingsEnabled, eventUrl, resourceUrl } from '../../../lib/calendar/config';
|
|
4
|
+
import { CALENDAR, bookingLinkUrl, bookingsEnabled, eventUrl, resourceUrl } from '../../../lib/calendar/config';
|
|
5
5
|
import { setBookingStatus, BookingError } from '../../../lib/booking/service';
|
|
6
6
|
import { bookingIcs, whenText } from '../../../lib/booking/emails';
|
|
7
7
|
import { resourceZone } from '../../../lib/booking/availability';
|
|
@@ -12,10 +12,10 @@ import { waitUntilFrom } from '../../../lib/webhooks';
|
|
|
12
12
|
// The booker's own page, reached from the confirmation and every email: see
|
|
13
13
|
// the status, add it to a calendar, move it or cancel it. The token in the
|
|
14
14
|
// URL is the only credential — it's long and random, and never listed.
|
|
15
|
-
if (!
|
|
15
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
16
16
|
const repo = await getRepo(Astro.locals);
|
|
17
17
|
const booking = await repo.getBookingByToken(Astro.params.token ?? '');
|
|
18
|
-
if (!booking) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
18
|
+
if (!booking || (!bookingsEnabled() && booking.kind !== 'rsvp')) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
19
19
|
const settings = await repo.getSettings();
|
|
20
20
|
const origin = siteOrigin(settings, Astro.url);
|
|
21
21
|
const [resource, link, event] = await Promise.all([
|
|
@@ -55,7 +55,7 @@ const heading = current.status === 'cancelled' ? 'Booking cancelled'
|
|
|
55
55
|
: current.status === 'pending' ? (isNew ? 'Request sent' : 'Waiting for approval')
|
|
56
56
|
: moved ? 'Booking moved' : isNew ? 'You’re booked' : 'Your booking';
|
|
57
57
|
const lede = current.status === 'pending' ? `We’ll email ${current.email} once it’s approved.`
|
|
58
|
-
: current.status === 'confirmed' ? (link?.confirmMessage || `A confirmation is on its way to ${current.email}.`)
|
|
58
|
+
: current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || `A confirmation is on its way to ${current.email}.`)
|
|
59
59
|
: current.status === 'declined' ? 'Sorry — this time couldn’t be confirmed. You’re welcome to pick another.'
|
|
60
60
|
: 'This booking is cancelled.';
|
|
61
61
|
const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchParams({ action: 'TEMPLATE', text: title, dates: `${new Date(current.start).toISOString().replace(/[-:]|\.\d{3}/g, '')}/${new Date(current.end).toISOString().replace(/[-:]|\.\d{3}/g, '')}`, details: url, location })}`;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { ApiError } from '../../lib/api';
|
|
3
|
+
import { starter } from '../../lib/forms/schema';
|
|
4
|
+
import { formVersion } from '../../lib/forms/service';
|
|
5
|
+
import { renderFields, hiddenFields } from '../../lib/forms/render';
|
|
6
|
+
import { readForm, readAnswers, submissionGuard } from '../../lib/forms/http';
|
|
7
|
+
import { submitEventForm, prepareLegacyEventForm } from '../../lib/forms/events';
|
|
2
8
|
import { requestLocale } from '../../lib/i18n';
|
|
3
9
|
import { localizeDoc, isExtraLocale } from '../../lib/translations';
|
|
4
10
|
import Site from '../../layouts/Site.astro';
|
|
@@ -14,7 +20,7 @@ import { CALENDAR, eventUrl } from '../../lib/calendar/config';
|
|
|
14
20
|
import { eventIsPublic, featuredOccurrence } from '../../lib/calendar/events';
|
|
15
21
|
import { eventJsonLd } from '../../lib/calendar/seo';
|
|
16
22
|
import { upcomingEvents } from '../../lib/calendar/cards';
|
|
17
|
-
import {
|
|
23
|
+
import { seatsTaken, BookingError } from '../../lib/booking/service';
|
|
18
24
|
import { manageUrl } from '../../lib/calendar/config';
|
|
19
25
|
import { waitUntilFrom } from '../../lib/webhooks';
|
|
20
26
|
|
|
@@ -40,21 +46,29 @@ if (!occurrence) return new Response(null, { status: 404, statusText: 'Not found
|
|
|
40
46
|
const origin = siteOrigin(settings, Astro.url);
|
|
41
47
|
// RSVP: "Save my seat" posts back here and lands on the manage page
|
|
42
48
|
let rsvpError = '';
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
let formValues: Record<string,string> = {};
|
|
50
|
+
let coreValues = { name: '', email: '', seats: '1' };
|
|
51
|
+
let requestId: string = crypto.randomUUID();
|
|
52
|
+
const definition = event.rsvp.form ?? starter('simple');
|
|
53
|
+
if (event.rsvp.enabled) Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
54
|
+
if (Astro.request.method === 'POST' && event.rsvp.enabled) {
|
|
45
55
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
if (!eventIsPublic(event, calMap)) throw new ApiError(404, 'Event not found.');
|
|
57
|
+
const form = await readForm(Astro.request);
|
|
58
|
+
await prepareLegacyEventForm(event, occurrence.start, form);
|
|
59
|
+
requestId = await submissionGuard(repo, form, Astro.clientAddress, `event:${event.id}`);
|
|
60
|
+
formValues = readAnswers(form, definition);
|
|
61
|
+
coreValues = { name: String(form.get('name') || '').slice(0,120), email: String(form.get('email') || '').slice(0,200), seats: String(form.get('seats') || '1') };
|
|
62
|
+
const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
|
|
63
|
+
return Astro.redirect(`${manageUrl(booking.manageToken)}?new=1`, 303);
|
|
51
64
|
} catch (err) {
|
|
52
|
-
rsvpError = err instanceof BookingError ? err.message : 'Couldn’t save your RSVP. Please try again.';
|
|
65
|
+
rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : 'Couldn’t save your RSVP. Please try again.';
|
|
53
66
|
}
|
|
54
67
|
}
|
|
68
|
+
const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
|
|
55
69
|
const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
|
|
56
70
|
const rsvp = event.rsvp.enabled
|
|
57
|
-
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, open: occurrence.end > now && !event.cancelled }
|
|
71
|
+
? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues, open: occurrence.end > now && !event.cancelled }
|
|
58
72
|
: null;
|
|
59
73
|
const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
|
|
60
74
|
const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
|