@acms-subir/core 0.3.5 → 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 +23 -0
- package/cli/remote.mjs +1 -1
- package/docs/theme-authoring.md +5 -2
- package/integration.mjs +38 -2
- package/package.json +1 -1
- package/src/actions/index.ts +148 -2
- package/src/components/DesignManager.tsx +34 -3
- 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/components/import/ImportExport.tsx +1 -1
- package/src/components/site/SiteLogo.astro +23 -0
- package/src/layouts/Admin.astro +3 -2
- package/src/layouts/Site.astro +5 -4
- 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/design-service.ts +2 -2
- package/src/lib/designDiff.ts +2 -2
- package/src/lib/drafts.ts +11 -2
- 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/import/bundle.ts +1 -0
- package/src/lib/import/export.ts +1 -1
- 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 +3 -3
- package/src/lib/mobile/site.ts +1 -1
- package/src/lib/repo/migrations.ts +11 -0
- package/src/lib/repo/sql.ts +104 -14
- package/src/lib/repo/types.ts +22 -2
- package/src/lib/schema.ts +39 -0
- package/src/lib/site-chrome.ts +1 -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
package/src/lib/repo/sql.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormStore, formInsert, submissionInsert } from '../forms/store';
|
|
1
2
|
import { LiveStore } from '../live/store';
|
|
2
3
|
import { PushStore } from '../push/store';
|
|
3
4
|
import { EditorStore } from '../editor/store';
|
|
@@ -5,7 +6,7 @@ import { LiveConfig } from '../live/types';
|
|
|
5
6
|
import { ApiError } from '../api';
|
|
6
7
|
import { EmailStore, bookingJobs } from '../email/store';
|
|
7
8
|
import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
|
|
8
|
-
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
9
|
+
import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
|
|
9
10
|
import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
|
|
10
11
|
import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
|
|
11
12
|
import { cleanRrule } from '../calendar/rrule';
|
|
@@ -157,7 +158,15 @@ function rowToResource(r: Row): Resource {
|
|
|
157
158
|
capacity: r.capacity === null || r.capacity === undefined ? null : Number(r.capacity),
|
|
158
159
|
amenities: json(r.amenities, []), timezone: r.timezone ?? '', hours: json(r.hours, {}), overrides: json(r.overrides, []),
|
|
159
160
|
busyFeeds: json(r.busy_feeds, []), notifyEmails: json(r.notify_emails, []), visibility: r.visibility ?? 'public',
|
|
160
|
-
showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', order: Number(r.ord ?? 0),
|
|
161
|
+
showOnCalendar: Boolean(r.show_on_calendar), feedToken: r.feed_token ?? '', placeId: r.place_id ?? '', order: Number(r.ord ?? 0),
|
|
162
|
+
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function rowToPlace(r: Row): Place {
|
|
166
|
+
const n = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number(v));
|
|
167
|
+
return Place.parse({
|
|
168
|
+
id: r.id, name: r.name, address: r.address ?? '', url: r.url ?? '', lat: n(r.lat), lng: n(r.lng), notes: r.notes ?? '',
|
|
169
|
+
isDefault: Boolean(r.is_default), mapImage: r.map_image ?? '', mapImageDark: r.map_image_dark ?? '', order: Number(r.ord ?? 0),
|
|
161
170
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
162
171
|
});
|
|
163
172
|
}
|
|
@@ -187,6 +196,7 @@ function rowToBooking(r: Row): Booking {
|
|
|
187
196
|
id: r.id, kind: r.kind ?? 'booking', resourceId: r.resource_id ?? '', linkId: r.link_id ?? '', eventId: r.event_id ?? '',
|
|
188
197
|
status: r.status, start: Number(r.start_at), end: Number(r.end_at), holdStart: Number(r.hold_start), holdEnd: Number(r.hold_end),
|
|
189
198
|
timezone: r.timezone ?? '', name: r.name ?? '', email: r.email ?? '', phone: r.phone ?? '', seats: Number(r.seats ?? 1),
|
|
199
|
+
formResponse: r.form_response ? json(r.form_response, undefined) : undefined,
|
|
190
200
|
answers: json(r.answers, {}), notes: r.notes ?? '', manageToken: r.manage_token ?? '', source: r.source ?? 'web',
|
|
191
201
|
notificationVersion: Number(r.notification_version ?? 0), reminderSentAt: r.reminder_sent_at === null || r.reminder_sent_at === undefined ? null : Number(r.reminder_sent_at),
|
|
192
202
|
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
@@ -248,12 +258,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
248
258
|
private driver!: SqlDriver;
|
|
249
259
|
private ready: Promise<void>;
|
|
250
260
|
readonly email: EmailStore;
|
|
261
|
+
readonly forms: FormStore;
|
|
251
262
|
readonly live: LiveStore;
|
|
252
263
|
readonly push: PushStore;
|
|
253
264
|
readonly editor: EditorStore;
|
|
254
265
|
|
|
255
266
|
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
256
267
|
this.ready = this.init(driver);
|
|
268
|
+
this.forms = new FormStore(async () => { await this.ready; return this.driver; });
|
|
257
269
|
this.live = new LiveStore(async () => { await this.ready; return this.driver; });
|
|
258
270
|
this.push = new PushStore(async () => { await this.ready; return this.driver; });
|
|
259
271
|
this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
|
|
@@ -424,7 +436,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
424
436
|
await this.ready;
|
|
425
437
|
// One SELECT snapshots bookings and their retry ledger together. No UI
|
|
426
438
|
// list limit; include tombstones for deleted bookings as well.
|
|
427
|
-
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','notes','manage_token','source','reminder_sent_at','notification_version','created_at','updated_at'];
|
|
439
|
+
const columns = ['id','kind','resource_id','link_id','event_id','status','start_at','end_at','hold_start','hold_end','timezone','name','email','phone','seats','answers','form_response','notes','manage_token','source','reminder_sent_at','notification_version','created_at','updated_at'];
|
|
428
440
|
const rows = await this.driver.all(`SELECT 'booking' AS type, json_object(${columns.map(c => `'${c}',${c}`).join(',')}) AS data FROM bookings
|
|
429
441
|
UNION ALL SELECT 'request', json_object('keyHash',key_hash,'requestHash',request_hash,'bookingId',booking_id,'createdAt',created_at) FROM mobile_booking_requests`);
|
|
430
442
|
return {
|
|
@@ -931,14 +943,88 @@ export class SqlRepository implements ContentRepository {
|
|
|
931
943
|
}
|
|
932
944
|
|
|
933
945
|
|
|
946
|
+
async setEventLocation(id: string, patch: Parameters<ContentRepository['setEventLocation']>[1]) {
|
|
947
|
+
await this.ready;
|
|
948
|
+
const current = await this.getEvent(id);
|
|
949
|
+
if (!current) return;
|
|
950
|
+
const stmts: SqlStatement[] = [];
|
|
951
|
+
if (patch.live) {
|
|
952
|
+
stmts.push({ sql: 'UPDATE events SET location = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(EventLocation.parse(patch.live)), Date.now(), id] });
|
|
953
|
+
if (patch.notify && current.status === 'published') {
|
|
954
|
+
// same fan-out as updateEvent's location change, minus the time math
|
|
955
|
+
stmts.push(
|
|
956
|
+
{ sql: `UPDATE bookings SET notification_version=notification_version+1 WHERE event_id=? AND kind='rsvp' AND status IN ('pending','confirmed')`, args: [id] },
|
|
957
|
+
...bookingJobs(id, 'updated').map((st) => ({ ...st, sql: st.sql.replace('b.id = ?', `b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')`) })),
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
if (patch.draft && current.draft) {
|
|
962
|
+
const draft = { ...current.draft, location: EventLocation.parse(patch.draft) };
|
|
963
|
+
stmts.push({ sql: 'UPDATE events SET draft = ? WHERE id = ?', args: [JSON.stringify(draft), id] });
|
|
964
|
+
}
|
|
965
|
+
if (!stmts.length) return;
|
|
966
|
+
await this.driver.batch(stmts);
|
|
967
|
+
if (patch.live) await this.syncEventIndex(id);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ---- places ----
|
|
971
|
+
private placeInsert(p: Place): SqlStatement {
|
|
972
|
+
return {
|
|
973
|
+
sql: `INSERT INTO places (id, name, address, url, lat, lng, notes, is_default, map_image, map_image_dark, ord, created_at, updated_at)
|
|
974
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
975
|
+
args: [p.id, p.name, p.address, p.url, p.lat, p.lng, p.notes, p.isDefault ? 1 : 0, p.mapImage, p.mapImageDark, p.order, p.createdAt, p.updatedAt],
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
async listPlaces() {
|
|
979
|
+
await this.ready;
|
|
980
|
+
return (await this.driver.all('SELECT * FROM places ORDER BY ord ASC, name COLLATE NOCASE ASC')).map(rowToPlace);
|
|
981
|
+
}
|
|
982
|
+
async getPlace(id: string) {
|
|
983
|
+
await this.ready;
|
|
984
|
+
const r = await this.first('SELECT * FROM places WHERE id = ?', [id]);
|
|
985
|
+
return r ? rowToPlace(r) : null;
|
|
986
|
+
}
|
|
987
|
+
async createPlace(data: Parameters<ContentRepository['createPlace']>[0]) {
|
|
988
|
+
await this.ready;
|
|
989
|
+
const now = Date.now();
|
|
990
|
+
const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS n FROM places');
|
|
991
|
+
const p = Place.parse({ ...data, id: crypto.randomUUID(), order: Number(next?.n ?? 0), createdAt: now, updatedAt: now });
|
|
992
|
+
await this.driver.batch([
|
|
993
|
+
...(p.isDefault ? [{ sql: 'UPDATE places SET is_default = 0 WHERE is_default = 1', args: [] }] : []),
|
|
994
|
+
this.placeInsert(p),
|
|
995
|
+
]);
|
|
996
|
+
return p;
|
|
997
|
+
}
|
|
998
|
+
async updatePlace(id: string, data: Parameters<ContentRepository['updatePlace']>[1]) {
|
|
999
|
+
await this.ready;
|
|
1000
|
+
const cur = await this.getPlace(id);
|
|
1001
|
+
if (!cur) return;
|
|
1002
|
+
const p = Place.parse({ ...cur, ...data, id, createdAt: cur.createdAt, updatedAt: Date.now() });
|
|
1003
|
+
await this.driver.batch([
|
|
1004
|
+
...(p.isDefault ? [{ sql: 'UPDATE places SET is_default = 0 WHERE id != ? AND is_default = 1', args: [id] }] : []),
|
|
1005
|
+
{
|
|
1006
|
+
sql: `UPDATE places SET name = ?, address = ?, url = ?, lat = ?, lng = ?, notes = ?, is_default = ?, map_image = ?, map_image_dark = ?,
|
|
1007
|
+
ord = ?, updated_at = ? WHERE id = ?`,
|
|
1008
|
+
args: [p.name, p.address, p.url, p.lat, p.lng, p.notes, p.isDefault ? 1 : 0, p.mapImage, p.mapImageDark, p.order, p.updatedAt, id],
|
|
1009
|
+
},
|
|
1010
|
+
]);
|
|
1011
|
+
}
|
|
1012
|
+
async deletePlace(id: string) {
|
|
1013
|
+
await this.ready;
|
|
1014
|
+
await this.driver.batch([
|
|
1015
|
+
{ sql: 'DELETE FROM places WHERE id = ?', args: [id] },
|
|
1016
|
+
{ sql: "UPDATE resources SET place_id = '' WHERE place_id = ?", args: [id] },
|
|
1017
|
+
]);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
934
1020
|
// ---- resources + booking links ----
|
|
935
1021
|
private resourceInsert(r: Resource): SqlStatement {
|
|
936
1022
|
return {
|
|
937
1023
|
sql: `INSERT INTO resources (id, slug, name, kind, description, photo, capacity, amenities, timezone, hours, overrides, busy_feeds,
|
|
938
|
-
notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1024
|
+
notify_emails, visibility, show_on_calendar, feed_token, ord, created_at, updated_at, place_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
939
1025
|
args: [r.id, r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
|
|
940
1026
|
JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
|
|
941
|
-
r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt],
|
|
1027
|
+
r.feedToken || randomToken(), r.order, r.createdAt, r.updatedAt, r.placeId],
|
|
942
1028
|
};
|
|
943
1029
|
}
|
|
944
1030
|
private linkInsert(l: BookingLink): SqlStatement {
|
|
@@ -955,9 +1041,9 @@ export class SqlRepository implements ContentRepository {
|
|
|
955
1041
|
private bookingInsert(b: Booking): SqlStatement {
|
|
956
1042
|
return {
|
|
957
1043
|
sql: `INSERT INTO bookings (id, kind, resource_id, link_id, event_id, status, start_at, end_at, hold_start, hold_end, timezone, name, email, phone,
|
|
958
|
-
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1044
|
+
seats, answers, notes, manage_token, source, reminder_sent_at, created_at, updated_at, notification_version, form_response) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
959
1045
|
args: [b.id, b.kind, b.resourceId, b.linkId, b.eventId, b.status, b.start, b.end, b.holdStart, b.holdEnd, b.timezone, b.name, b.email, b.phone,
|
|
960
|
-
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion],
|
|
1046
|
+
b.seats, JSON.stringify(b.answers), b.notes, b.manageToken, b.source, b.reminderSentAt, b.createdAt, b.updatedAt, b.notificationVersion, b.formResponse ? JSON.stringify(b.formResponse) : null],
|
|
961
1047
|
};
|
|
962
1048
|
}
|
|
963
1049
|
async listResources() {
|
|
@@ -989,10 +1075,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
989
1075
|
const r = Resource.parse({ ...cur, ...data, id, updatedAt: Date.now() });
|
|
990
1076
|
await this.driver.run(
|
|
991
1077
|
`UPDATE resources SET slug = ?, name = ?, kind = ?, description = ?, photo = ?, capacity = ?, amenities = ?, timezone = ?, hours = ?,
|
|
992
|
-
overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, updated_at = ? WHERE id = ?`,
|
|
1078
|
+
overrides = ?, busy_feeds = ?, notify_emails = ?, visibility = ?, show_on_calendar = ?, feed_token = ?, place_id = ?, updated_at = ? WHERE id = ?`,
|
|
993
1079
|
[r.slug, r.name, r.kind, r.description, r.photo, r.capacity, JSON.stringify(r.amenities), r.timezone, JSON.stringify(r.hours),
|
|
994
1080
|
JSON.stringify(r.overrides), JSON.stringify(r.busyFeeds), JSON.stringify(r.notifyEmails), r.visibility, r.showOnCalendar ? 1 : 0,
|
|
995
|
-
r.feedToken, r.updatedAt, id]
|
|
1081
|
+
r.feedToken, r.placeId, r.updatedAt, id]
|
|
996
1082
|
);
|
|
997
1083
|
}
|
|
998
1084
|
async reorderResources(ids: string[]) {
|
|
@@ -1615,7 +1701,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1615
1701
|
// Suppression and re-enabling share the restore transaction. A failed
|
|
1616
1702
|
// restore cannot leave notifications disabled or emit phantom deletes.
|
|
1617
1703
|
{ sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
|
|
1618
|
-
...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records'].map((t) => ({
|
|
1704
|
+
...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records', 'places', 'forms', 'form_submissions'].map((t) => ({
|
|
1619
1705
|
sql: `DELETE FROM ${t}`,
|
|
1620
1706
|
})),
|
|
1621
1707
|
...(content.calendars ?? []).map((c) => ({
|
|
@@ -1635,7 +1721,10 @@ export class SqlRepository implements ContentRepository {
|
|
|
1635
1721
|
JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}),
|
|
1636
1722
|
],
|
|
1637
1723
|
})),
|
|
1724
|
+
...(content.forms ?? []).map(formInsert),
|
|
1725
|
+
...(content.formSubmissions ?? []).map(submissionInsert),
|
|
1638
1726
|
...(content.resources ?? []).map((r) => this.resourceInsert(r)),
|
|
1727
|
+
...(content.places ?? []).map((p) => this.placeInsert(p)),
|
|
1639
1728
|
...(content.bookingLinks ?? []).map((l) => this.linkInsert(l)),
|
|
1640
1729
|
...(content.bookings ?? []).map((b) => this.bookingInsert(b)),
|
|
1641
1730
|
...(content.bookingRequests ?? []).map(r => ({ sql: 'INSERT INTO mobile_booking_requests (key_hash,request_hash,booking_id,created_at) VALUES (?,?,?,?)', args: [r.keyHash,r.requestHash,r.bookingId,r.createdAt] })),
|
|
@@ -1839,6 +1928,7 @@ export class SqlRepository implements ContentRepository {
|
|
|
1839
1928
|
activeTheme: r?.active_theme ?? '', designOverrides: JSON.parse(String(r?.design_overrides ?? '{}')),
|
|
1840
1929
|
sectionPages: JSON.parse(String(r?.section_pages ?? '{}')), designRevision: Number(r?.design_revision ?? 0),
|
|
1841
1930
|
siteLogo: r?.site_logo ?? '',
|
|
1931
|
+
siteLogoDark: r?.site_logo_dark ?? '',
|
|
1842
1932
|
siteUrl: r?.site_url ?? '', siteFavicon: r?.site_favicon ?? '', siteCover: r?.site_cover ?? '',
|
|
1843
1933
|
announcement: JSON.parse(String(r?.announcement ?? '{}')),
|
|
1844
1934
|
analytics: JSON.parse(String(r?.analytics ?? '{}')),
|
|
@@ -1862,14 +1952,14 @@ export class SqlRepository implements ContentRepository {
|
|
|
1862
1952
|
private settingsStatement(data: Partial<Settings>): SqlStatement | undefined {
|
|
1863
1953
|
const columns: Record<string, string> = {
|
|
1864
1954
|
siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
|
|
1865
|
-
activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
|
|
1955
|
+
activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteLogoDark:'site_logo_dark', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
|
|
1866
1956
|
announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
|
|
1867
1957
|
draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push', calendar:'calendar',
|
|
1868
1958
|
};
|
|
1869
1959
|
const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push','calendar']);
|
|
1870
1960
|
const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
|
|
1871
1961
|
if (!entries.length) return;
|
|
1872
|
-
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
|
|
1962
|
+
const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteLogoDark','siteFavicon','siteCover','draftDesign'].includes(k));
|
|
1873
1963
|
return { sql: `UPDATE settings SET ${entries.map(([k]) => `${columns[k]} = ?`).join(', ')}${design ? ', design_revision = design_revision + 1' : ''} WHERE id = 1`,
|
|
1874
1964
|
args: entries.map(([k,v]) => jsonKeys.has(k) && v !== null ? JSON.stringify(v) : v) };
|
|
1875
1965
|
}
|
|
@@ -1919,8 +2009,8 @@ export class SqlRepository implements ContentRepository {
|
|
|
1919
2009
|
const statements: SqlStatement[] = [
|
|
1920
2010
|
{ sql: 'INSERT INTO design_versions (id,created_at,author,summary,snapshot) VALUES (?,?,?,?,?)', args: [crypto.randomUUID(), Date.now(), opts.author ?? '', opts.summary ?? '', JSON.stringify(designSnapshot(current, liveNav))] },
|
|
1921
2011
|
{ sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
|
|
1922
|
-
{ sql: 'UPDATE settings SET active_theme=?,design_overrides=?,section_pages=?,site_logo=?,site_favicon=?,site_cover=?,draft_design=NULL WHERE id=1',
|
|
1923
|
-
args: [design.activeTheme, JSON.stringify(design.designOverrides), JSON.stringify(design.sectionPages), design.siteLogo, design.siteFavicon, design.siteCover] },
|
|
2012
|
+
{ sql: 'UPDATE settings SET active_theme=?,design_overrides=?,section_pages=?,site_logo=?,site_logo_dark=?,site_favicon=?,site_cover=?,draft_design=NULL WHERE id=1',
|
|
2013
|
+
args: [design.activeTheme, JSON.stringify(design.designOverrides), JSON.stringify(design.sectionPages), design.siteLogo, design.siteLogoDark, design.siteFavicon, design.siteCover] },
|
|
1924
2014
|
];
|
|
1925
2015
|
if (nav) statements.push({ sql: 'DELETE FROM nav_items' }, ...nav.map((n,i) => ({
|
|
1926
2016
|
sql: 'INSERT INTO nav_items (id,label,url,area,ord) VALUES (?,?,?,?,?)', args: [n.id.startsWith('tmp-') ? crypto.randomUUID() : n.id,n.label,n.url,n.area,i],
|
package/src/lib/repo/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FormStore } from '../forms/store';
|
|
2
|
+
import type { SavedForm, FormSubmission } from '../forms/schema';
|
|
1
3
|
import type { LiveStore } from '../live/store';
|
|
2
4
|
import type { BookingRequest } from '../backup/content';
|
|
3
5
|
import type { EmailStore } from '../email/store';
|
|
@@ -8,7 +10,7 @@ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'red
|
|
|
8
10
|
export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
|
|
9
11
|
export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
|
|
10
12
|
|
|
11
|
-
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus } from '../schema';
|
|
13
|
+
import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus, Place, EventLocation } from '../schema';
|
|
12
14
|
|
|
13
15
|
export interface Stats {
|
|
14
16
|
publishedPages: number;
|
|
@@ -48,6 +50,7 @@ export type EventUpdate = Partial<
|
|
|
48
50
|
// this — never a concrete adapter.
|
|
49
51
|
export interface ContentRepository {
|
|
50
52
|
readonly email: EmailStore;
|
|
53
|
+
readonly forms: FormStore;
|
|
51
54
|
readonly live: LiveStore;
|
|
52
55
|
readonly push: PushStore;
|
|
53
56
|
readonly editor: EditorStore;
|
|
@@ -131,6 +134,19 @@ export interface ContentRepository {
|
|
|
131
134
|
applyEventDraft(id: string): Promise<void>;
|
|
132
135
|
discardEventDraft(id: string): Promise<void>;
|
|
133
136
|
deleteEvent(id: string): Promise<void>;
|
|
137
|
+
// saved-place sync (lib/maps/places.ts): rewrite just the location of the
|
|
138
|
+
// live row and/or the staged draft, leaving every other field — and the
|
|
139
|
+
// draft itself — alone. notify = RSVP guests of a published event get an
|
|
140
|
+
// "event updated" email (the address changed).
|
|
141
|
+
setEventLocation(id: string, patch: { live?: EventLocation; draft?: EventLocation; notify?: boolean }): Promise<void>;
|
|
142
|
+
|
|
143
|
+
// saved places. Only one can be the default (saving one as default clears
|
|
144
|
+
// the others). Deleting a place unlinks its rooms; events keep their copy.
|
|
145
|
+
listPlaces(): Promise<Place[]>;
|
|
146
|
+
getPlace(id: string): Promise<Place | null>;
|
|
147
|
+
createPlace(data: Partial<Omit<Place, 'id' | 'createdAt' | 'updatedAt' | 'order'>> & { name: string }): Promise<Place>;
|
|
148
|
+
updatePlace(id: string, data: Partial<Omit<Place, 'id' | 'createdAt' | 'updatedAt'>>): Promise<void>;
|
|
149
|
+
deletePlace(id: string): Promise<void>;
|
|
134
150
|
|
|
135
151
|
// resources (rooms, people, vans…) and their booking links. Deleting a
|
|
136
152
|
// resource deletes its links; its bookings stay as history.
|
|
@@ -265,6 +281,10 @@ export interface ContentRepository {
|
|
|
265
281
|
bookingLinks?: BookingLink[];
|
|
266
282
|
bookings?: Booking[];
|
|
267
283
|
bookingRequests?: BookingRequest[];
|
|
284
|
+
// added with maps support; older backups restore with none
|
|
285
|
+
places?: Place[];
|
|
286
|
+
forms?: SavedForm[];
|
|
287
|
+
formSubmissions?: FormSubmission[];
|
|
268
288
|
}): Promise<void>;
|
|
269
289
|
|
|
270
290
|
// imports (lib/import/apply.ts drives these). writeImported is the
|
|
@@ -292,7 +312,7 @@ export interface ContentRepository {
|
|
|
292
312
|
updateSettings(data: Partial<Settings>): Promise<void>;
|
|
293
313
|
// Design-screen staging: save stages into settings.draftDesign, apply
|
|
294
314
|
// copies staged → live design fields and clears, discard just clears
|
|
295
|
-
saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
|
|
315
|
+
saveDesignDraft(data: Partial<Pick<Settings, 'activeTheme' | 'designOverrides' | 'siteLogo' | 'siteLogoDark' | 'siteFavicon' | 'siteCover' | 'sectionPages'>> & {
|
|
296
316
|
navItems?: { id: string; label: string; url: string; area: NavItem['area'] }[];
|
|
297
317
|
shareEpoch?: string;
|
|
298
318
|
}): Promise<void>;
|
package/src/lib/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { PushSettings } from './push/types';
|
|
|
3
3
|
import { SectionPages } from '../theme-sdk/contract.mjs';
|
|
4
4
|
import { AiConfig, AgentConfig } from './ai-schema';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
import { FormDefinition, FormResponse } from './forms/schema';
|
|
6
7
|
|
|
7
8
|
// ---- Widgets -------------------------------------------------------------
|
|
8
9
|
// Each widget type has a zod config schema; pages store an ordered array of
|
|
@@ -304,6 +305,18 @@ export const EventLocation = z.object({
|
|
|
304
305
|
address: z.string().default(''),
|
|
305
306
|
// a map link or an online-meeting URL
|
|
306
307
|
url: z.string().default(''),
|
|
308
|
+
// the pin, when known (picked from suggestions, parsed from a map link,
|
|
309
|
+
// or copied from a saved place); null = no pin, so no map image
|
|
310
|
+
lat: z.number().nullable().default(null),
|
|
311
|
+
lng: z.number().nullable().default(null),
|
|
312
|
+
// the saved place this was picked from ('' = typed by hand). The fields
|
|
313
|
+
// above are a copy; saving the place refreshes upcoming events' copies.
|
|
314
|
+
placeId: z.string().default(''),
|
|
315
|
+
// show the static map image on the public event page
|
|
316
|
+
showMap: z.boolean().default(true),
|
|
317
|
+
// cached static map images (media URLs) for the current pin; '' = none
|
|
318
|
+
mapImage: z.string().default(''),
|
|
319
|
+
mapImageDark: z.string().default(''),
|
|
307
320
|
});
|
|
308
321
|
export type EventLocation = z.infer<typeof EventLocation>;
|
|
309
322
|
|
|
@@ -365,6 +378,7 @@ export const CalEvent = z.object({
|
|
|
365
378
|
capacity: z.number().int().default(0),
|
|
366
379
|
autoConfirm: z.boolean().default(true),
|
|
367
380
|
maxSeats: z.number().int().default(4),
|
|
381
|
+
form: FormDefinition.optional(),
|
|
368
382
|
}).default({}),
|
|
369
383
|
// rooms/people this event uses: they can't be booked while it happens (or
|
|
370
384
|
// all day). Computed into availability, never stored as bookings. Drafts
|
|
@@ -439,12 +453,35 @@ export const Resource = z.object({
|
|
|
439
453
|
showOnCalendar: z.boolean().default(false),
|
|
440
454
|
// secret for the staff feed /book/<slug>.ics?t=… (bookings with names)
|
|
441
455
|
feedToken: z.string().default(''),
|
|
456
|
+
// the saved place this room is in ('' = none)
|
|
457
|
+
placeId: z.string().default(''),
|
|
442
458
|
order: z.number().default(0),
|
|
443
459
|
createdAt: z.number(),
|
|
444
460
|
updatedAt: z.number(),
|
|
445
461
|
});
|
|
446
462
|
export type Resource = z.infer<typeof Resource>;
|
|
447
463
|
|
|
464
|
+
// A saved place (HQ, the park pavilion…): picked in an event's Where field,
|
|
465
|
+
// it copies its details into the event. Rooms can belong to one (resource.placeId).
|
|
466
|
+
export const Place = z.object({
|
|
467
|
+
id: z.string(),
|
|
468
|
+
name: z.string(),
|
|
469
|
+
address: z.string().default(''),
|
|
470
|
+
url: z.string().default(''),
|
|
471
|
+
lat: z.number().nullable().default(null),
|
|
472
|
+
lng: z.number().nullable().default(null),
|
|
473
|
+
// shown to editors only ("Use the side door")
|
|
474
|
+
notes: z.string().default(''),
|
|
475
|
+
// new events start here
|
|
476
|
+
isDefault: z.boolean().default(false),
|
|
477
|
+
mapImage: z.string().default(''),
|
|
478
|
+
mapImageDark: z.string().default(''),
|
|
479
|
+
order: z.number().default(0),
|
|
480
|
+
createdAt: z.number(),
|
|
481
|
+
updatedAt: z.number(),
|
|
482
|
+
});
|
|
483
|
+
export type Place = z.infer<typeof Place>;
|
|
484
|
+
|
|
448
485
|
export const QUESTION_TYPES = ['text', 'textarea', 'email', 'phone', 'number', 'select', 'checkbox', 'date'] as const;
|
|
449
486
|
export const BookingQuestion = z.object({
|
|
450
487
|
id: z.string(),
|
|
@@ -549,6 +586,7 @@ export const Booking = z.object({
|
|
|
549
586
|
phone: z.string().default(''),
|
|
550
587
|
seats: z.number().int().default(1),
|
|
551
588
|
answers: z.record(z.string()).default({}),
|
|
589
|
+
formResponse: FormResponse.optional(),
|
|
552
590
|
notes: z.string().default(''), // staff-only
|
|
553
591
|
manageToken: z.string().default(''),
|
|
554
592
|
source: z.enum(['web', 'admin', 'api', 'agent']).default('web'),
|
|
@@ -650,6 +688,7 @@ export const Settings = z.object({
|
|
|
650
688
|
sectionPages: SectionPages.default({}),
|
|
651
689
|
designRevision: z.number().int().nonnegative().default(0),
|
|
652
690
|
siteLogo: z.string().default(''),
|
|
691
|
+
siteLogoDark: z.string().default(''),
|
|
653
692
|
// SEO foundation: canonical origin for absolute URLs (sitemap, feeds, OG
|
|
654
693
|
// tags — empty falls back to the request origin), favicon, and the default
|
|
655
694
|
// social-share image used when a post has no cover
|
package/src/lib/site-chrome.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface SiteChrome {
|
|
|
19
19
|
title: string;
|
|
20
20
|
tagline: string;
|
|
21
21
|
logo: string; // '' when the site has no logo
|
|
22
|
+
logoDark: string; // '' when unset; themes can fall back to logo
|
|
22
23
|
homeUrl: string;
|
|
23
24
|
links: ChromeLink[]; // header navigation
|
|
24
25
|
footerLinks: ChromeLink[];
|
package/src/lib/umami.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface TrafficSummary {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const TTL = 10 * 60_000;
|
|
24
|
+
const CACHE_MAX = 32;
|
|
24
25
|
const cache = new Map<string, { at: number; value: TrafficSummary | null }>();
|
|
25
26
|
|
|
26
27
|
function config(settings: Settings) {
|
|
@@ -97,7 +98,13 @@ export async function getTraffic(settings: Settings, days = 30): Promise<Traffic
|
|
|
97
98
|
} catch {
|
|
98
99
|
value = null;
|
|
99
100
|
}
|
|
100
|
-
|
|
101
|
+
// Expiry controls freshness, not retention. Bound old site/range keys in
|
|
102
|
+
// long-lived Node processes as well as short-lived Worker isolates.
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
for (const [key, entry] of cache) if (now - entry.at >= TTL) cache.delete(key);
|
|
105
|
+
cache.delete(cacheKey);
|
|
106
|
+
cache.set(cacheKey, { at: now, value });
|
|
107
|
+
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value!);
|
|
101
108
|
return value;
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { csv } from '../../../lib/forms/service';
|
|
3
|
+
import { readForm } from '../../../lib/forms/http';
|
|
2
4
|
import Admin from '../../../layouts/Admin.astro';
|
|
3
5
|
import BookingsHead from '../../../components/calendar/BookingsHead.astro';
|
|
4
6
|
import { eventHolds } from '../../../lib/booking/holds';
|
|
5
7
|
import Modal from '../../../components/Modal.astro';
|
|
6
8
|
import { getRepo } from '../../../lib/repo';
|
|
7
9
|
import type { Booking, BookingStatus } from '../../../lib/schema';
|
|
8
|
-
import { bookingsEnabled, eventUrl } from '../../../lib/calendar/config';
|
|
10
|
+
import { CALENDAR, bookingsEnabled, eventUrl } from '../../../lib/calendar/config';
|
|
9
11
|
import { createBlock, createResourceBooking, rescheduleBooking, setBookingStatus, BookingError } from '../../../lib/booking/service';
|
|
10
12
|
import { addDaysYmd, localMidnight, ymd } from '../../../lib/booking/availability';
|
|
11
13
|
import { fmt } from '../../../lib/booking/view';
|
|
@@ -16,7 +18,7 @@ import { waitUntilFrom } from '../../../lib/webhooks';
|
|
|
16
18
|
// Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
|
|
17
19
|
// requests first, a Day view with one column per resource, a details panel
|
|
18
20
|
// (?b=<id>) with private notes, plus Block time and Add booking.
|
|
19
|
-
if (!
|
|
21
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
20
22
|
const repo = await getRepo(Astro.locals);
|
|
21
23
|
const settings = await repo.getSettings();
|
|
22
24
|
const tz = settings.timezone || 'UTC';
|
|
@@ -43,10 +45,11 @@ let notice = Astro.url.searchParams.get('notice') ?? '';
|
|
|
43
45
|
let error = '';
|
|
44
46
|
|
|
45
47
|
if (Astro.request.method === 'POST') {
|
|
46
|
-
const form = await Astro.request
|
|
48
|
+
const form = await readForm(Astro.request);
|
|
47
49
|
const op = String(form.get('op'));
|
|
48
50
|
const id = String(form.get('id') || '');
|
|
49
51
|
try {
|
|
52
|
+
if (!bookingsEnabled() && ['block', 'add', 'reschedule'].includes(op)) throw new BookingError(400, 'Resource bookings are disabled.');
|
|
50
53
|
if (op === 'status') {
|
|
51
54
|
const b = await repo.getBooking(id);
|
|
52
55
|
if (!b) throw new BookingError(404, 'Booking not found.');
|
|
@@ -104,9 +107,10 @@ if (Astro.request.method === 'POST') {
|
|
|
104
107
|
// ---- data ----
|
|
105
108
|
const tab = (['upcoming', 'unconfirmed', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
|
|
106
109
|
const view = Astro.url.searchParams.get('view') === 'day' ? 'day' : 'agenda';
|
|
110
|
+
const eventFilter = Astro.url.searchParams.get('event') ?? '';
|
|
107
111
|
const resFilter = Astro.url.searchParams.get('r') ?? '';
|
|
108
112
|
const q = (Astro.url.searchParams.get('q') ?? '').trim().toLowerCase();
|
|
109
|
-
const all = await repo.listBookings({ from: now - 400 * 86_400_000 });
|
|
113
|
+
const all = await repo.listBookings({ from: now - 400 * 86_400_000, ...(eventFilter ? { eventId: eventFilter } : {}), ...(!bookingsEnabled() ? { kinds: ['rsvp'] } : {}) });
|
|
110
114
|
const titleOf = (b: Booking) =>
|
|
111
115
|
b.kind === 'block' ? `Blocked${b.notes ? ` · ${b.notes}` : ''}`
|
|
112
116
|
: b.kind === 'rsvp' ? b.name
|
|
@@ -116,6 +120,7 @@ const contextOf = (b: Booking) =>
|
|
|
116
120
|
: [linkById.get(b.linkId)?.name, ...Object.values(b.answers).slice(0, 2)].filter(Boolean).join(' · ');
|
|
117
121
|
const where = (b: Booking) => (b.kind === 'rsvp' ? evById.get(b.eventId)?.location.name || 'Event' : resById.get(b.resourceId)?.name ?? '—');
|
|
118
122
|
const matches = (b: Booking) =>
|
|
123
|
+
(!eventFilter || b.eventId === eventFilter) &&
|
|
119
124
|
(!resFilter || b.resourceId === resFilter) &&
|
|
120
125
|
(!q || `${b.name} ${b.email} ${b.phone} ${where(b)} ${contextOf(b)}`.toLowerCase().includes(q));
|
|
121
126
|
const active = (b: Booking) => b.status === 'pending' || b.status === 'confirmed';
|
|
@@ -131,15 +136,14 @@ const list = all
|
|
|
131
136
|
.sort((a, b) => (tab === 'past' || tab === 'cancelled' ? b.start - a.start : a.start - b.start))
|
|
132
137
|
.slice(0, 300);
|
|
133
138
|
if (Astro.url.searchParams.get('format') === 'csv') {
|
|
134
|
-
const cell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
|
|
135
139
|
const iso = (ms: number) => new Date(ms).toISOString();
|
|
136
140
|
const keys = [...new Set(list.flatMap((b) => Object.keys(b.answers)))];
|
|
137
141
|
const rows = [
|
|
138
142
|
['Start', 'End', 'Status', 'Kind', 'Resource / event', 'Link', 'Name', 'Email', 'Phone', 'Seats', ...keys, 'Notes', 'Booked'],
|
|
139
143
|
...list.map((b) => [iso(b.start), iso(b.end), b.status, b.kind, where(b), linkById.get(b.linkId)?.name ?? '', b.name, b.email, b.phone, String(b.seats), ...keys.map((k) => b.answers[k] ?? ''), b.notes, iso(b.createdAt)]),
|
|
140
144
|
];
|
|
141
|
-
return new Response(rows
|
|
142
|
-
headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="bookings-${tab}.csv"
|
|
145
|
+
return new Response(csv(rows), {
|
|
146
|
+
headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="bookings-${tab}.csv"`, 'Cache-Control': 'private, no-store' },
|
|
143
147
|
});
|
|
144
148
|
}
|
|
145
149
|
const pending = tab === 'upcoming' ? list.filter((b) => b.status === 'pending') : [];
|
|
@@ -190,6 +194,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
190
194
|
<a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
|
|
191
195
|
</BookingsHead>
|
|
192
196
|
{notice && <p class="notice" role="status">{notice}</p>}
|
|
197
|
+
{eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · <a href={keep({ event: null })}>Show all bookings</a></p>}
|
|
193
198
|
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
194
199
|
{provider === 'none' && <p class="notice"><span>Emails are off — set <code>EMAIL_FROM</code> plus <code>RESEND_API_KEY</code> (or a Cloudflare <code>send_email</code> binding named EMAIL) so guests get confirmations and reminders.</span></p>}
|
|
195
200
|
|
|
@@ -8,14 +8,16 @@ import { BRAND } from '../../../../lib/brand';
|
|
|
8
8
|
import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
|
|
9
9
|
import { mergeEventDraft } from '../../../../lib/drafts';
|
|
10
10
|
import { safeColor } from '../../../../lib/calendar/events';
|
|
11
|
+
import { mapsStatus } from '../../../../lib/maps/config';
|
|
11
12
|
import ThemeScript from '../../../../components/ThemeScript.astro';
|
|
12
13
|
import PostEditor from '../../../../components/editor/PostEditor';
|
|
13
14
|
import '../../../../styles/admin.css';
|
|
14
15
|
import '../../../../styles/editor.css';
|
|
15
16
|
|
|
16
17
|
// The shared writing surface in event mode: same cards, autosave, preview and
|
|
17
|
-
// publish flow as posts,
|
|
18
|
-
//
|
|
18
|
+
// publish flow as posts, with the event's settings (When · Where ·
|
|
19
|
+
// Registration · Organize) on the page above its content. Live events stage
|
|
20
|
+
// edits until Update, like posts.
|
|
19
21
|
if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
20
22
|
|
|
21
23
|
const repo = await getRepo(Astro.locals);
|
|
@@ -59,6 +61,7 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
|
|
|
59
61
|
calendars={calendars.filter((c) => c.kind === 'native').map((c) => ({ id: c.id, name: c.name, color: safeColor(c.color) }))}
|
|
60
62
|
eventFields={CALENDAR.eventFields}
|
|
61
63
|
siteTimezone={settings.timezone}
|
|
64
|
+
maps={mapsStatus(Astro.locals)}
|
|
62
65
|
site={{ title: settings.siteTitle, url: settings.siteUrl }}
|
|
63
66
|
/>
|
|
64
67
|
</body>
|
|
@@ -9,6 +9,7 @@ import { getRepo } from '../../../../lib/repo';
|
|
|
9
9
|
import { slugify, type Status } from '../../../../lib/schema';
|
|
10
10
|
import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
|
|
11
11
|
import { featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
|
|
12
|
+
import { defaultPlaceLocation } from '../../../../lib/maps/places';
|
|
12
13
|
import { describeRepeat } from '../../../../lib/calendar/rrule';
|
|
13
14
|
import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
|
|
14
15
|
import { normalizeCalendarSrc } from '../../../../lib/calendar/src';
|
|
@@ -71,9 +72,11 @@ if (Astro.request.method === 'POST') {
|
|
|
71
72
|
const today = tzParts(now + 86_400_000, tz);
|
|
72
73
|
const day = /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : `${today.y}-${String(today.m).padStart(2, '0')}-${String(today.d).padStart(2, '0')}`;
|
|
73
74
|
const start = /^\d{2}:\d{2}$/.test(time) ? `${day}T${time}` : day;
|
|
75
|
+
const place = await defaultPlaceLocation(repo);
|
|
74
76
|
const event = await repo.createEvent({
|
|
75
77
|
title, slug: await uniqueSlug(title), start, allDay: !/^\d{2}:\d{2}$/.test(time),
|
|
76
78
|
calendarIds: calendarId && (await repo.getCalendar(calendarId))?.kind === 'native' ? [calendarId] : [],
|
|
79
|
+
...(place ? { location: place } : {}),
|
|
77
80
|
});
|
|
78
81
|
return Astro.redirect(`/admin/calendar/events/${event.id}`);
|
|
79
82
|
}
|
|
@@ -26,6 +26,9 @@ let notice = Astro.url.searchParams.has('created') ? 'Created. Set its hours, th
|
|
|
26
26
|
let error = '';
|
|
27
27
|
const token = () => btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(18)))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
28
28
|
|
|
29
|
+
// saved places (Calendar settings → Places): which building this room is in
|
|
30
|
+
const places = await repo.listPlaces();
|
|
31
|
+
|
|
29
32
|
if (Astro.request.method === 'POST') {
|
|
30
33
|
const form = await Astro.request.formData();
|
|
31
34
|
const op = String(form.get('op'));
|
|
@@ -48,6 +51,7 @@ if (Astro.request.method === 'POST') {
|
|
|
48
51
|
timezone: tz && isValidTimeZone(tz) ? tz : '',
|
|
49
52
|
visibility: form.get('visibility') === 'unlisted' ? 'unlisted' : 'public',
|
|
50
53
|
showOnCalendar: Boolean(form.get('showOnCalendar')),
|
|
54
|
+
placeId: places.some((p) => p.id === form.get('placeId')) ? String(form.get('placeId')) : '',
|
|
51
55
|
hours: hoursFromForm(form), overrides: overridesFromForm(form), busyFeeds, notifyEmails: emails,
|
|
52
56
|
});
|
|
53
57
|
notice = error ? '' : 'Saved.';
|
|
@@ -160,6 +164,13 @@ const ovRows = [...resource.overrides, { date: '', endDate: '', ranges: [], labe
|
|
|
160
164
|
<label class="field"><span>Description</span><textarea name="description" rows="3">{resource.description}</textarea></label>
|
|
161
165
|
<label class="field"><span>Photo URL</span><input type="text" class="mono" name="photo" value={resource.photo} placeholder="/media/… or https://…" /></label>
|
|
162
166
|
<label class="field"><span>Amenities <em class="hint">comma-separated</em></span><input type="text" name="amenities" value={resource.amenities.join(', ')} placeholder="Kitchen, Projector, Accessible" /></label>
|
|
167
|
+
<label class="field"><span>Place</span>
|
|
168
|
+
<select name="placeId">
|
|
169
|
+
<option value="">{places.length ? 'Not set' : 'No saved places yet'}</option>
|
|
170
|
+
{places.map((p) => <option value={p.id} selected={resource.placeId === p.id}>{p.name}{p.address ? ` — ${p.address}` : ''}</option>)}
|
|
171
|
+
</select>
|
|
172
|
+
<small class="hint">The building or venue it’s in. Add places in <a href="/admin/calendar/settings#places">Calendar settings</a>.</small>
|
|
173
|
+
</label>
|
|
163
174
|
<label class="field"><span>Timezone</span><input type="text" name="timezone" value={resource.timezone} placeholder={`${settings.timezone || 'UTC'} (site default)`} /></label>
|
|
164
175
|
<label class="field">
|
|
165
176
|
<span>Listed</span>
|