@acms-subir/core 0.3.6 → 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 +27 -0
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +146 -2
- package/src/components/EmailSettings.tsx +6 -0
- package/src/components/EventPage.astro +25 -5
- package/src/components/booking/DurationEditor.astro +156 -0
- 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/forms.ts +67 -0
- 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/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/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 +23 -12
- 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/resources/link.astro +95 -49
- 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/admin/settings.astro +1 -1
- 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/email/settings.ts +4 -2
- 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
|
@@ -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]');
|
|
@@ -9,9 +9,14 @@ import { normalizeCalendarSrc } from '../../../lib/calendar/src';
|
|
|
9
9
|
import { loadFeed } from '../../../lib/calendar/ics';
|
|
10
10
|
import { siteApproval, APPROVAL_LABEL } from '../../../lib/booking/approval';
|
|
11
11
|
import { siteOrigin } from '../../../lib/seo';
|
|
12
|
+
import { placeUsage, savePlace } from '../../../lib/maps/places';
|
|
13
|
+
import { parseMapLink } from '../../../lib/maps/parse';
|
|
14
|
+
import { ApiError } from '../../../lib/api';
|
|
15
|
+
import type { Place } from '../../../lib/schema';
|
|
12
16
|
|
|
13
17
|
// Calendar settings — one page behind the ⚙ on Schedule and Bookings:
|
|
14
|
-
// calendars (groups + colors),
|
|
18
|
+
// calendars (groups + colors), saved places (picked in an event's Where),
|
|
19
|
+
// subscribed calendars (read-only, synced in),
|
|
15
20
|
// share links (out to Google/Apple/Outlook), booking approval, defaults.
|
|
16
21
|
// Replaces the old Calendars page and the duplicate subscribe dialogs.
|
|
17
22
|
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
@@ -77,6 +82,34 @@ if (Astro.request.method === 'POST') {
|
|
|
77
82
|
}
|
|
78
83
|
return back(reset ? `Approval saved — ${reset} booking link${reset === 1 ? '' : 's'} now follow it.` : 'Approval saved.', '#bookings');
|
|
79
84
|
}
|
|
85
|
+
if (op === 'place-save') {
|
|
86
|
+
// the map link field is parsed in the browser (maps.resolve, short links
|
|
87
|
+
// included); without JS, a full map link is still read here
|
|
88
|
+
const num = (k: string) => { const v = String(form.get(k) ?? '').trim(); return v === '' ? null : Number(v); };
|
|
89
|
+
let lat = num('lat');
|
|
90
|
+
let lng = num('lng');
|
|
91
|
+
const url = String(form.get('url') || '').trim();
|
|
92
|
+
let address = String(form.get('address') || '');
|
|
93
|
+
if ((lat === null || lng === null) && url) {
|
|
94
|
+
const parsed = parseMapLink(url);
|
|
95
|
+
if (parsed?.lat !== null && parsed?.lat !== undefined) { lat = parsed.lat; lng = parsed.lng; }
|
|
96
|
+
if (!address.trim() && parsed?.address) address = parsed.address;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const { place, updatedEvents } = await savePlace(repo, {
|
|
100
|
+
id: id || undefined, name, address, url, lat: Number.isFinite(lat) ? lat : null, lng: Number.isFinite(lng) ? lng : null,
|
|
101
|
+
notes: String(form.get('notes') || ''), isDefault: form.has('isDefault'),
|
|
102
|
+
}, { locals: Astro.locals, origin });
|
|
103
|
+
return back(`Saved “${place.name}”${updatedEvents ? ` — ${updatedEvents} upcoming event${updatedEvents === 1 ? '' : 's'} updated` : ''}.`, '#places');
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return back(err instanceof ApiError ? err.message : 'Couldn’t save that place.', '#places');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (op === 'place-delete') {
|
|
109
|
+
const place = id ? await repo.getPlace(id) : null;
|
|
110
|
+
if (place) await repo.deletePlace(place.id);
|
|
111
|
+
return back(place ? `Deleted “${place.name}”. Events that used it keep their address.` : undefined, '#places');
|
|
112
|
+
}
|
|
80
113
|
if (op === 'create' || op === 'subscribe') {
|
|
81
114
|
if (!name) return back('Give the calendar a name.');
|
|
82
115
|
let feedUrl = '';
|
|
@@ -122,7 +155,9 @@ if (Astro.request.method === 'POST') {
|
|
|
122
155
|
}
|
|
123
156
|
}
|
|
124
157
|
|
|
125
|
-
const [calendars, events, links] = await Promise.all([repo.listCalendars(), repo.listEvents(), bookingsEnabled() ? repo.listBookingLinks() : Promise.resolve([])]);
|
|
158
|
+
const [calendars, events, links, places] = await Promise.all([repo.listCalendars(), repo.listEvents(), bookingsEnabled() ? repo.listBookingLinks() : Promise.resolve([]), repo.listPlaces()]);
|
|
159
|
+
const placeCounts = placeUsage(events, settings);
|
|
160
|
+
const placeLine = (p: Place) => [p.address, `${placeCounts.get(p.id) ?? 0} upcoming event${placeCounts.get(p.id) === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
|
|
126
161
|
const natives = calendars.filter((c) => c.kind === 'native');
|
|
127
162
|
const feeds = calendars.filter((c) => c.kind === 'feed');
|
|
128
163
|
const counts = new Map<string, number>();
|
|
@@ -148,9 +183,10 @@ const APPROVAL_NOTE: Record<ApprovalMode, string> = {
|
|
|
148
183
|
const I = {
|
|
149
184
|
up: 'M6 15l6-6 6 6', down: 'M6 9l6 6 6-6',
|
|
150
185
|
ext: 'M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
|
186
|
+
pin: 'M12 22s7-7 7-12a7 7 0 0 0-14 0c0 5 7 12 7 12zM12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
|
|
151
187
|
};
|
|
152
188
|
const sections = [
|
|
153
|
-
['calendars', 'Calendars'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
|
|
189
|
+
['calendars', 'Calendars'], ['places', 'Places'], ['subscribed', 'Subscribed calendars'], ['share', 'Share links'],
|
|
154
190
|
...(bookingsEnabled() ? [['bookings', 'Bookings']] : []), ['defaults', 'Defaults'],
|
|
155
191
|
] as const;
|
|
156
192
|
---
|
|
@@ -204,6 +240,28 @@ const sections = [
|
|
|
204
240
|
))}
|
|
205
241
|
</section>
|
|
206
242
|
|
|
243
|
+
<!-- Places -->
|
|
244
|
+
<section class="cs-sect" id="places">
|
|
245
|
+
<header>
|
|
246
|
+
<div><h2>Places</h2><p>Where your events usually happen. Pick one in an event’s Where field to fill in the address and map. Changing a place updates its upcoming events.</p></div>
|
|
247
|
+
<button class="btn" type="button" data-modal="place-new">+ New place</button>
|
|
248
|
+
</header>
|
|
249
|
+
{places.length === 0 ? (
|
|
250
|
+
<p class="cs-empty">No saved places yet. Add your building, a park or a partner venue once instead of typing it into every event.</p>
|
|
251
|
+
) : places.map((p) => (
|
|
252
|
+
<div class="cs-row" id={`place-${p.id}`}>
|
|
253
|
+
<svg class="cs-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.pin} /></svg>
|
|
254
|
+
<div class="cs-main">
|
|
255
|
+
<b>{p.name}{p.isDefault && <span class="cs-default">Default</span>}</b>
|
|
256
|
+
<small>{placeLine(p)}{p.lat === null ? ' · no pin' : ''}</small>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="cs-actions">
|
|
259
|
+
<button class="btn sm" type="button" data-modal={`place-edit-${p.id}`}>Edit</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
))}
|
|
263
|
+
</section>
|
|
264
|
+
|
|
207
265
|
<!-- Subscribed -->
|
|
208
266
|
<section class="cs-sect" id="subscribed">
|
|
209
267
|
<header>
|
|
@@ -347,6 +405,37 @@ const sections = [
|
|
|
347
405
|
</Modal>
|
|
348
406
|
))}
|
|
349
407
|
|
|
408
|
+
{[...places, null].map((p: Place | null) => (
|
|
409
|
+
<Modal id={p ? `place-edit-${p.id}` : 'place-new'} title={p ? `Edit ${p.name}` : 'New place'}>
|
|
410
|
+
<form method="post" data-place-form>
|
|
411
|
+
<input type="hidden" name="op" value="place-save" />
|
|
412
|
+
{p && <input type="hidden" name="id" value={p.id} />}
|
|
413
|
+
<input type="hidden" name="lat" value={p?.lat ?? ''} data-lat />
|
|
414
|
+
<input type="hidden" name="lng" value={p?.lng ?? ''} data-lng />
|
|
415
|
+
<label class="field"><span>Map link <em class="hint">paste from Google Maps, Apple Maps or OpenStreetMap — or a map embed code</em></span>
|
|
416
|
+
<input class="mono" type="text" name="url" value={p?.url ?? ''} placeholder="https://maps.app.goo.gl/…" data-maplink autocomplete="off" />
|
|
417
|
+
</label>
|
|
418
|
+
<p class="cs-pinline" data-pinline>
|
|
419
|
+
<span data-pintext>{p?.lat != null && p?.lng != null ? `Pin set at ${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}` : 'No pin yet — paste a map link to set one.'}</span>
|
|
420
|
+
<button class="btn sm ghost" type="button" data-clearpin hidden={p?.lat == null}>Remove pin</button>
|
|
421
|
+
</p>
|
|
422
|
+
<label class="field"><span>Name</span><input type="text" name="name" value={p?.name ?? ''} placeholder="Main building" required data-name /></label>
|
|
423
|
+
<label class="field"><span>Address</span><input type="text" name="address" value={p?.address ?? ''} placeholder="Street, city" data-address /></label>
|
|
424
|
+
<label class="field"><span>Notes <em class="hint">for editors only</em></span><textarea name="notes" rows="2" placeholder="Use the side door after 6 PM">{p?.notes ?? ''}</textarea></label>
|
|
425
|
+
<label class="check-row"><input type="checkbox" name="isDefault" checked={p?.isDefault ?? false} /> New events start here</label>
|
|
426
|
+
{p?.mapImage && (
|
|
427
|
+
<figure class="cs-map"><img src={p.mapImage} alt={`Map of ${p.name}`} width="1200" height="500" loading="lazy" /></figure>
|
|
428
|
+
)}
|
|
429
|
+
<div class="modal-foot">
|
|
430
|
+
{p && <button class="btn danger ghost" type="submit" name="op" value="place-delete" formnovalidate data-confirm={`Delete “${p.name}”? Events that used it keep their address.`}>Delete</button>}
|
|
431
|
+
<span class="spacer" />
|
|
432
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
433
|
+
<button class="btn primary" type="submit">{p ? 'Save' : 'Add place'}</button>
|
|
434
|
+
</div>
|
|
435
|
+
</form>
|
|
436
|
+
</Modal>
|
|
437
|
+
))}
|
|
438
|
+
|
|
350
439
|
<Modal id="cal-new" title="New calendar" open={Astro.url.searchParams.has('new')}>
|
|
351
440
|
<form method="post">
|
|
352
441
|
<input type="hidden" name="op" value="create" />
|
|
@@ -424,6 +513,14 @@ const sections = [
|
|
|
424
513
|
.cs-field { display: grid; grid-template-columns: 160px auto; align-items: center; gap: 10px; font-size: 13px; color: var(--text-2); justify-content: start; }
|
|
425
514
|
.cs-foot { display: flex; align-items: center; gap: 12px; justify-content: space-between; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 12px; }
|
|
426
515
|
.cs-foot small { color: var(--muted); font-size: 12px; }
|
|
516
|
+
.cs-pin { width: 14px; height: 14px; color: var(--muted); }
|
|
517
|
+
.cs-default { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; font-size: 11.5px; font-weight: 600; color: var(--text-2); vertical-align: 1px; }
|
|
518
|
+
.cs-default::before { content: ''; width: 6px; height: 6px; border-radius: 2px; background: var(--accent); }
|
|
519
|
+
.cs-pinline { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: -4px 0 12px; font-size: 12px; color: var(--muted); }
|
|
520
|
+
.cs-pinline.is-busy span { opacity: 0.6; }
|
|
521
|
+
.cs-pinline.is-error span { color: var(--red); }
|
|
522
|
+
.cs-map { margin: 0 0 4px; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
523
|
+
.cs-map img { display: block; width: 100%; height: auto; }
|
|
427
524
|
[hidden] { display: none !important; }
|
|
428
525
|
@media (max-width: 860px) {
|
|
429
526
|
.cs { grid-template-columns: 1fr; }
|
|
@@ -437,6 +534,8 @@ const sections = [
|
|
|
437
534
|
</style>
|
|
438
535
|
|
|
439
536
|
<script>
|
|
537
|
+
import { actions } from 'astro:actions';
|
|
538
|
+
|
|
440
539
|
document.addEventListener('click', async (e) => {
|
|
441
540
|
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
442
541
|
if (b) {
|
|
@@ -448,6 +547,44 @@ const sections = [
|
|
|
448
547
|
const c = e.target instanceof Element ? e.target.closest<HTMLElement>('[data-confirm]') : null;
|
|
449
548
|
if (c && !confirm(c.dataset.confirm)) e.preventDefault();
|
|
450
549
|
});
|
|
550
|
+
// places: a pasted map link fills the pin (and name/address when empty).
|
|
551
|
+
// maps.resolve expands short links server-side; nothing needs a maps key.
|
|
552
|
+
for (const f of document.querySelectorAll<HTMLFormElement>('form[data-place-form]')) {
|
|
553
|
+
const link = f.querySelector<HTMLInputElement>('[data-maplink]')!;
|
|
554
|
+
const lat = f.querySelector<HTMLInputElement>('[data-lat]')!;
|
|
555
|
+
const lng = f.querySelector<HTMLInputElement>('[data-lng]')!;
|
|
556
|
+
const line = f.querySelector<HTMLElement>('[data-pinline]')!;
|
|
557
|
+
const text = f.querySelector<HTMLElement>('[data-pintext]')!;
|
|
558
|
+
const clear = f.querySelector<HTMLButtonElement>('[data-clearpin]')!;
|
|
559
|
+
const name = f.querySelector<HTMLInputElement>('[data-name]')!;
|
|
560
|
+
const address = f.querySelector<HTMLInputElement>('[data-address]')!;
|
|
561
|
+
const show = (msg: string, state = '') => { text.textContent = msg; line.className = `cs-pinline${state ? ` is-${state}` : ''}`; clear.hidden = !lat.value; };
|
|
562
|
+
let last = link.value.trim();
|
|
563
|
+
const resolve = async () => {
|
|
564
|
+
const raw = link.value.trim();
|
|
565
|
+
if (raw === last) return;
|
|
566
|
+
last = raw;
|
|
567
|
+
if (!raw) return;
|
|
568
|
+
show('Reading the link…', 'busy');
|
|
569
|
+
const { data, error } = await actions.maps.resolve({ url: raw });
|
|
570
|
+
if (link.value.trim() !== raw) return;
|
|
571
|
+
const loc = data?.location;
|
|
572
|
+
if (error || !loc) { show('That link didn’t include a place we could read. You can still type the address.', 'error'); return; }
|
|
573
|
+
if (loc.kind === 'meeting') { show('That’s an online meeting link — saved places are for physical places.', 'error'); return; }
|
|
574
|
+
// an embed's src isn't a link people can open: keep the field tidy
|
|
575
|
+
if (loc.url !== raw) { link.value = loc.url; last = loc.url; }
|
|
576
|
+
if (!name.value.trim() && loc.name) name.value = loc.name;
|
|
577
|
+
if (!address.value.trim() && loc.address) address.value = loc.address;
|
|
578
|
+
if (loc.lat !== null && loc.lng !== null) {
|
|
579
|
+
lat.value = String(loc.lat); lng.value = String(loc.lng);
|
|
580
|
+
show(`Pin set at ${loc.lat.toFixed(5)}, ${loc.lng.toFixed(5)}`);
|
|
581
|
+
} else show('No pin in that link — the address was filled in.');
|
|
582
|
+
};
|
|
583
|
+
link.addEventListener('paste', () => setTimeout(resolve, 0));
|
|
584
|
+
link.addEventListener('change', resolve);
|
|
585
|
+
clear.addEventListener('click', () => { lat.value = ''; lng.value = ''; show('Pin removed — it’s gone when you save.'); });
|
|
586
|
+
}
|
|
587
|
+
|
|
451
588
|
// approval: rules only for "unless…", the deadline only when bookings can wait
|
|
452
589
|
const form = document.querySelector<HTMLFormElement>('[data-approval]');
|
|
453
590
|
form?.addEventListener('change', () => {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import FormHeader from '../../../../components/forms/FormHeader.astro';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { readForm } from '../../../../lib/forms/http';
|
|
6
|
+
import { escapeForm } from '../../../../lib/forms/fields';
|
|
7
|
+
import { siteOrigin } from '../../../../lib/seo';
|
|
8
|
+
import { CONTENT } from '../../../../lib/content';
|
|
9
|
+
import '../../../../styles/forms-admin.css';
|
|
10
|
+
|
|
11
|
+
const repo = await getRepo(Astro.locals);
|
|
12
|
+
let form = await repo.forms.get(Astro.params.id || '');
|
|
13
|
+
if (!form || form.kind !== 'form') return new Response(null, { status: 404 });
|
|
14
|
+
Astro.response.headers.set('Cache-Control', 'private, no-store');
|
|
15
|
+
|
|
16
|
+
let error = '', notice = '';
|
|
17
|
+
if (Astro.request.method === 'POST') {
|
|
18
|
+
try {
|
|
19
|
+
const data = await readForm(Astro.request);
|
|
20
|
+
const op = String(data.get('op') || '');
|
|
21
|
+
if (op !== 'publish' && op !== 'archive') throw new Error('Unknown action.');
|
|
22
|
+
// Same store operation as the Build tab: publish promotes pending edits.
|
|
23
|
+
await repo.forms.save(form.id, Number(data.get('revision')), form.name, form.draft ?? form.definition, op);
|
|
24
|
+
form = (await repo.forms.get(form.id))!;
|
|
25
|
+
notice = op === 'publish' ? 'Published. The form is accepting responses.' : 'Archived. The form no longer accepts responses; existing responses are kept.';
|
|
26
|
+
} catch (e) { error = e instanceof Error ? e.message : 'Could not update the form.'; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [settings, stats, used] = await Promise.all([repo.getSettings(), repo.forms.submissionStats(form.id, 0), repo.forms.usedOn(form.id)]);
|
|
30
|
+
const origin = siteOrigin(settings, Astro.url);
|
|
31
|
+
const link = `${origin}/forms/${form.id}`;
|
|
32
|
+
const title = escapeForm(form.definition.title || form.name);
|
|
33
|
+
const embed = `<iframe src="${link}?embed=1" title="${title}" loading="lazy" style="width:100%;min-height:560px;border:0"></iframe>`;
|
|
34
|
+
const live = form.status === 'published';
|
|
35
|
+
const state = live ? (form.draft ? ['Accepting responses', 'green', 'Visitors see the last published version. Publish to apply your changes.'] : ['Accepting responses', 'green', 'Anyone with the link, or on a page with this form, can respond.'])
|
|
36
|
+
: form.status === 'archived' ? ['Not accepting responses', 'muted', 'Archived. The link and embedded copies are hidden. Responses are kept.']
|
|
37
|
+
: ['Not accepting responses', 'amber', 'Publish the form to open the link and show it on pages.'];
|
|
38
|
+
const editHref = (u: (typeof used)[number]) => u.kind === 'page' ? `/admin/pages/${u.id}` : u.kind === 'event' ? `/admin/calendar/events/${u.id}` : `/admin/c/${u.collection}/${u.id}`;
|
|
39
|
+
const kindLabel = (u: (typeof used)[number]) => u.kind === 'page' ? 'Page' : u.kind === 'event' ? 'Event' : (CONTENT.collections[u.collection]?.singular ?? 'Post');
|
|
40
|
+
const copyIcon = 'M9 9h10v10H9zM5 15V5h10';
|
|
41
|
+
---
|
|
42
|
+
<Admin title={`${form.name} · Share`} section="forms">
|
|
43
|
+
<FormHeader form={form} active="share" responses={stats.total}>
|
|
44
|
+
{live && <a slot="actions" class="btn" href={`/forms/${form.id}`} target="_blank" rel="noopener">Open form ↗</a>}
|
|
45
|
+
</FormHeader>
|
|
46
|
+
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
47
|
+
{notice && <p class="notice" role="status">{notice}</p>}
|
|
48
|
+
|
|
49
|
+
<div class="fs">
|
|
50
|
+
<section class="fs-sec">
|
|
51
|
+
<div class="fs-label"><b>Responses</b><small>Publishing opens the form; archiving closes it.</small></div>
|
|
52
|
+
<div class="fs-body">
|
|
53
|
+
<div class="fs-state">
|
|
54
|
+
<div>
|
|
55
|
+
<p class={`fst fst-${state[1]} fs-state-title`}><i aria-hidden="true" />{state[0]}</p>
|
|
56
|
+
<p class="hint">{state[2]}</p>
|
|
57
|
+
</div>
|
|
58
|
+
<form method="post" class="fs-state-actions">
|
|
59
|
+
<input type="hidden" name="revision" value={form.revision} />
|
|
60
|
+
{(!live || form.draft) && <button class="btn primary" name="op" value="publish">{form.draft ? 'Publish changes' : form.status === 'archived' ? 'Reopen and publish' : 'Publish'}</button>}
|
|
61
|
+
{live && <button class="btn" name="op" value="archive">Stop accepting responses</button>}
|
|
62
|
+
</form>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
|
|
67
|
+
<section class="fs-sec">
|
|
68
|
+
<div class="fs-label"><b>Public link</b><small>A page with just this form.</small></div>
|
|
69
|
+
<div class="fs-body">
|
|
70
|
+
<div class="fs-copy">
|
|
71
|
+
<input type="text" class="mono" readonly value={link} aria-label="Public link" id="fs-link" />
|
|
72
|
+
<button class="btn" type="button" data-copy="fs-link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={copyIcon} /></svg><span>Copy</span></button>
|
|
73
|
+
</div>
|
|
74
|
+
{!live && <p class="hint">The link opens once the form is published.</p>}
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<section class="fs-sec">
|
|
79
|
+
<div class="fs-label"><b>Embed</b><small>Paste into another website. On this site, add a Form block to a page instead.</small></div>
|
|
80
|
+
<div class="fs-body">
|
|
81
|
+
<textarea class="code fs-embed" readonly rows="3" id="fs-embed" aria-label="Embed code">{embed}</textarea>
|
|
82
|
+
<div class="fs-row"><button class="btn" type="button" data-copy="fs-embed"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={copyIcon} /></svg><span>Copy code</span></button></div>
|
|
83
|
+
</div>
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
<section class="fs-sec">
|
|
87
|
+
<div class="fs-label"><b>Used on</b><small>Pages, posts and events with a Form block for this form.</small></div>
|
|
88
|
+
<div class="fs-body">
|
|
89
|
+
{used.length ? (
|
|
90
|
+
<ul class="fs-used">
|
|
91
|
+
{used.map(u => (
|
|
92
|
+
<li>
|
|
93
|
+
<a href={editHref(u)}><b>{u.title || 'Untitled'}</b><small>{kindLabel(u)} · <span class={`status ${u.status}`}>{u.status[0].toUpperCase() + u.status.slice(1)}</span></small></a>
|
|
94
|
+
<a class="btn sm ghost" href={editHref(u)}>Edit</a>
|
|
95
|
+
</li>
|
|
96
|
+
))}
|
|
97
|
+
</ul>
|
|
98
|
+
) : <p class="hint fs-none">Not on any page yet. In the editor, type <kbd>/</kbd> and choose Form.</p>}
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<section class="fs-sec">
|
|
103
|
+
<div class="fs-label"><b>Notifications</b></div>
|
|
104
|
+
<div class="fs-body"><p class="hint">Email alerts and webhooks for new responses are coming later. For now, check Responses or export a CSV.</p></div>
|
|
105
|
+
</section>
|
|
106
|
+
</div>
|
|
107
|
+
</Admin>
|
|
108
|
+
|
|
109
|
+
<script>
|
|
110
|
+
document.addEventListener('click', async (e) => {
|
|
111
|
+
const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
|
|
112
|
+
if (!b) return;
|
|
113
|
+
const el = document.getElementById(b.dataset.copy!) as HTMLInputElement | HTMLTextAreaElement | null;
|
|
114
|
+
if (!el) return;
|
|
115
|
+
const label = b.querySelector('span')!;
|
|
116
|
+
const was = label.textContent;
|
|
117
|
+
try { await navigator.clipboard.writeText(el.value); label.textContent = 'Copied'; }
|
|
118
|
+
catch { el.select(); label.textContent = 'Press ⌘C'; }
|
|
119
|
+
setTimeout(() => (label.textContent = was), 1600);
|
|
120
|
+
});
|
|
121
|
+
</script>
|