@acms-subir/core 0.3.36 → 0.3.38
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 +21 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/theme-authoring.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +54 -47
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +54 -47
- package/mobile-template/recipes/rsvp/recipe.json +2 -1
- package/package.json +1 -1
- package/src/components/EventPage.astro +50 -30
- package/src/lib/booking/emails.ts +59 -44
- package/src/lib/booking/service.ts +22 -11
- package/src/lib/calendar/cards.ts +5 -3
- package/src/lib/calendar/event-words.ts +171 -0
- package/src/lib/calendar/events.ts +5 -2
- package/src/lib/calendar/render.ts +3 -1
- package/src/lib/calendar/rrule.ts +14 -18
- package/src/lib/calendar/rsvp-words.ts +121 -0
- package/src/lib/email/booking-words.ts +203 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/mobile/bookings.ts +9 -2
- package/src/lib/mobile/calendar.ts +6 -4
- package/src/lib/mobile/guest.ts +3 -2
- package/src/lib/mobile/manage.ts +7 -5
- package/src/lib/mobile/rsvp.ts +9 -1
- package/src/lib/mobile/schemas.ts +2 -0
- package/src/lib/mobile/search.ts +1 -1
- package/src/lib/mobile/site.ts +7 -3
- package/src/lib/push/service.ts +10 -0
- package/src/lib/repo/migrations.ts +5 -0
- package/src/lib/repo/sql.ts +4 -4
- package/src/lib/repo/types.ts +2 -1
- package/src/lib/schema.ts +3 -0
- package/src/pages/book/[resource]/[link].astro +8 -4
- package/src/pages/book/manage/[token].astro +48 -29
- package/src/pages/events/[slug].astro +8 -5
- package/src/theme-sdk/index.ts +3 -0
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
// The built-in event page (feature image, date block, place, button, add to
|
|
3
3
|
// calendar, share, related events). Themes replace it with
|
|
4
4
|
// templates/event.astro, which receives the same props. Every element
|
|
5
|
-
// carries a stable .pc-ev-* class for CSS overrides.
|
|
5
|
+
// carries a stable .pc-ev-* class for CSS overrides. Built-in words follow
|
|
6
|
+
// the page's language (lib/calendar/event-words.ts, also in the theme SDK).
|
|
6
7
|
import type { CalEvent, Calendar, Settings } from '../lib/schema';
|
|
7
8
|
import { CALENDAR, eventIcsUrl, eventUrl, calendarPageUrl } from '../lib/calendar/config';
|
|
8
9
|
import { addToCalendarLinks, eventTimeZone, formatOccurrence, safeColor, type Occurrence } from '../lib/calendar/events';
|
|
9
10
|
import { describeRepeat } from '../lib/calendar/rrule';
|
|
10
11
|
import { eventCardHtml, safeHref } from '../lib/calendar/cards';
|
|
11
|
-
import { siteLocale } from '../lib/dates';
|
|
12
|
+
import { siteLang, siteLocale } from '../lib/dates';
|
|
13
|
+
import { I18N, localePath, requestLocale } from '../lib/i18n';
|
|
14
|
+
import { aroundCount, rsvpStrings } from '../lib/calendar/rsvp-words';
|
|
15
|
+
import { eventStrings } from '../lib/calendar/event-words';
|
|
16
|
+
import { fill } from '../lib/live/words';
|
|
12
17
|
import { eventImage } from '../lib/calendar/images';
|
|
13
18
|
import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
|
|
14
19
|
|
|
@@ -31,12 +36,18 @@ const image = eventImage(e, settings);
|
|
|
31
36
|
const tz = eventTimeZone(e, settings);
|
|
32
37
|
const locale = siteLocale(settings);
|
|
33
38
|
const f = formatOccurrence(o, tz, locale);
|
|
34
|
-
|
|
39
|
+
// the page's language, as the layout's: its built-in words, and links that
|
|
40
|
+
// keep the visitor in it (/es/events/…)
|
|
41
|
+
const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
|
|
42
|
+
const lang = reqLocale || siteLang(settings);
|
|
43
|
+
const ew = eventStrings(lang);
|
|
44
|
+
const here = (path: string) => localePath(path, reqLocale);
|
|
45
|
+
const pageUrl = origin + here(eventUrl(e.slug));
|
|
35
46
|
const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
|
|
36
47
|
const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
|
|
37
48
|
const [y, m, d] = ymd.split('-').map(Number);
|
|
38
49
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
39
|
-
});
|
|
50
|
+
}, lang);
|
|
40
51
|
const ctaHref = safeHref(e.cta.href);
|
|
41
52
|
const mapHref = e.location.url
|
|
42
53
|
? safeHref(e.location.url)
|
|
@@ -48,7 +59,9 @@ const loc = e.location;
|
|
|
48
59
|
const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
|
|
49
60
|
const directions = showMap ? safeHref(directionsUrl(loc)) : '';
|
|
50
61
|
const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
|
|
51
|
-
const
|
|
62
|
+
const place = loc.name || loc.address;
|
|
63
|
+
const directionsLabel = place ? fill(ew.directionsTo, { place }) : ew.directionsHere;
|
|
64
|
+
const mapAlt = place ? fill(ew.mapOf, { place }) : ew.mapHere;
|
|
52
65
|
const fieldRows = CALENDAR.eventFields.filter((fd) => !fd.private && e.fields[fd.key]).map((fd) => ({ label: fd.label, value: e.fields[fd.key], type: fd.type }));
|
|
53
66
|
const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
|
|
54
67
|
const I = {
|
|
@@ -61,6 +74,13 @@ const I = {
|
|
|
61
74
|
video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
|
|
62
75
|
};
|
|
63
76
|
const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
|
|
77
|
+
// the RSVP block speaks the page's language too
|
|
78
|
+
const w = rsvpStrings(lang);
|
|
79
|
+
const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
|
|
80
|
+
const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.spotsLeft) : ['', ''];
|
|
81
|
+
const dateCancelled = fill(ew.dateCancelled, { date: f.date });
|
|
82
|
+
const showing = fill(ew.showing, { date: f.date });
|
|
83
|
+
const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: true, href: here(eventUrl(u.event.slug)) })).join('');
|
|
64
84
|
---
|
|
65
85
|
|
|
66
86
|
<article class={`pc-ev-page${cancelled ? ' is-cancelled' : ''}`}>
|
|
@@ -73,19 +93,19 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
73
93
|
{(calendars.length > 0 || visibleTags.length > 0) && (
|
|
74
94
|
<div class="pc-ev-chips">
|
|
75
95
|
{calendars.map((c) => (
|
|
76
|
-
<a class="pc-ev-chip" href={calendarPageUrl(c.slug)}><i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}</a>
|
|
96
|
+
<a class="pc-ev-chip" href={here(calendarPageUrl(c.slug))}><i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}</a>
|
|
77
97
|
))}
|
|
78
98
|
{visibleTags.map((t) => <span class="pc-ev-chip is-tag">{t}</span>)}
|
|
79
99
|
</div>
|
|
80
100
|
)}
|
|
81
101
|
<h1 class="pc-ev-title">{e.title}</h1>
|
|
82
|
-
{e.cancelled && <p class="pc-ev-cancelled"><b>
|
|
83
|
-
{!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>
|
|
102
|
+
{e.cancelled && <p class="pc-ev-cancelled"><b>{ew.eventCancelled}</b> {ew.eventCancelledNote}</p>}
|
|
103
|
+
{!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>{dateCancelled}</b> {ew.dateCancelledNote}</p>}
|
|
84
104
|
{e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
|
|
85
105
|
<div class="pc-ev-body post-content" set:html={html} />
|
|
86
106
|
</div>
|
|
87
107
|
|
|
88
|
-
<aside class="pc-ev-side" aria-label=
|
|
108
|
+
<aside class="pc-ev-side" aria-label={ew.details}>
|
|
89
109
|
<div class="pc-ev-date">
|
|
90
110
|
<span class="pc-ev-tile" aria-hidden="true"><b>{f.month}</b><span>{f.day}</span></span>
|
|
91
111
|
<div>
|
|
@@ -101,7 +121,7 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
101
121
|
{(e.location.address || mapHref) && (
|
|
102
122
|
<span>
|
|
103
123
|
{e.location.address}
|
|
104
|
-
{mapHref && <>{e.location.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ?
|
|
124
|
+
{mapHref && <>{e.location.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? ew.joinOnline : ew.map}</a></>}
|
|
105
125
|
</span>
|
|
106
126
|
)}
|
|
107
127
|
</div>
|
|
@@ -109,9 +129,9 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
109
129
|
)}
|
|
110
130
|
{showMap && (
|
|
111
131
|
<figure class="pc-ev-map">
|
|
112
|
-
<a href={directions || undefined} target="_blank" rel="noopener" aria-label={
|
|
113
|
-
<img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={
|
|
114
|
-
{loc.mapImageDark && <img class="pc-ev-map-img is-dark" src={loc.mapImageDark} alt={
|
|
132
|
+
<a href={directions || undefined} target="_blank" rel="noopener" aria-label={directionsLabel}>
|
|
133
|
+
<img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={mapAlt} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />
|
|
134
|
+
{loc.mapImageDark && <img class="pc-ev-map-img is-dark" src={loc.mapImageDark} alt={mapAlt} width={MAP_WIDTH} height={MAP_HEIGHT} loading="lazy" decoding="async" />}
|
|
115
135
|
</a>
|
|
116
136
|
{mapCredit && <figcaption class="pc-ev-map-credit">{mapCredit}</figcaption>}
|
|
117
137
|
</figure>
|
|
@@ -119,7 +139,7 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
119
139
|
{repeats && (
|
|
120
140
|
<div class="pc-ev-fact">
|
|
121
141
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.repeat} /></svg>
|
|
122
|
-
<div><b>{repeats}</b><span>
|
|
142
|
+
<div><b>{repeats}</b><span>{showing}</span></div>
|
|
123
143
|
</div>
|
|
124
144
|
)}
|
|
125
145
|
{fieldRows.map((r) => (
|
|
@@ -130,27 +150,27 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
130
150
|
))}
|
|
131
151
|
{rsvp ? (
|
|
132
152
|
<div class="pc-ev-rsvp">
|
|
133
|
-
<p class="pc-ev-going"
|
|
153
|
+
<p class="pc-ev-going">{going[0]}<b>{rsvp.going}</b>{going[1]}{rsvp.left !== null && <> · {left[0]}<b>{rsvp.left}</b>{left[1]}</>}</p>
|
|
134
154
|
{rsvp.open && (rsvp.left === null || rsvp.left > 0) ? (
|
|
135
155
|
<details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
|
|
136
|
-
<summary class="pc-ev-cta">
|
|
156
|
+
<summary class="pc-ev-cta">{w.saveSeat}</summary>
|
|
137
157
|
<form method="post">
|
|
138
158
|
<Fragment set:html={rsvp.formHtml ?? ''}/>
|
|
139
159
|
{e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
|
|
140
160
|
{e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
|
|
141
161
|
{rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
|
|
142
|
-
<label class="pc-bk-field"><span>
|
|
143
|
-
<label class="pc-bk-field"><span>
|
|
162
|
+
<label class="pc-bk-field"><span>{w.nameField}</span><input name="name" required maxlength="120" autocomplete="name" value={rsvp.values?.name} /></label>
|
|
163
|
+
<label class="pc-bk-field"><span>{w.emailField}</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
|
|
144
164
|
{rsvp.maxSeats > 1 && (
|
|
145
|
-
<label class="pc-bk-field"><span>
|
|
165
|
+
<label class="pc-bk-field"><span>{w.seatsField}</span>
|
|
146
166
|
<select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
|
|
147
167
|
</label>
|
|
148
168
|
)}
|
|
149
169
|
<Fragment set:html={rsvp.fieldsHtml ?? ''}/>
|
|
150
|
-
<button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ?
|
|
170
|
+
<button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? w.confirmSeat : w.requestSeat)}</button>
|
|
151
171
|
</form>
|
|
152
172
|
</details>
|
|
153
|
-
) : <p class="pc-ev-full">{rsvp.open ?
|
|
173
|
+
) : <p class="pc-ev-full">{rsvp.open ? w.full : w.closed}</p>}
|
|
154
174
|
</div>
|
|
155
175
|
) : e.cta.label && ctaHref && !cancelled && (
|
|
156
176
|
<a class={`pc-ev-cta${e.cta.style === 'outline' ? ' is-outline' : ''}`} href={ctaHref}>{e.cta.label}</a>
|
|
@@ -158,33 +178,33 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
158
178
|
<details class="pc-ev-add">
|
|
159
179
|
<summary>
|
|
160
180
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.cal} /></svg>
|
|
161
|
-
|
|
181
|
+
{ew.addToCalendar}
|
|
162
182
|
</summary>
|
|
163
183
|
<div class="pc-ev-add-menu">
|
|
164
184
|
<a href={links.google} target="_blank" rel="noopener">Google Calendar</a>
|
|
165
185
|
<a href={links.ics}>Apple Calendar</a>
|
|
166
186
|
<a href={links.outlook} target="_blank" rel="noopener">Outlook</a>
|
|
167
|
-
<a href={links.ics} download={`${e.slug}.ics`}>
|
|
187
|
+
<a href={links.ics} download={`${e.slug}.ics`}>{ew.downloadIcs}</a>
|
|
168
188
|
</div>
|
|
169
189
|
</details>
|
|
170
|
-
<button type="button" class="pc-ev-share" data-url={pageUrl} data-title={e.title}>
|
|
190
|
+
<button type="button" class="pc-ev-share" data-url={pageUrl} data-title={e.title} data-share={ew.share} data-copied={ew.linkCopied}>
|
|
171
191
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={I.share} /></svg>
|
|
172
|
-
<span>
|
|
192
|
+
<span>{ew.share}</span>
|
|
173
193
|
</button>
|
|
174
194
|
</aside>
|
|
175
195
|
</div>
|
|
176
196
|
|
|
177
197
|
{upcoming.length > 0 && (
|
|
178
198
|
<section class="pc-ev-more">
|
|
179
|
-
<h2>
|
|
180
|
-
<div class="pc-ev-cards" data-layout="grid" set:html={
|
|
181
|
-
<a class="pc-ev-all" href={CALENDAR.base}>
|
|
199
|
+
<h2>{ew.moreEvents}</h2>
|
|
200
|
+
<div class="pc-ev-cards" data-layout="grid" set:html={moreCards} />
|
|
201
|
+
<a class="pc-ev-all" href={here(CALENDAR.base)}>{ew.fullCalendar}</a>
|
|
182
202
|
</section>
|
|
183
203
|
)}
|
|
184
204
|
</article>
|
|
185
205
|
|
|
186
206
|
<script>
|
|
187
|
-
// share sheet where available, else copy the link
|
|
207
|
+
// share sheet where available, else copy the link (words from the page's language)
|
|
188
208
|
document.querySelectorAll<HTMLButtonElement>('.pc-ev-share').forEach((b) => b.addEventListener('click', async () => {
|
|
189
209
|
const url = b.dataset.url ?? location.href;
|
|
190
210
|
if (navigator.share) {
|
|
@@ -192,7 +212,7 @@ const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.t
|
|
|
192
212
|
}
|
|
193
213
|
await navigator.clipboard.writeText(url);
|
|
194
214
|
const label = b.querySelector('span');
|
|
195
|
-
if (label) { label.textContent = 'Link copied'; setTimeout(() => (label.textContent = 'Share'), 1400); }
|
|
215
|
+
if (label) { label.textContent = b.dataset.copied ?? 'Link copied'; setTimeout(() => (label.textContent = b.dataset.share ?? 'Share'), 1400); }
|
|
196
216
|
}));
|
|
197
217
|
// one open menu; outside click closes
|
|
198
218
|
document.addEventListener('click', (ev) => {
|
|
@@ -1,20 +1,43 @@
|
|
|
1
1
|
// Booking emails: plain, readable HTML (inline styles, no images) plus a
|
|
2
2
|
// text part. Confirmed bookings carry an .ics so one tap adds them to the
|
|
3
|
-
// booker's calendar.
|
|
3
|
+
// booker's calendar. Guest emails speak the language the booking was made
|
|
4
|
+
// in (Booking.locale, words in email/booking-words.ts) and link to its pages
|
|
5
|
+
// in it; staff emails speak the site's main language.
|
|
4
6
|
import type { Booking, BookingLink, Resource, Settings } from '../schema';
|
|
5
|
-
import { siteLocale } from '../dates';
|
|
7
|
+
import { siteLang, siteLocale } from '../dates';
|
|
6
8
|
import { escapeText, foldLine } from '../calendar/ics-write';
|
|
9
|
+
import { eventStrings } from '../calendar/event-words';
|
|
10
|
+
import { manageUrl } from '../calendar/config';
|
|
11
|
+
import { I18N, localePath } from '../i18n';
|
|
12
|
+
import { isExtraLocale } from '../translations';
|
|
13
|
+
import { fill } from '../live/words';
|
|
14
|
+
import { bookingStrings, mailCopy, type BookingStrings } from '../email/booking-words';
|
|
7
15
|
import type { Mail } from '../email';
|
|
8
16
|
|
|
9
17
|
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
10
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The language a booking speaks: its own (an extra site locale it was made
|
|
21
|
+
* in, while the site still has it), else the main language. `settings`
|
|
22
|
+
* carries it for dates, keeping the site's region when it's the same language.
|
|
23
|
+
*/
|
|
24
|
+
export function bookingLanguage(b: Pick<Booking, 'locale'>, settings: Settings): { locale: string; lang: string; settings: Settings } {
|
|
25
|
+
const locale = isExtraLocale(b.locale) ? b.locale : '';
|
|
26
|
+
const lang = locale || (I18N.enabled && I18N.defaultLocale) || siteLang(settings);
|
|
27
|
+
return { locale, lang, settings: locale && locale.split('-')[0] !== siteLang(settings) ? { ...settings, locale } : settings };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** the booking's own page, in its language: /es/book/manage/<token> */
|
|
31
|
+
export const bookingManagePath = (b: Pick<Booking, 'locale' | 'manageToken'>) =>
|
|
32
|
+
localePath(manageUrl(b.manageToken), isExtraLocale(b.locale) ? b.locale : '');
|
|
33
|
+
|
|
11
34
|
export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings: Settings, allDayish = false): string {
|
|
12
35
|
const locale = siteLocale(settings);
|
|
13
36
|
const f = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: tz });
|
|
14
37
|
if (allDayish) {
|
|
15
38
|
const last = b.end - 1;
|
|
16
39
|
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)} ·
|
|
40
|
+
return d.format(b.start) === d.format(last) ? `${f({ weekday: 'long', month: 'long', day: 'numeric' }).format(b.start)} · ${eventStrings(locale).allDay.toLowerCase()}` : `${d.format(b.start)} – ${d.format(last)}`;
|
|
18
41
|
}
|
|
19
42
|
const zone = f({ hour: 'numeric', timeZoneName: 'short' }).formatToParts(b.start).find((p) => p.type === 'timeZoneName')?.value ?? '';
|
|
20
43
|
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();
|
|
@@ -22,14 +45,15 @@ export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings
|
|
|
22
45
|
|
|
23
46
|
const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
24
47
|
|
|
25
|
-
|
|
48
|
+
// lang: the description's language ("Manage: <url>")
|
|
49
|
+
export function bookingIcs(b: Booking, title: string, location: string, url: string, host: string, method: 'REQUEST' | 'CANCEL' = 'REQUEST', options: { organizer?: string; allDay?: boolean; lang?: string } = {}): string {
|
|
26
50
|
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
51
|
const actualMethod = method === 'CANCEL' ? 'CANCEL' : options.organizer ? 'REQUEST' : 'PUBLISH';
|
|
28
52
|
const lines = [
|
|
29
53
|
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//acms-subir//Bookings//EN', `METHOD:${actualMethod}`,
|
|
30
54
|
'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
55
|
...(options.organizer ? [`ORGANIZER:mailto:${options.organizer}`, `ATTENDEE;RSVP=FALSE:mailto:${b.email}`] : []),
|
|
32
|
-
`SUMMARY:${escapeText(title)}`, ...(location ? [`LOCATION:${escapeText(location)}`] : []), `DESCRIPTION:${escapeText(
|
|
56
|
+
`SUMMARY:${escapeText(title)}`, ...(location ? [`LOCATION:${escapeText(location)}`] : []), `DESCRIPTION:${escapeText(fill(bookingStrings(options.lang).icsManage, { url }))}`,
|
|
33
57
|
`URL:${url}`, `STATUS:${method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`, `SEQUENCE:${b.notificationVersion ?? 0}`,
|
|
34
58
|
'END:VEVENT', 'END:VCALENDAR',
|
|
35
59
|
];
|
|
@@ -49,67 +73,58 @@ interface Ctx {
|
|
|
49
73
|
message?: string;
|
|
50
74
|
organizer?: string;
|
|
51
75
|
allDay?: boolean;
|
|
76
|
+
/** the words' language: the booking's for guest emails, the main one for staff */
|
|
77
|
+
lang?: string;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
|
-
function layout(heading: string, intro: string, c: Ctx, button?: { href: string; label: string }): { html: string; text: string } {
|
|
55
|
-
const rows: [string, string][] = [[
|
|
56
|
-
if (c.booking.seats > 1) rows.push([
|
|
57
|
-
const
|
|
80
|
+
function layout(heading: string, intro: string, c: Ctx, w: BookingStrings, button?: { href: string; label: string }): { html: string; text: string } {
|
|
81
|
+
const rows: [string, string][] = [[w.what, c.title], [w.when, c.when], ...(c.location ? [[w.where, c.location] as [string, string]] : [])];
|
|
82
|
+
if (c.booking.seats > 1) rows.push([w.seats, String(c.booking.seats)]);
|
|
83
|
+
const reference = fill(w.reference, { ref: c.booking.id.slice(0, 8).toUpperCase() });
|
|
84
|
+
const html = `<!doctype html><html lang="${esc(c.lang || 'en')}"><body style="margin:0;background:#f6f5f2;font-family:-apple-system,Segoe UI,Inter,Arial,sans-serif;color:#1e1f24">
|
|
58
85
|
<div style="max-width:520px;margin:0 auto;padding:28px 20px">
|
|
59
86
|
<p style="font-size:13px;color:#6b6e78;margin:0 0 18px">${esc(c.siteTitle)}</p>
|
|
60
87
|
<h1 style="font-size:22px;margin:0 0 8px">${esc(heading)}</h1>
|
|
61
88
|
<p style="font-size:15px;line-height:1.55;margin:0 0 18px;color:#3d3f47">${esc(intro)}</p>
|
|
62
89
|
<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>`)
|
|
90
|
+
.map(([k, v]) => `<tr><td style="padding:10px 14px;font-size:13px;color:#6b6e78;width:70px;border-bottom:1px solid #eeece8">${esc(k)}</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid #eeece8">${esc(v)}</td></tr>`)
|
|
64
91
|
.join('')}</table>
|
|
65
92
|
${c.message ? `<p style="font-size:14px;line-height:1.55;margin:18px 0 0">${esc(c.message)}</p>` : ''}
|
|
66
93
|
${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"
|
|
94
|
+
<p style="font-size:12px;color:#8e919a;margin:26px 0 0">${esc(reference)}</p>
|
|
68
95
|
</div></body></html>`;
|
|
69
|
-
const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '',
|
|
96
|
+
const text = [heading, '', intro, '', ...rows.map(([k, v]) => `${k}: ${v}`), ...(c.message ? ['', c.message] : []), ...(button ? ['', `${button.label}: ${button.href}`] : []), '', reference].join('\n');
|
|
70
97
|
return { html, text };
|
|
71
98
|
}
|
|
72
99
|
|
|
73
100
|
export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'staff-new';
|
|
74
101
|
|
|
75
102
|
export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
103
|
+
const w = bookingStrings(c.lang);
|
|
104
|
+
const rsvp = c.booking.kind === 'rsvp';
|
|
105
|
+
const manage = { href: c.manageUrl, label: rsvp ? w.rsvp.manage : w.manage };
|
|
106
|
+
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, lang: c.lang }) }];
|
|
107
|
+
// null: no button (nothing left to change)
|
|
108
|
+
const guest = (k: Exclude<BookingEmail, 'staff-new'>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
|
|
109
|
+
const copy = mailCopy(w, k, rsvp);
|
|
110
|
+
const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when };
|
|
111
|
+
return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(copy.heading, fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
|
|
112
|
+
};
|
|
78
113
|
switch (kind) {
|
|
79
|
-
case 'received':
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
case '
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
}
|
|
114
|
+
case 'received': return guest('received');
|
|
115
|
+
case 'confirmed': return { ...guest('confirmed', { message: c.message || c.link?.confirmMessage || '' }), attachments: ics() };
|
|
116
|
+
case 'declined': return guest('declined', {}, null);
|
|
117
|
+
case 'cancelled': return { ...guest('cancelled', {}, null), attachments: ics('CANCEL') };
|
|
118
|
+
case 'updated': return { ...guest('updated'), attachments: ics() };
|
|
119
|
+
case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
|
|
120
|
+
case 'reminder': return guest('reminder');
|
|
107
121
|
case 'staff-new': {
|
|
108
122
|
const pending = c.booking.status === 'pending';
|
|
109
123
|
const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
|
|
110
124
|
const answers = Object.entries(c.booking.answers).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
111
|
-
const
|
|
112
|
-
|
|
125
|
+
const vars = { title: c.title, when: c.when };
|
|
126
|
+
const m = layout(pending ? w.staff.pending : w.staff.booked, `${who}${answers ? `\n${answers}` : ''}`, c, w, { href: c.manageUrl, label: pending ? w.staff.review : w.staff.open });
|
|
127
|
+
return { to: [], subject: fill(pending ? w.staff.pendingSubject : w.staff.bookedSubject, vars), ...m, replyTo: c.booking.email || undefined };
|
|
113
128
|
}
|
|
114
129
|
}
|
|
115
130
|
}
|
|
@@ -9,9 +9,12 @@ import { loadFeed, expandVEvents } from '../calendar/ics';
|
|
|
9
9
|
import { emitWebhook } from '../webhooks';
|
|
10
10
|
import { parseFrom } from '../email/config';
|
|
11
11
|
import { kickEmail } from '../email/service';
|
|
12
|
-
import {
|
|
12
|
+
import { eventUrl } from '../calendar/config';
|
|
13
13
|
import { eventTimeZone, locationLine, occurrenceKey } from '../calendar/events';
|
|
14
|
-
import {
|
|
14
|
+
import { normalizePageLocale } from '../i18n';
|
|
15
|
+
import { localizeDoc } from '../translations';
|
|
16
|
+
import { bookingStrings } from '../email/booking-words';
|
|
17
|
+
import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type BookingEmail } from './emails';
|
|
15
18
|
import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
|
|
16
19
|
import { decideStatus, linkApproval } from './approval';
|
|
17
20
|
import { eventHolds } from './holds';
|
|
@@ -76,9 +79,12 @@ export async function availabilityContext(
|
|
|
76
79
|
|
|
77
80
|
// ---- notifications ---------------------------------------------------------
|
|
78
81
|
|
|
79
|
-
|
|
82
|
+
// locale: an extra language to word it in (the event's translated title and
|
|
83
|
+
// place, dates in it); settings then carry that language
|
|
84
|
+
async function describe(repo: ContentRepository, b: Booking, settings: Settings, locale = '') {
|
|
80
85
|
if (b.kind === 'rsvp') {
|
|
81
|
-
const
|
|
86
|
+
const raw = b.eventId ? await repo.getEvent(b.eventId) : null;
|
|
87
|
+
const ev = raw && locale ? localizeDoc(raw, locale) : raw;
|
|
82
88
|
const tz = ev ? eventTimeZone(ev, settings) : settings.timezone || 'UTC';
|
|
83
89
|
return {
|
|
84
90
|
resource: null, link: null, event: ev,
|
|
@@ -108,17 +114,20 @@ export function bookingJson(b: Booking, extra: { title?: string; resourceSlug?:
|
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
export async function bookingMail(repo: ContentRepository, b: Booking, kind: BookingEmail, origin: string, from = '', snapshot?: { message?: string; eventTitle?: string; eventLocation?: string; eventAllDay?: number }) {
|
|
111
|
-
const
|
|
112
|
-
|
|
117
|
+
const site = await repo.getSettings();
|
|
118
|
+
// the guest hears in the language they booked in; staff in the main one
|
|
119
|
+
const language = bookingLanguage(kind === 'staff-new' ? { locale: '' } : b, site);
|
|
120
|
+
const settings = language.settings;
|
|
121
|
+
const d = await describe(repo,b,settings,language.locale);
|
|
113
122
|
if (b.kind === 'rsvp' && !d.event && snapshot?.eventTitle) {
|
|
114
123
|
d.title = snapshot.eventTitle;
|
|
115
124
|
try { d.location = locationLine(JSON.parse(snapshot.eventLocation || '{}')); } catch { d.location = ''; }
|
|
116
125
|
d.when = whenText(b,b.timezone || settings.timezone || 'UTC',settings,!!snapshot.eventAllDay);
|
|
117
126
|
}
|
|
118
127
|
return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
|
|
119
|
-
manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin +
|
|
128
|
+
manageUrl:kind === 'staff-new' ? `${origin}/admin/calendar/bookings?b=${b.id}` : origin + bookingManagePath(b),
|
|
120
129
|
siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
|
|
121
|
-
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay) });
|
|
130
|
+
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang });
|
|
122
131
|
}
|
|
123
132
|
async function notify(repo: ContentRepository, _b: Booking, _kinds: BookingEmail[], opts: FlowOptions, _message?: string) {
|
|
124
133
|
// Repository mutations already committed their outbox jobs atomically.
|
|
@@ -142,6 +151,8 @@ export interface BookerInput {
|
|
|
142
151
|
phone?: string;
|
|
143
152
|
answers?: Record<string, string>;
|
|
144
153
|
formVersion?: string;
|
|
154
|
+
/** the language they booked in (the page's or the app's); left out = the main language */
|
|
155
|
+
locale?: string;
|
|
145
156
|
}
|
|
146
157
|
|
|
147
158
|
export function validateBooker(link: Pick<BookingLink, 'questions' | 'askPhone'> | null, input: BookerInput): { name: string; email: string; phone: string; answers: Record<string, string> } {
|
|
@@ -236,7 +247,7 @@ export async function createResourceBooking(
|
|
|
236
247
|
kind: 'booking', resourceId: resource.id, linkId: link.id, eventId: '', status,
|
|
237
248
|
start: args.start, end: args.end, ...holdOf(link, args.start, args.end),
|
|
238
249
|
timezone: resourceZone(resource, settings.timezone), ...booker, seats: 1, notes: '',
|
|
239
|
-
manageToken: token(), source: args.source ?? 'web',
|
|
250
|
+
manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
240
251
|
}, { idempotency: opts.idempotency });
|
|
241
252
|
if (!b) throw new BookingError(409, 'That time was just taken. Please pick another.');
|
|
242
253
|
await notify(repo, b, [status === 'confirmed' ? 'confirmed' : 'received', 'staff-new'], opts);
|
|
@@ -278,7 +289,7 @@ export async function createRsvp(
|
|
|
278
289
|
const b = await repo.createBooking({
|
|
279
290
|
kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
|
|
280
291
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
281
|
-
formResponse, notes: '', manageToken: token(), source: args.source ?? 'web',
|
|
292
|
+
formResponse, notes: '', manageToken: token(), source: args.source ?? 'web', locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
282
293
|
}, { capacity: e.rsvp.capacity, idempotency: opts.idempotency });
|
|
283
294
|
if (!b) throw new BookingError(409, e.rsvp.capacity ? 'Sorry — that’s more seats than are left.' : 'Couldn’t save your RSVP.');
|
|
284
295
|
await notify(repo, b, [b.status === 'confirmed' ? 'confirmed' : 'received'], opts);
|
|
@@ -377,7 +388,7 @@ export async function decideOverdue(repo: ContentRepository, opts: FlowOptions):
|
|
|
377
388
|
if (!rules.deadlineHours || b.createdAt + rules.deadlineHours * 3_600_000 > now) continue;
|
|
378
389
|
try {
|
|
379
390
|
await setBookingStatus(repo, b, rules.deadlineAction === 'decline' ? 'declined' : 'confirmed', opts,
|
|
380
|
-
rules.deadlineAction === 'decline' ?
|
|
391
|
+
rules.deadlineAction === 'decline' ? bookingStrings(bookingLanguage(b, settings).lang).lateDecline : undefined);
|
|
381
392
|
decided++;
|
|
382
393
|
} catch { /* changed meanwhile — next sweep sees the new state */ }
|
|
383
394
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// .pc-ev-card* classes are the styling API, like .pc-cal-* for the grid.
|
|
4
4
|
import type { ContentRepository } from '../repo/types';
|
|
5
5
|
import { tagFilter, type CalEvent, type Settings } from '../schema';
|
|
6
|
-
import { siteLocale } from '../dates';
|
|
6
|
+
import { siteLang, siteLocale } from '../dates';
|
|
7
|
+
import { eventStrings } from './event-words';
|
|
7
8
|
import { eventImage } from './images';
|
|
8
9
|
import { eventUrl } from './config';
|
|
9
10
|
import { eventIsPublic, featuredOccurrence, formatOccurrence, locationLine, type Occurrence } from './events';
|
|
@@ -37,7 +38,8 @@ export function eventCardHtml(
|
|
|
37
38
|
const f = formatOccurrence(o, tz, siteLocale(settings));
|
|
38
39
|
const href = opts.href ?? eventUrl(e.slug);
|
|
39
40
|
const h = opts.heading ?? 'h3';
|
|
40
|
-
// "Sat, Oct 3 · 11:00 AM", "Sat, Oct 3 · All day", "Sat, Oct 24 – Sun, Oct 25"
|
|
41
|
+
// "Sat, Oct 3 · 11:00 AM", "Sat, Oct 3 · All day", "Sat, Oct 24 – Sun, Oct 25";
|
|
42
|
+
// the Cancelled flag speaks the dates' language
|
|
41
43
|
const when = f.range;
|
|
42
44
|
const image = eventImage(e, settings);
|
|
43
45
|
const media = opts.showImage
|
|
@@ -53,7 +55,7 @@ export function eventCardHtml(
|
|
|
53
55
|
: '';
|
|
54
56
|
return (
|
|
55
57
|
`<article class="pc-ev-card${cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
|
|
56
|
-
`<div class="pc-ev-card-when">${cancelled ?
|
|
58
|
+
`<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : ''}${esc(when)}</div>` +
|
|
57
59
|
`<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || 'Untitled event')}</a></${h}>` +
|
|
58
60
|
(e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
|
|
59
61
|
(loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
|