@acms-subir/core 0.2.0 → 0.3.0

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.
Files changed (230) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -369
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +182 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +27 -5
  19. package/src/actions/index.ts +575 -47
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +418 -46
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +114 -57
  30. package/src/components/NavManager.tsx +42 -43
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +605 -223
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/events.tsx +153 -0
  45. package/src/components/editor/extensions/slash.tsx +7 -1
  46. package/src/components/site/Pager.astro +24 -0
  47. package/src/components/site/PostCard.astro +64 -0
  48. package/src/components/site/TagLabel.astro +11 -0
  49. package/src/layouts/Admin.astro +289 -49
  50. package/src/layouts/Site.astro +324 -59
  51. package/src/lib/agent-api.ts +13 -0
  52. package/src/lib/agent-discovery.ts +40 -0
  53. package/src/lib/agent.ts +114 -0
  54. package/src/lib/ai-config.ts +58 -0
  55. package/src/lib/ai-schema.ts +22 -0
  56. package/src/lib/ai.ts +90 -0
  57. package/src/lib/api-keys.ts +65 -0
  58. package/src/lib/api.ts +52 -0
  59. package/src/lib/auth.ts +25 -5
  60. package/src/lib/backup/content.ts +36 -0
  61. package/src/lib/backup/unzip.ts +12 -1
  62. package/src/lib/backup/zip.ts +1 -1
  63. package/src/lib/booking/agent.ts +108 -0
  64. package/src/lib/booking/availability.ts +221 -0
  65. package/src/lib/booking/emails.ts +115 -0
  66. package/src/lib/booking/forms.ts +77 -0
  67. package/src/lib/booking/service.ts +277 -0
  68. package/src/lib/booking/view.ts +54 -0
  69. package/src/lib/calendar/agent-schema.ts +48 -0
  70. package/src/lib/calendar/agent.ts +136 -0
  71. package/src/lib/calendar/api.ts +81 -0
  72. package/src/lib/calendar/cards.ts +102 -0
  73. package/src/lib/calendar/config.ts +52 -0
  74. package/src/lib/calendar/data.ts +130 -0
  75. package/src/lib/calendar/events.ts +261 -0
  76. package/src/lib/calendar/feeds.ts +43 -0
  77. package/src/lib/calendar/hooks.ts +38 -0
  78. package/src/lib/calendar/http.ts +30 -0
  79. package/src/lib/calendar/ics-write.ts +120 -0
  80. package/src/lib/calendar/ics.ts +15 -5
  81. package/src/lib/calendar/import.ts +203 -0
  82. package/src/lib/calendar/render.ts +120 -37
  83. package/src/lib/calendar/rrule.ts +149 -0
  84. package/src/lib/calendar/seo.ts +48 -0
  85. package/src/lib/design-service.ts +58 -0
  86. package/src/lib/designDiff.ts +145 -0
  87. package/src/lib/designPreview.ts +110 -12
  88. package/src/lib/documents/cleanup.ts +31 -0
  89. package/src/lib/documents/extract.ts +154 -0
  90. package/src/lib/documents/vendor.d.ts +4 -0
  91. package/src/lib/drafts.ts +60 -6
  92. package/src/lib/dynamicBlocks.ts +40 -8
  93. package/src/lib/email/accounts.ts +55 -0
  94. package/src/lib/email/config.ts +86 -0
  95. package/src/lib/email/migration.ts +24 -0
  96. package/src/lib/email/providers.ts +89 -0
  97. package/src/lib/email/service.ts +78 -0
  98. package/src/lib/email/store.ts +167 -0
  99. package/src/lib/email/templates.ts +18 -0
  100. package/src/lib/email/types.ts +32 -0
  101. package/src/lib/email/webhooks.ts +87 -0
  102. package/src/lib/email.ts +15 -0
  103. package/src/lib/env.ts +1 -1
  104. package/src/lib/legal/index.ts +25 -0
  105. package/src/lib/mobile/bookings.ts +73 -0
  106. package/src/lib/mobile/calendar.ts +85 -0
  107. package/src/lib/mobile/content.ts +85 -0
  108. package/src/lib/mobile/http.ts +63 -0
  109. package/src/lib/mobile/route.ts +20 -0
  110. package/src/lib/ops.ts +8 -0
  111. package/src/lib/preview.ts +21 -1
  112. package/src/lib/repo/migrations.ts +217 -0
  113. package/src/lib/repo/sql.ts +853 -60
  114. package/src/lib/repo/types.ts +122 -5
  115. package/src/lib/scheduler.ts +25 -0
  116. package/src/lib/schema.ts +309 -0
  117. package/src/lib/search-i18n.ts +3 -0
  118. package/src/lib/socialIcons.ts +32 -0
  119. package/src/lib/tagColor.ts +11 -0
  120. package/src/lib/theme.ts +59 -40
  121. package/src/lib/themeTokens.ts +174 -0
  122. package/src/lib/umami.ts +108 -0
  123. package/src/lib/webhooks.ts +139 -0
  124. package/src/middleware.ts +36 -3
  125. package/src/pages/404.astro +23 -0
  126. package/src/pages/[...slug].astro +8 -5
  127. package/src/pages/account/[action].astro +53 -0
  128. package/src/pages/admin/ai.astro +12 -0
  129. package/src/pages/admin/c/[collection]/[id].astro +4 -1
  130. package/src/pages/admin/c/[collection]/index.astro +127 -92
  131. package/src/pages/admin/calendar/bookings.astro +400 -0
  132. package/src/pages/admin/calendar/calendars.astro +265 -0
  133. package/src/pages/admin/calendar/events/[id].astro +56 -0
  134. package/src/pages/admin/calendar/events/index.astro +408 -0
  135. package/src/pages/admin/calendar/index.ts +4 -0
  136. package/src/pages/admin/calendar/integrations.astro +221 -0
  137. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  138. package/src/pages/admin/calendar/resources/index.astro +106 -0
  139. package/src/pages/admin/calendar/resources/link.astro +196 -0
  140. package/src/pages/admin/design.astro +10 -3
  141. package/src/pages/admin/email.astro +13 -0
  142. package/src/pages/admin/index.astro +232 -85
  143. package/src/pages/admin/links.astro +3 -3
  144. package/src/pages/admin/login.astro +14 -10
  145. package/src/pages/admin/navigation.astro +8 -2
  146. package/src/pages/admin/pages/[id].astro +4 -1
  147. package/src/pages/admin/pages/index.astro +41 -48
  148. package/src/pages/admin/settings.astro +418 -367
  149. package/src/pages/admin/tags.astro +4 -4
  150. package/src/pages/admin/users.astro +38 -28
  151. package/src/pages/api/agent/docs.ts +33 -0
  152. package/src/pages/api/agent/index.ts +21 -0
  153. package/src/pages/api/ai/clean.ts +13 -0
  154. package/src/pages/api/ai/draft.ts +14 -0
  155. package/src/pages/api/ai/settings.ts +53 -0
  156. package/src/pages/api/ai/test.ts +12 -0
  157. package/src/pages/api/backup.ts +8 -21
  158. package/src/pages/api/calendar/availability.ts +35 -0
  159. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  160. package/src/pages/api/calendar/bookings/index.ts +73 -0
  161. package/src/pages/api/calendar/calendars.ts +35 -0
  162. package/src/pages/api/calendar/docs.ts +64 -0
  163. package/src/pages/api/calendar/events/[id].ts +63 -0
  164. package/src/pages/api/calendar/events/index.ts +112 -0
  165. package/src/pages/api/calendar/resources.ts +34 -0
  166. package/src/pages/api/design/capabilities.ts +10 -0
  167. package/src/pages/api/design/index.ts +58 -0
  168. package/src/pages/api/design/keys.ts +24 -0
  169. package/src/pages/api/design/media.ts +32 -0
  170. package/src/pages/api/email/run.ts +11 -0
  171. package/src/pages/api/email/settings.ts +61 -0
  172. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  173. package/src/pages/api/mcp.ts +59 -0
  174. package/src/pages/api/media/upload.ts +2 -2
  175. package/src/pages/api/ops/status.ts +39 -0
  176. package/src/pages/api/restore.ts +27 -37
  177. package/src/pages/api/search.ts +18 -4
  178. package/src/pages/api/v1/availability.ts +7 -0
  179. package/src/pages/api/v1/bookings.ts +17 -0
  180. package/src/pages/api/v1/calendars.ts +7 -0
  181. package/src/pages/api/v1/content/[id].ts +7 -0
  182. package/src/pages/api/v1/content/index.ts +7 -0
  183. package/src/pages/api/v1/events.ts +7 -0
  184. package/src/pages/api/v1/resources.ts +7 -0
  185. package/src/pages/author/[slug].astro +43 -35
  186. package/src/pages/blog/[slug].astro +137 -66
  187. package/src/pages/blog/index.astro +95 -30
  188. package/src/pages/blog/tag/[slug].astro +45 -40
  189. package/src/pages/book/[resource]/[link].astro +70 -0
  190. package/src/pages/book/[resource]/index.astro +63 -0
  191. package/src/pages/book/[resource].ics.ts +38 -0
  192. package/src/pages/book/index.astro +47 -0
  193. package/src/pages/book/manage/[token].astro +108 -0
  194. package/src/pages/calendar/[slug].ics.ts +27 -0
  195. package/src/pages/calendar/feed.ics.ts +15 -0
  196. package/src/pages/calendar/index.astro +191 -0
  197. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  198. package/src/pages/embed/calendar/[slug].astro +43 -0
  199. package/src/pages/events/[slug].astro +74 -0
  200. package/src/pages/events/[slug].ics.ts +25 -0
  201. package/src/pages/index.astro +6 -3
  202. package/src/pages/legal/privacy.astro +4 -0
  203. package/src/pages/legal/terms.astro +4 -0
  204. package/src/pages/links.astro +23 -3
  205. package/src/pages/llms.txt.ts +4 -0
  206. package/src/pages/preview/[type]/[id].astro +27 -6
  207. package/src/pages/search.astro +40 -20
  208. package/src/pages/sitemap.xml.ts +16 -1
  209. package/src/styles/admin.css +924 -105
  210. package/src/styles/cards.css +92 -74
  211. package/src/styles/editor.css +492 -114
  212. package/src/styles/email-settings.css +117 -0
  213. package/src/styles/sections.css +12 -0
  214. package/src/styles/site.css +938 -281
  215. package/src/styles/theme-editor.css +135 -0
  216. package/src/theme-sdk/contract.d.mts +619 -0
  217. package/src/theme-sdk/contract.mjs +80 -0
  218. package/src/theme-sdk/index.ts +15 -0
  219. package/src/theme-sdk/tokens.d.mts +148 -0
  220. package/src/theme-sdk/tokens.mjs +52 -0
  221. package/src/themes/editorial/templates/home.astro +4 -4
  222. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  223. package/src/themes/editorial/theme.css +22 -37
  224. package/src/themes/editorial/theme.json +23 -3
  225. package/src/themes/field/theme.json +26 -9
  226. package/src/themes/ink/theme.json +30 -12
  227. package/src/themes/night/theme.css +4 -7
  228. package/src/themes/night/theme.json +27 -10
  229. package/src/virtual.d.ts +14 -0
  230. package/src/worker.ts +5 -1
