@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
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
|
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'),
|
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,22 +1,25 @@
|
|
|
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';
|
|
12
|
-
import {
|
|
14
|
+
import { resolveConfig } from '../../../lib/email/config';
|
|
15
|
+
import { emailReadiness } from '../../../lib/email/readiness';
|
|
13
16
|
import { siteOrigin } from '../../../lib/seo';
|
|
14
17
|
import { waitUntilFrom } from '../../../lib/webhooks';
|
|
15
18
|
|
|
16
19
|
// Bookings (mockup I): Cal.com-style tabs, an agenda by day with pending
|
|
17
20
|
// requests first, a Day view with one column per resource, a details panel
|
|
18
21
|
// (?b=<id>) with private notes, plus Block time and Add booking.
|
|
19
|
-
if (!
|
|
22
|
+
if (!CALENDAR.enabled) return new Response(null, { status: 404 });
|
|
20
23
|
const repo = await getRepo(Astro.locals);
|
|
21
24
|
const settings = await repo.getSettings();
|
|
22
25
|
const tz = settings.timezone || 'UTC';
|
|
@@ -43,15 +46,16 @@ let notice = Astro.url.searchParams.get('notice') ?? '';
|
|
|
43
46
|
let error = '';
|
|
44
47
|
|
|
45
48
|
if (Astro.request.method === 'POST') {
|
|
46
|
-
const form = await Astro.request
|
|
49
|
+
const form = await readForm(Astro.request);
|
|
47
50
|
const op = String(form.get('op'));
|
|
48
51
|
const id = String(form.get('id') || '');
|
|
49
52
|
try {
|
|
53
|
+
if (!bookingsEnabled() && ['block', 'add', 'reschedule'].includes(op)) throw new BookingError(400, 'Resource bookings are disabled.');
|
|
50
54
|
if (op === 'status') {
|
|
51
55
|
const b = await repo.getBooking(id);
|
|
52
56
|
if (!b) throw new BookingError(404, 'Booking not found.');
|
|
53
57
|
await setBookingStatus(repo, b, String(form.get('to')) as BookingStatus, flow, String(form.get('message') || '') || undefined);
|
|
54
|
-
return Astro.redirect(keep({ notice: form.get('to') === 'confirmed' ? 'Confirmed — it’s on the calendar
|
|
58
|
+
return Astro.redirect(keep({ notice: form.get('to') === 'confirmed' ? 'Confirmed — it’s on the calendar. Check Email → Activity for notification status.' : 'Updated.' }));
|
|
55
59
|
}
|
|
56
60
|
if (op === 'notes') {
|
|
57
61
|
await repo.updateBooking(id, { notes: String(form.get('notes') || '').slice(0, 4000) });
|
|
@@ -67,7 +71,7 @@ if (Astro.request.method === 'POST') {
|
|
|
67
71
|
const start = toMs(String(form.get('date')), String(form.get('time')));
|
|
68
72
|
if (Number.isNaN(start)) throw new BookingError(400, 'Pick a date and time.');
|
|
69
73
|
await rescheduleBooking(repo, b, start, start + (b.end - b.start), { ...flow, skipRules: true });
|
|
70
|
-
return Astro.redirect(keep({ notice: 'Moved
|
|
74
|
+
return Astro.redirect(keep({ notice: 'Moved. Check Email → Activity for notification status.' }));
|
|
71
75
|
}
|
|
72
76
|
if (op === 'block') {
|
|
73
77
|
const r = resById.get(String(form.get('resourceId')));
|
|
@@ -104,9 +108,10 @@ if (Astro.request.method === 'POST') {
|
|
|
104
108
|
// ---- data ----
|
|
105
109
|
const tab = (['upcoming', 'unconfirmed', 'past', 'cancelled'] as const).find((t) => t === Astro.url.searchParams.get('tab')) ?? 'upcoming';
|
|
106
110
|
const view = Astro.url.searchParams.get('view') === 'day' ? 'day' : 'agenda';
|
|
111
|
+
const eventFilter = Astro.url.searchParams.get('event') ?? '';
|
|
107
112
|
const resFilter = Astro.url.searchParams.get('r') ?? '';
|
|
108
113
|
const q = (Astro.url.searchParams.get('q') ?? '').trim().toLowerCase();
|
|
109
|
-
const all = await repo.listBookings({ from: now - 400 * 86_400_000 });
|
|
114
|
+
const all = await repo.listBookings({ from: now - 400 * 86_400_000, ...(eventFilter ? { eventId: eventFilter } : {}), ...(!bookingsEnabled() ? { kinds: ['rsvp'] } : {}) });
|
|
110
115
|
const titleOf = (b: Booking) =>
|
|
111
116
|
b.kind === 'block' ? `Blocked${b.notes ? ` · ${b.notes}` : ''}`
|
|
112
117
|
: b.kind === 'rsvp' ? b.name
|
|
@@ -116,6 +121,7 @@ const contextOf = (b: Booking) =>
|
|
|
116
121
|
: [linkById.get(b.linkId)?.name, ...Object.values(b.answers).slice(0, 2)].filter(Boolean).join(' · ');
|
|
117
122
|
const where = (b: Booking) => (b.kind === 'rsvp' ? evById.get(b.eventId)?.location.name || 'Event' : resById.get(b.resourceId)?.name ?? '—');
|
|
118
123
|
const matches = (b: Booking) =>
|
|
124
|
+
(!eventFilter || b.eventId === eventFilter) &&
|
|
119
125
|
(!resFilter || b.resourceId === resFilter) &&
|
|
120
126
|
(!q || `${b.name} ${b.email} ${b.phone} ${where(b)} ${contextOf(b)}`.toLowerCase().includes(q));
|
|
121
127
|
const active = (b: Booking) => b.status === 'pending' || b.status === 'confirmed';
|
|
@@ -131,15 +137,14 @@ const list = all
|
|
|
131
137
|
.sort((a, b) => (tab === 'past' || tab === 'cancelled' ? b.start - a.start : a.start - b.start))
|
|
132
138
|
.slice(0, 300);
|
|
133
139
|
if (Astro.url.searchParams.get('format') === 'csv') {
|
|
134
|
-
const cell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
|
|
135
140
|
const iso = (ms: number) => new Date(ms).toISOString();
|
|
136
141
|
const keys = [...new Set(list.flatMap((b) => Object.keys(b.answers)))];
|
|
137
142
|
const rows = [
|
|
138
143
|
['Start', 'End', 'Status', 'Kind', 'Resource / event', 'Link', 'Name', 'Email', 'Phone', 'Seats', ...keys, 'Notes', 'Booked'],
|
|
139
144
|
...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
145
|
];
|
|
141
|
-
return new Response(rows
|
|
142
|
-
headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="bookings-${tab}.csv"
|
|
146
|
+
return new Response(csv(rows), {
|
|
147
|
+
headers: { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="bookings-${tab}.csv"`, 'Cache-Control': 'private, no-store' },
|
|
143
148
|
});
|
|
144
149
|
}
|
|
145
150
|
const pending = tab === 'upcoming' ? list.filter((b) => b.status === 'pending') : [];
|
|
@@ -170,7 +175,12 @@ const pos = (b: Booking) => {
|
|
|
170
175
|
};
|
|
171
176
|
|
|
172
177
|
const selected = Astro.url.searchParams.get('b') ? await repo.getBooking(Astro.url.searchParams.get('b')!) : null;
|
|
173
|
-
const
|
|
178
|
+
const emailConfig = resolveConfig(await repo.email.config(), Astro.locals);
|
|
179
|
+
const emailStatus = emailReadiness(emailConfig, settings.siteUrl, Astro.locals);
|
|
180
|
+
const emailNotice = !emailConfig.config.enabled || emailConfig.config.provider === 'none'
|
|
181
|
+
? 'Emails are turned off. Enable email so guests can receive confirmations and reminders.'
|
|
182
|
+
: !emailStatus.configured ? `Email needs attention: ${emailStatus.error || 'Choose a provider in Email settings.'}`
|
|
183
|
+
: emailStatus.linkError;
|
|
174
184
|
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
175
185
|
const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
|
|
176
186
|
const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
|
|
@@ -190,8 +200,9 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
190
200
|
<a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
|
|
191
201
|
</BookingsHead>
|
|
192
202
|
{notice && <p class="notice" role="status">{notice}</p>}
|
|
203
|
+
{eventFilter && <p class="notice">Registrations for {evById.get(eventFilter)?.title ?? 'this event'} · <a href={keep({ event: null })}>Show all bookings</a></p>}
|
|
193
204
|
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
194
|
-
{
|
|
205
|
+
{emailNotice && <p class="notice warn"><span>{emailNotice} <a href={emailStatus.configured && emailStatus.linkError ? '/admin/settings#site' : '/admin/email'}>{emailStatus.configured && emailStatus.linkError ? 'Site settings' : 'Email settings'}</a></span></p>}
|
|
195
206
|
|
|
196
207
|
{resources.length === 0 && !all.length ? (
|
|
197
208
|
<div class="empty-state">
|
|
@@ -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>
|