@@ -0,0 +1,221 @@
1
+ // The availability engine: what can be booked, and when. Pure — callers pass
2
+ // in the resource, the link, the bookings that hold time and any busy
3
+ // intervals from external calendars.
4
+ //
5
+ // resource hours (or a date override) ∩ link hours → open windows
6
+ // open windows − holds (bookings ± buffers) − busy feeds − notice/advance
7
+ // → start times of the link's length (slots / range) or whole days (day)
8
+ //
9
+ // All wall-clock math happens in the resource's zone, so "9am–5pm" stays
10
+ // 9–5 across DST.
11
+ import type { Booking, BookingLink, Resource, TimeRange, Weekday } from '../schema';
12
+ import { WEEKDAYS } from '../schema';
13
+ import { tzParts, zonedToUtc } from '../calendar/ics';
14
+ import { isValidTimeZone } from '../calendar/events';
15
+
16
+ const DAY = 86_400_000;
17
+ const MIN = 60_000;
18
+
19
+ export interface Interval { start: number; end: number }
20
+ export interface Slot { start: number; end: number; minutes: number }
21
+
22
+ export interface AvailabilityContext {
23
+ resource: Resource;
24
+ link: BookingLink;
25
+ // pending + confirmed holds on this resource (any link) — other links'
26
+ // buffers already live in each booking's holdStart/holdEnd
27
+ bookings: Pick<Booking, 'holdStart' | 'holdEnd' | 'start' | 'linkId' | 'status' | 'kind' | 'id'>[];
28
+ busy: Interval[];
29
+ siteTimezone: string;
30
+ now: number;
31
+ // reschedule: ignore this booking's own hold
32
+ ignoreBookingId?: string;
33
+ }
34
+
35
+ const pad = (n: number) => String(n).padStart(2, '0');
36
+ export const resourceZone = (r: Pick<Resource, 'timezone'>, siteTz: string) =>
37
+ isValidTimeZone(r.timezone) ? r.timezone : isValidTimeZone(siteTz) ? siteTz : 'UTC';
38
+
39
+ const minutesOf = (hhmm: string) => {
40
+ const [h, m] = hhmm.split(':').map(Number);
41
+ return h * 60 + m;
42
+ };
43
+
44
+ export function ymd(ms: number, tz: string): string {
45
+ const p = tzParts(ms, tz);
46
+ return `${p.y}-${pad(p.m)}-${pad(p.d)}`;
47
+ }
48
+ export function addDaysYmd(date: string, n: number): string {
49
+ const [y, m, d] = date.split('-').map(Number);
50
+ const t = new Date(Date.UTC(y, m - 1, d + n));
51
+ return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
52
+ }
53
+ const weekdayOf = (date: string): Weekday => {
54
+ const [y, m, d] = date.split('-').map(Number);
55
+ return WEEKDAYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
56
+ };
57
+ export const localMidnight = (date: string, tz: string) => {
58
+ const [y, m, d] = date.split('-').map(Number);
59
+ return zonedToUtc(y, m, d, 0, 0, 0, tz);
60
+ };
61
+
62
+ // minute ranges for one local date: override wins over weekly hours; a link
63
+ // with its own hours can only narrow them
64
+ export function dayRanges(resource: Resource, link: BookingLink | null, date: string): [number, number][] {
65
+ const ov = resource.overrides.find((o) => date >= o.date && date <= (o.endDate || o.date));
66
+ const base = (ov ? ov.ranges : resource.hours[weekdayOf(date)]) ?? [];
67
+ let ranges = normalize(base);
68
+ if (link?.hours) ranges = intersect(ranges, normalize(link.hours[weekdayOf(date)] ?? []));
69
+ return ranges;
70
+ }
71
+
72
+ function normalize(list: TimeRange[]): [number, number][] {
73
+ const r = list
74
+ .map((t) => [minutesOf(t.start), t.end === '00:00' ? 1440 : minutesOf(t.end)] as [number, number])
75
+ .filter(([a, b]) => b > a)
76
+ .sort((a, b) => a[0] - b[0]);
77
+ const out: [number, number][] = [];
78
+ for (const cur of r) {
79
+ const last = out[out.length - 1];
80
+ if (last && cur[0] <= last[1]) last[1] = Math.max(last[1], cur[1]);
81
+ else out.push([...cur]);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function intersect(a: [number, number][], b: [number, number][]): [number, number][] {
87
+ const out: [number, number][] = [];
88
+ for (const [s1, e1] of a) for (const [s2, e2] of b) {
89
+ const s = Math.max(s1, s2), e = Math.min(e1, e2);
90
+ if (e > s) out.push([s, e]);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ const overlaps = (a0: number, a1: number, b0: number, b1: number) => a0 < b1 && a1 > b0;
96
+
97
+ function blocked(ctx: AvailabilityContext, start: number, end: number): boolean {
98
+ const hs = start - ctx.link.bufferBefore * MIN;
99
+ const he = end + ctx.link.bufferAfter * MIN;
100
+ for (const b of ctx.bookings) {
101
+ if (b.id === ctx.ignoreBookingId) continue;
102
+ if (b.status !== 'pending' && b.status !== 'confirmed') continue;
103
+ if (b.kind === 'rsvp') continue;
104
+ if (overlaps(hs, he, b.holdStart, b.holdEnd)) return true;
105
+ }
106
+ for (const i of ctx.busy) if (overlaps(start, end, i.start, i.end)) return true;
107
+ return false;
108
+ }
109
+
110
+ function capReached(ctx: AvailabilityContext, date: string, tz: string): boolean {
111
+ if (!ctx.link.perDayCap) return false;
112
+ const n = ctx.bookings.filter(
113
+ (b) => b.linkId === ctx.link.id && b.id !== ctx.ignoreBookingId && (b.status === 'pending' || b.status === 'confirmed') && ymd(b.start, tz) === date
114
+ ).length;
115
+ return n >= ctx.link.perDayCap;
116
+ }
117
+
118
+ export function linkLengths(link: BookingLink): number[] {
119
+ if (link.mode === 'range') {
120
+ const l = [...new Set(link.lengths.filter((n) => n >= 5 && n <= 24 * 60))].sort((a, b) => a - b);
121
+ return l.length ? l : [link.duration];
122
+ }
123
+ return [Math.max(5, link.duration)];
124
+ }
125
+
126
+ // the open start times on one local date (all lengths unless `minutes` given)
127
+ export function slotsForDay(ctx: AvailabilityContext, date: string, minutes?: number): Slot[] {
128
+ const tz = resourceZone(ctx.resource, ctx.siteTimezone);
129
+ const earliest = ctx.now + ctx.link.minNotice * MIN;
130
+ const latest = ctx.now + ctx.link.maxAdvance * DAY;
131
+ if (capReached(ctx, date, tz)) return [];
132
+ const midnight = localMidnight(date, tz);
133
+ if (ctx.link.mode === 'day') {
134
+ const out: Slot[] = [];
135
+ for (let n = Math.max(1, ctx.link.minDays); n <= Math.max(ctx.link.minDays, ctx.link.maxDays); n++) {
136
+ if (minutes && minutes !== n * 1440) continue;
137
+ // every day in the span must be open at all; the whole span must be free
138
+ let open = true;
139
+ for (let k = 0; k < n; k++) if (!dayRanges(ctx.resource, ctx.link, addDaysYmd(date, k)).length) open = false;
140
+ const start = midnight;
141
+ const end = localMidnight(addDaysYmd(date, n), tz);
142
+ // a day that has already begun (or falls inside the notice window) is out
143
+ if (!open || start < earliest || start > latest) continue;
144
+ if (blocked(ctx, start, end)) continue;
145
+ out.push({ start, end, minutes: n * 1440 });
146
+ }
147
+ return out;
148
+ }
149
+ const step = Math.max(5, ctx.link.step) * MIN;
150
+ const lengths = minutes ? [minutes] : linkLengths(ctx.link);
151
+ const out: Slot[] = [];
152
+ const [y, m, d] = date.split('-').map(Number);
153
+ for (const [a, b] of dayRanges(ctx.resource, ctx.link, date)) {
154
+ // wall-clock → UTC per boundary, so a window spanning a DST change is right
155
+ const wStart = zonedToUtc(y, m, d, Math.floor(a / 60), a % 60, 0, tz);
156
+ const wEnd = b >= 1440 ? localMidnight(addDaysYmd(date, 1), tz) : zonedToUtc(y, m, d, Math.floor(b / 60), b % 60, 0, tz);
157
+ for (const len of lengths) {
158
+ for (let s = wStart; s + len * MIN <= wEnd; s += step) {
159
+ if (s < earliest || s > latest) continue;
160
+ const e = s + len * MIN;
161
+ if (blocked(ctx, s, e)) continue;
162
+ out.push({ start: s, end: e, minutes: len });
163
+ }
164
+ }
165
+ }
166
+ return out.sort((p, q) => p.start - q.start || p.minutes - q.minutes);
167
+ }
168
+
169
+ // dates (in the resource zone) with at least one opening
170
+ export function openDays(ctx: AvailabilityContext, fromDate: string, days: number, minutes?: number): string[] {
171
+ const out: string[] = [];
172
+ for (let i = 0; i < days; i++) {
173
+ const date = addDaysYmd(fromDate, i);
174
+ if (slotsForDay(ctx, date, minutes).length) out.push(date);
175
+ }
176
+ return out;
177
+ }
178
+
179
+ export function nextOpenings(ctx: AvailabilityContext, count: number, horizonDays = 60): Slot[] {
180
+ const tz = resourceZone(ctx.resource, ctx.siteTimezone);
181
+ const out: Slot[] = [];
182
+ const seenDays = new Set<string>();
183
+ let date = ymd(ctx.now, tz);
184
+ for (let i = 0; i < Math.min(horizonDays, ctx.link.maxAdvance + 1) && out.length < count; i++, date = addDaysYmd(date, 1)) {
185
+ const slots = slotsForDay(ctx, date);
186
+ // range/day links: one chip per day reads better than 5 starts on day one
187
+ const pick = ctx.link.mode === 'slots' ? slots : slots.slice(0, 1);
188
+ for (const s of pick) {
189
+ if (ctx.link.mode !== 'slots' && seenDays.has(date)) continue;
190
+ seenDays.add(date);
191
+ out.push(s);
192
+ if (out.length >= count) break;
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+
198
+ // server-side check before an insert (the DB guard backs it up for races)
199
+ export function isBookable(ctx: AvailabilityContext, start: number, end: number): boolean {
200
+ const tz = resourceZone(ctx.resource, ctx.siteTimezone);
201
+ const minutes = Math.round((end - start) / MIN);
202
+ return slotsForDay(ctx, ymd(start, tz), minutes).some((s) => s.start === start && s.end === end);
203
+ }
204
+
205
+ export const holdOf = (link: Pick<BookingLink, 'bufferBefore' | 'bufferAfter'>, start: number, end: number) => ({
206
+ holdStart: start - link.bufferBefore * MIN,
207
+ holdEnd: end + link.bufferAfter * MIN,
208
+ });
209
+
210
+ export function describeLength(minutes: number): string {
211
+ if (minutes % 1440 === 0) return `${minutes / 1440} day${minutes === 1440 ? '' : 's'}`;
212
+ if (minutes < 60) return `${minutes} min`;
213
+ const h = Math.floor(minutes / 60), m = minutes % 60;
214
+ return m ? `${h} h ${m} min` : `${h} h`;
215
+ }
216
+
217
+ export function describeLink(link: BookingLink): string {
218
+ if (link.mode === 'day') return link.minDays === link.maxDays ? describeLength(link.minDays * 1440) : `${link.minDays}–${link.maxDays} days`;
219
+ const l = linkLengths(link);
220
+ return l.length === 1 ? describeLength(l[0]) : `${describeLength(l[0])} – ${describeLength(l[l.length - 1])}`;
221
+ }
@@ -0,0 +1,115 @@
1
+ // Booking emails: plain, readable HTML (inline styles, no images) plus a
2
+ // text part. Confirmed bookings carry an .ics so one tap adds them to the
3
+ // booker's calendar.
4
+ import type { Booking, BookingLink, Resource, Settings } from '../schema';
5
+ import { siteLocale } from '../dates';
6
+ import { escapeText, foldLine } from '../calendar/ics-write';
7
+ import type { Mail } from '../email';
8
+
9
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
10
+
11
+ export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings: Settings, allDayish = false): string {
12
+ const locale = siteLocale(settings);
13
+ const f = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: tz });
14
+ if (allDayish) {
15
+ const last = b.end - 1;
16
+ const d = f({ weekday: 'short', month: 'short', day: 'numeric' });
17
+ return d.format(b.start) === d.format(last) ? `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · all day` : `${d.format(b.start)} – ${d.format(last)}`;
18
+ }
19
+ const zone = f({ hour: 'numeric', timeZoneName: 'short' }).formatToParts(b.start).find((p) => p.type === 'timeZoneName')?.value ?? '';
20
+ return `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · ${f({ hour: 'numeric', minute: '2-digit' }).format(b.start)} – ${f({ hour: 'numeric', minute: '2-digit' }).format(b.end)} ${zone}`.trim();
21
+ }
22
+
23
+ const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
24
+
25
+ export function bookingIcs(b: Booking, title: string, location: string, url: string, host: string, method: 'REQUEST' | 'CANCEL' = 'REQUEST', options: { organizer?: string; allDay?: boolean } = {}): string {
26
+ const day = (ms: number) => new Intl.DateTimeFormat('en-CA', { timeZone: b.timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(ms).replaceAll('-', '');
27
+ const actualMethod = method === 'CANCEL' ? 'CANCEL' : options.organizer ? 'REQUEST' : 'PUBLISH';
28
+ const lines = [
29
+ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//acms-subir//Bookings//EN', `METHOD:${actualMethod}`,
30
+ 'BEGIN:VEVENT', `UID:booking-${b.id}@acms.invalid`, `DTSTAMP:${utc(Date.now())}`, ...(options.allDay ? [`DTSTART;VALUE=DATE:${day(b.start)}`, `DTEND;VALUE=DATE:${day(b.end)}`] : [`DTSTART:${utc(b.start)}`, `DTEND:${utc(b.end)}`]),
31
+ ...(options.organizer ? [`ORGANIZER:mailto:${options.organizer}`, `ATTENDEE;RSVP=FALSE:mailto:${b.email}`] : []),
32
+ `SUMMARY:${escapeText(title)}`, ...(location ? [`LOCATION:${escapeText(location)}`] : []), `DESCRIPTION:${escapeText(`Manage: ${url}`)}`,
33
+ `URL:${url}`, `STATUS:${method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`, `SEQUENCE:${b.notificationVersion ?? 0}`,
34
+ 'END:VEVENT', 'END:VCALENDAR',
35
+ ];
36
+ return lines.map(foldLine).join('\r\n') + '\r\n';
37
+ }
38
+
39
+ interface Ctx {
40
+ booking: Booking;
41
+ resource: Resource | null;
42
+ link: BookingLink | null;
43
+ title: string; // "Fellowship Hall · Event or reception" / event title
44
+ location: string;
45
+ when: string;
46
+ manageUrl: string;
47
+ siteTitle: string;
48
+ host: string;
49
+ message?: string;
50
+ organizer?: string;
51
+ allDay?: boolean;
52
+ }
53
+
54
+ function layout(heading: string, intro: string, c: Ctx, button?: { href: string; label: string }): { html: string; text: string } {
55
+ const rows: [string, string][] = [['What', c.title], ['When', c.when], ...(c.location ? [['Where', c.location] as [string, string]] : [])];
56
+ if (c.booking.seats > 1) rows.push(['Seats', String(c.booking.seats)]);
57
+ const html = `<!doctype html><html><body style="margin:0;background:#f6f5f2;font-family:-apple-system,Segoe UI,Inter,Arial,sans-serif;color:#1e1f24">
58
+ <div style="max-width:520px;margin:0 auto;padding:28px 20px">
59
+ <p style="font-size:13px;color:#6b6e78;margin:0 0 18px">${esc(c.siteTitle)}</p>
60
+ <h1 style="font-size:22px;margin:0 0 8px">${esc(heading)}</h1>
61
+ <p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>
62
+ <table style="width:100%;border-collapse:collapse;background:#fff;border:1px solid #e5e3de;border-radius:10px">${rows
63
+ .map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${k}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v)}</td></tr>`)
64
+ .join('')}</table>
65
+ ${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
66
+ ${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="display:inline-block;background:#1e1f24;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:10px 16px;border-radius:8px">${esc(button.label)}</a></p>` : ''}
67
+ <p style="font-size:12px;color:#8e919a;margin:26px 0 0">Reference ${esc(c.booking.id.slice(0, 8).toUpperCase())}</p>
68
+ </div></body></html>`;
69
+ const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', `Reference ${c.booking.id.slice(0, 8).toUpperCase()}`].join('\n');
70
+ return { html, text };
71
+ }
72
+
73
+ export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'staff-new';
74
+
75
+ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
76
+ const manage = { href: c.manageUrl, label: 'View or change your booking' };
77
+ const ics = (method: 'REQUEST' | 'CANCEL' = 'REQUEST') => [{ filename: 'booking.ics', type: `text/calendar; charset=utf-8; method=${method === 'CANCEL' ? 'CANCEL' : c.organizer ? 'REQUEST' : 'PUBLISH'}`, content: bookingIcs(c.booking, c.title, c.location, c.manageUrl, c.host, method, { organizer: c.organizer, allDay: c.allDay }) }];
78
+ switch (kind) {
79
+ case 'received': {
80
+ const m = layout('Request received', `Thanks, ${c.booking.name || 'there'}. We'll confirm by email soon.`, c, manage);
81
+ return { to: c.booking.email, subject: `Request received: ${c.title}`, ...m };
82
+ }
83
+ case 'confirmed': {
84
+ const m = layout('You’re booked', `It’s confirmed. Import the attached file to add it to your calendar.`, { ...c, message: c.message || c.link?.confirmMessage || '' }, manage);
85
+ return { to: c.booking.email, subject: `Confirmed: ${c.title} — ${c.when}`, ...m, attachments: ics() };
86
+ }
87
+ case 'declined': {
88
+ const m = layout('We couldn’t confirm your request', 'Sorry — this time isn’t available. You’re welcome to pick another.', c);
89
+ return { to: c.booking.email, subject: `Not available: ${c.title}`, ...m };
90
+ }
91
+ case 'cancelled': {
92
+ const m = layout('Booking cancelled', 'This booking has been cancelled.', c);
93
+ return { to: c.booking.email, subject: `Cancelled: ${c.title}`, ...m, attachments: ics('CANCEL') };
94
+ }
95
+ case 'updated': {
96
+ const m = layout('Booking details updated', 'Please review the latest details. Use the attached calendar file to update your calendar.', c, manage);
97
+ return { to: c.booking.email, subject: `Updated: ${c.title}`, ...m, attachments: ics() };
98
+ }
99
+ case 'rescheduled': {
100
+ const m = layout('Booking moved', 'Your booking has a new time. Import the attached calendar file to update your calendar.', c, manage);
101
+ return { to: c.booking.email, subject: `New time: ${c.title} — ${c.when}`, ...m, attachments: ics() };
102
+ }
103
+ case 'reminder': {
104
+ const m = layout('Coming up soon', 'A quick reminder about your booking.', c, manage);
105
+ return { to: c.booking.email, subject: `Reminder: ${c.title} — ${c.when}`, ...m };
106
+ }
107
+ case 'staff-new': {
108
+ const pending = c.booking.status === 'pending';
109
+ const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
110
+ const answers = Object.entries(c.booking.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
111
+ const m = layout(pending ? 'New request to approve' : 'New booking', `${who}${answers ? `\n${answers}` : ''}`, c, { href: c.manageUrl, label: pending ? 'Review in the admin' : 'Open in the admin' });
112
+ return { to: [], subject: `${pending ? 'Approve' : 'New booking'}: ${c.title} — ${c.when}`, ...m, replyTo: c.booking.email || undefined };
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,77 @@
1
+ // Form ⇄ data helpers for the admin booking screens (plain HTML forms, no
2
+ // island): weekly hours rows, date overrides, questions, comma lists.
3
+ import { WEEKDAYS, type BookingQuestion, type DateOverride, type TimeRange, type WeeklyHours } from '../schema';
4
+ import { QUESTION_TYPES } from '../schema';
5
+
6
+ export const DAY_LABELS: Record<(typeof WEEKDAYS)[number], string> = { su: 'Sun', mo: 'Mon', tu: 'Tue', we: 'Wed', th: 'Thu', fr: 'Fri', sa: 'Sat' };
7
+ // Monday-first reads naturally in an hours editor
8
+ export const DAY_ORDER = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] as const;
9
+
10
+ const hhmm = (v: FormDataEntryValue | null) => {
11
+ const s = String(v ?? '').trim();
12
+ return /^\d{2}:\d{2}$/.test(s) ? s : '';
13
+ };
14
+
15
+ // hours_<day>_on, hours_<day>_s1/e1, hours_<day>_s2/e2 (second range optional)
16
+ export function hoursFromForm(form: FormData, prefix = 'hours'): WeeklyHours {
17
+ const out = {} as WeeklyHours;
18
+ for (const d of WEEKDAYS) {
19
+ const ranges: TimeRange[] = [];
20
+ if (form.get(`${prefix}_${d}_on`)) {
21
+ for (const n of [1, 2]) {
22
+ const start = hhmm(form.get(`${prefix}_${d}_s${n}`));
23
+ const end = hhmm(form.get(`${prefix}_${d}_e${n}`));
24
+ if (start && end && (end > start || end === '00:00')) ranges.push({ start, end });
25
+ }
26
+ }
27
+ out[d] = ranges;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export function overridesFromForm(form: FormData): DateOverride[] {
33
+ const out: DateOverride[] = [];
34
+ for (let i = 0; i < 60; i++) {
35
+ const date = String(form.get(`ov_${i}_date`) ?? '');
36
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
37
+ const endDate = String(form.get(`ov_${i}_end`) ?? '');
38
+ const closed = Boolean(form.get(`ov_${i}_closed`));
39
+ const s = hhmm(form.get(`ov_${i}_s`));
40
+ const e = hhmm(form.get(`ov_${i}_e`));
41
+ out.push({
42
+ date,
43
+ endDate: /^\d{4}-\d{2}-\d{2}$/.test(endDate) && endDate > date ? endDate : '',
44
+ ranges: closed || !s || !e ? [] : [{ start: s, end: e }],
45
+ label: String(form.get(`ov_${i}_label`) ?? '').slice(0, 80),
46
+ });
47
+ }
48
+ return out.sort((a, b) => a.date.localeCompare(b.date));
49
+ }
50
+
51
+ export function questionsFromForm(form: FormData): BookingQuestion[] {
52
+ const out: BookingQuestion[] = [];
53
+ for (let i = 0; i < 30; i++) {
54
+ const label = String(form.get(`qq_${i}_label`) ?? '').trim().slice(0, 200);
55
+ if (!label) continue;
56
+ const type = (QUESTION_TYPES as readonly string[]).includes(String(form.get(`qq_${i}_type`))) ? (String(form.get(`qq_${i}_type`)) as BookingQuestion['type']) : 'text';
57
+ out.push({
58
+ id: String(form.get(`qq_${i}_id`) || '') || crypto.randomUUID().slice(0, 8),
59
+ label, type,
60
+ required: Boolean(form.get(`qq_${i}_required`)),
61
+ options: type === 'select' ? list(String(form.get(`qq_${i}_options`) ?? '')) : [],
62
+ help: String(form.get(`qq_${i}_help`) ?? '').slice(0, 300),
63
+ });
64
+ }
65
+ return out;
66
+ }
67
+
68
+ export const list = (s: string) => [...new Set(s.split(/[,\n]/).map((x) => x.trim()).filter(Boolean))];
69
+ export const int = (v: FormDataEntryValue | null, min: number, max: number, fallback: number) => {
70
+ if (v === null || String(v).trim() === '') return fallback;
71
+ const n = Math.round(Number(v));
72
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
73
+ };
74
+ export const DEFAULT_HOURS: WeeklyHours = {
75
+ su: [], mo: [{ start: '09:00', end: '17:00' }], tu: [{ start: '09:00', end: '17:00' }], we: [{ start: '09:00', end: '17:00' }],
76
+ th: [{ start: '09:00', end: '17:00' }], fr: [{ start: '09:00', end: '17:00' }], sa: [],
77
+ };