@acms-subir/core 0.3.37 → 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 +11 -0
- package/cli/migrations.generated.mjs +5 -0
- package/docs/theme-authoring.md +1 -1
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +7 -4
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +7 -4
- package/mobile-template/recipes/rsvp/recipe.json +2 -1
- package/package.json +1 -1
- package/src/components/EventPage.astro +39 -25
- 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/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/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 +22 -14
- package/src/pages/events/[slug].astro +2 -2
- package/src/theme-sdk/index.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.38
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8d85520: `GET /api/v1/site?locale=es` returns the menu the way the `/es` header shows it: links to the Spanish pages, and the automatic menu's own words translated ("Inicio", "Enlaces"). Labels you wrote in Navigation stay as written. Events in the app API carry `notify.changes`, the push topics that hear when the event's date, time or place changes (Admin → Mobile app → When an event changes), or `[]`, so an app only promises "we'll let you know if it changes" when it's true. The RSVP panel recipe takes `children`, shown under the person's RSVP.
|
|
8
|
+
- 7b71457: Event pages now follow the page's language, in English, Spanish, French, German and Portuguese (other languages stay in English). On `/es` event pages, and on a site whose main language is Spanish, a cancelled date said "Cancelled on … Other dates go ahead as usual." in English; now it says "Cancelado el domingo, 25 de octubre de 2026. Las demás fechas siguen como siempre.", so the "Cancelado el …" push opens a page that matches it. The same goes for a cancelled event, the "Cancelado" flag on event cards and in the calendar's pop-up, "Todo el día", the repeat line ("Todos los domingos", "El primer domingo de cada mes", "Cada 2 semanas, los lunes, 6 veces"), Agregar al calendario, Descargar .ics, Compartir and Enlace copiado, Más eventos, Ver el calendario completo, the map's labels and "Mostrando el …". Links on a `/es` event page (more events, the calendar, sharing) stay in Spanish. Theme event templates can use the same words with `eventStrings` and `describeRepeat` from `@acms-subir/core/theme`.
|
|
9
|
+
|
|
10
|
+
Booking and RSVP emails now speak the language the person used: the page's on the website (RSVPs from `/es` event pages, and bookings from `/es/book/…`, whose pages stay in English), the app's `locale` on `POST /api/v1/bookings`, and the site's main language for bookings staff add. The subject, message, details, dates and button are in that language, RSVPs show the event's translated title and place, and every link (the booking's own page, the calendar file) keeps it, such as `/es/book/manage/…`. Spanish emails say _usted_, like the privacy notice. In the app API, `manageUrl` (and the event's `url` from `GET /api/v1/bookings/manage`) points to the page in that language too. Staff notifications stay in the main language. Older bookings, and backups made before this release, use the main language. On a site whose main language isn't English, booking emails without a language (and staff notifications) now come in that language instead of English. Migration 60 adds `bookings.locale` (default `''`, the main language).
|
|
11
|
+
|
|
12
|
+
A room booking's own page (`/book/manage/…`, the page booking emails link to) now follows the page's language too, like an RSVP's: "Tu reservación está confirmada", Cambiar horario, Cancelar reservación and so on. Opened without `/es`, it shows the main language.
|
|
13
|
+
|
|
3
14
|
## 0.3.37
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
|
@@ -1111,6 +1111,11 @@ var MIGRATIONS = [
|
|
|
1111
1111
|
{ version: 59, statements: [
|
|
1112
1112
|
`ALTER TABLE push_queue ADD COLUMN payload TEXT`,
|
|
1113
1113
|
`ALTER TABLE push_queue ADD COLUMN due_at INTEGER`
|
|
1114
|
+
] },
|
|
1115
|
+
// the language a booking or RSVP was made in (Booking.locale), so its
|
|
1116
|
+
// emails and links follow it; '' = the main language, so older rows need no backfill
|
|
1117
|
+
{ version: 60, statements: [
|
|
1118
|
+
`ALTER TABLE bookings ADD COLUMN locale TEXT NOT NULL DEFAULT ''`
|
|
1114
1119
|
] }
|
|
1115
1120
|
];
|
|
1116
1121
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
package/docs/theme-authoring.md
CHANGED
|
@@ -32,7 +32,7 @@ const { page, posts } = Astro.props;
|
|
|
32
32
|
{posts.map(post => <PostCard href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} />)}
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
|
|
35
|
+
Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. For an event template, `eventStrings(lang)` gives the built-in event page's words (Cancelled, Add to calendar, Share, …) and `describeRepeat(event.rrule, event.start, formatDate?, lang)` its repeat line ("Weekly on Sunday", "Todos los domingos"), in English, Spanish, French, German or Portuguese, otherwise English. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
|
|
36
36
|
|
|
37
37
|
### Upcoming events in coded templates
|
|
38
38
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useEffect, useState } from 'react';
|
|
1
|
+
import { Fragment, useEffect, useState, type ReactNode } from 'react';
|
|
2
2
|
import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
|
|
3
3
|
import * as Crypto from 'expo-crypto';
|
|
4
4
|
import * as SecureStore from 'expo-secure-store';
|
|
@@ -37,7 +37,9 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
|
|
|
37
37
|
// a count in bold inside its words ("{count} going")
|
|
38
38
|
const bold = (template: string, n: string) => template.split('{count}').map((part, i) => (i ? <Fragment key={i}><Text style={{ fontWeight: '700' }}>{n}</Text>{part}</Fragment> : part));
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// children: shown under the person's RSVP (not a declined one), e.g. an offer
|
|
41
|
+
// to get notified if the date, time or place changes
|
|
42
|
+
export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
|
|
41
43
|
const t = useTheme();
|
|
42
44
|
const words = useStrings();
|
|
43
45
|
const { locale } = useLocale();
|
|
@@ -113,7 +115,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
113
115
|
const who = mine?.booking.email ?? made!.email;
|
|
114
116
|
const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
|
|
115
117
|
const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
|
|
116
|
-
return (
|
|
118
|
+
return (<>
|
|
117
119
|
<View style={[t.styles.card, { gap: 8 }]}>
|
|
118
120
|
<Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
|
|
119
121
|
<Text style={[t.styles.text.label, { color: t.colors.ink }]}>
|
|
@@ -136,7 +138,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
136
138
|
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
|
|
137
139
|
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
|
|
138
140
|
</View>
|
|
139
|
-
|
|
141
|
+
{state !== 'declined' && children}
|
|
142
|
+
</>);
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
if (s.reason === 'disabled') return null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useEffect, useState } from 'react';
|
|
1
|
+
import { Fragment, useEffect, useState, type ReactNode } from 'react';
|
|
2
2
|
import { ActivityIndicator, Platform, Pressable, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
|
|
3
3
|
import * as Crypto from 'expo-crypto';
|
|
4
4
|
import * as SecureStore from 'expo-secure-store';
|
|
@@ -37,7 +37,9 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
|
|
|
37
37
|
// a count in bold inside its words ("{count} going")
|
|
38
38
|
const bold = (template: string, n: string) => template.split('{count}').map((part, i) => (i ? <Fragment key={i}><Text style={{ fontWeight: '700' }}>{n}</Text>{part}</Fragment> : part));
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// children: shown under the person's RSVP (not a declined one), e.g. an offer
|
|
41
|
+
// to get notified if the date, time or place changes
|
|
42
|
+
export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
|
|
41
43
|
const t = useTheme();
|
|
42
44
|
const words = useStrings();
|
|
43
45
|
const { locale } = useLocale();
|
|
@@ -113,7 +115,7 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
113
115
|
const who = mine?.booking.email ?? made!.email;
|
|
114
116
|
const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
|
|
115
117
|
const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
|
|
116
|
-
return (
|
|
118
|
+
return (<>
|
|
117
119
|
<View style={[t.styles.card, { gap: 8 }]}>
|
|
118
120
|
<Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
|
|
119
121
|
<Text style={[t.styles.text.label, { color: t.colors.ink }]}>
|
|
@@ -136,7 +138,8 @@ export function RsvpPanel({ slug, start }: { slug: string; start: string }) {
|
|
|
136
138
|
{cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
|
|
137
139
|
{!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
|
|
138
140
|
</View>
|
|
139
|
-
|
|
141
|
+
{state !== 'declined' && children}
|
|
142
|
+
</>);
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
if (s.reason === 'disabled') return null;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"next": [
|
|
11
11
|
"Mount it on your event screen where the date is known: <RsvpPanel slug={occurrence.event.slug} start={occurrence.start} /> from '../../components/RsvpPanel', with start exactly as useEvents returns it (the UTC ISO string, e.g. pass o.start through router params), and only when occurrence.event.rsvp?.enabled",
|
|
12
12
|
"The screen should scroll and avoid the keyboard: wrap it in <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}> with <ScrollView keyboardShouldPersistTaps=\"handled\">",
|
|
13
|
-
"RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too"
|
|
13
|
+
"RSVPs need only the calendar (useSite().data.features.rsvp). Each RSVP's private manage token stays in expo-secure-store (never a URL or log): the panel shows the person's RSVP and lets them cancel it; the confirmation email's link works too",
|
|
14
|
+
"Anything you pass as children shows under the person's RSVP (not a declined one), e.g. an offer to get notified if the date, time or place changes: <RsvpPanel slug={…} start={…}><MyOffer /></RsvpPanel>. With push on, occurrence.event.notify.changes lists the topics that hear about changes ([] = none)"
|
|
14
15
|
]
|
|
15
16
|
}
|
package/package.json
CHANGED
|
@@ -2,15 +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
12
|
import { siteLang, siteLocale } from '../lib/dates';
|
|
12
|
-
import { I18N, requestLocale } from '../lib/i18n';
|
|
13
|
+
import { I18N, localePath, requestLocale } from '../lib/i18n';
|
|
13
14
|
import { aroundCount, rsvpStrings } from '../lib/calendar/rsvp-words';
|
|
15
|
+
import { eventStrings } from '../lib/calendar/event-words';
|
|
16
|
+
import { fill } from '../lib/live/words';
|
|
14
17
|
import { eventImage } from '../lib/calendar/images';
|
|
15
18
|
import { directionsUrl, mapAttribution, MAP_HEIGHT, MAP_WIDTH } from '../lib/maps/service';
|
|
16
19
|
|
|
@@ -33,12 +36,18 @@ const image = eventImage(e, settings);
|
|
|
33
36
|
const tz = eventTimeZone(e, settings);
|
|
34
37
|
const locale = siteLocale(settings);
|
|
35
38
|
const f = formatOccurrence(o, tz, locale);
|
|
36
|
-
|
|
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));
|
|
37
46
|
const links = addToCalendarLinks(e, o, { pageUrl, icsUrl: eventIcsUrl(e.slug), tz });
|
|
38
47
|
const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
|
|
39
48
|
const [y, m, d] = ymd.split('-').map(Number);
|
|
40
49
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
41
|
-
});
|
|
50
|
+
}, lang);
|
|
42
51
|
const ctaHref = safeHref(e.cta.href);
|
|
43
52
|
const mapHref = e.location.url
|
|
44
53
|
? safeHref(e.location.url)
|
|
@@ -50,7 +59,9 @@ const loc = e.location;
|
|
|
50
59
|
const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
|
|
51
60
|
const directions = showMap ? safeHref(directionsUrl(loc)) : '';
|
|
52
61
|
const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
|
|
53
|
-
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;
|
|
54
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 }));
|
|
55
66
|
const visibleTags = e.tags.filter((t) => !t.startsWith('#'));
|
|
56
67
|
const I = {
|
|
@@ -63,10 +74,13 @@ const I = {
|
|
|
63
74
|
video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
|
|
64
75
|
};
|
|
65
76
|
const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(e.location.url);
|
|
66
|
-
// the RSVP block speaks the page's language
|
|
67
|
-
const w = rsvpStrings(
|
|
77
|
+
// the RSVP block speaks the page's language too
|
|
78
|
+
const w = rsvpStrings(lang);
|
|
68
79
|
const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
|
|
69
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('');
|
|
70
84
|
---
|
|
71
85
|
|
|
72
86
|
<article class={`pc-ev-page${cancelled ? ' is-cancelled' : ''}`}>
|
|
@@ -79,19 +93,19 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
|
|
|
79
93
|
{(calendars.length > 0 || visibleTags.length > 0) && (
|
|
80
94
|
<div class="pc-ev-chips">
|
|
81
95
|
{calendars.map((c) => (
|
|
82
|
-
<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>
|
|
83
97
|
))}
|
|
84
98
|
{visibleTags.map((t) => <span class="pc-ev-chip is-tag">{t}</span>)}
|
|
85
99
|
</div>
|
|
86
100
|
)}
|
|
87
101
|
<h1 class="pc-ev-title">{e.title}</h1>
|
|
88
|
-
{e.cancelled && <p class="pc-ev-cancelled"><b>
|
|
89
|
-
{!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>}
|
|
90
104
|
{e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
|
|
91
105
|
<div class="pc-ev-body post-content" set:html={html} />
|
|
92
106
|
</div>
|
|
93
107
|
|
|
94
|
-
<aside class="pc-ev-side" aria-label=
|
|
108
|
+
<aside class="pc-ev-side" aria-label={ew.details}>
|
|
95
109
|
<div class="pc-ev-date">
|
|
96
110
|
<span class="pc-ev-tile" aria-hidden="true"><b>{f.month}</b><span>{f.day}</span></span>
|
|
97
111
|
<div>
|
|
@@ -107,7 +121,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
|
|
|
107
121
|
{(e.location.address || mapHref) && (
|
|
108
122
|
<span>
|
|
109
123
|
{e.location.address}
|
|
110
|
-
{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></>}
|
|
111
125
|
</span>
|
|
112
126
|
)}
|
|
113
127
|
</div>
|
|
@@ -115,9 +129,9 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
|
|
|
115
129
|
)}
|
|
116
130
|
{showMap && (
|
|
117
131
|
<figure class="pc-ev-map">
|
|
118
|
-
<a href={directions || undefined} target="_blank" rel="noopener" aria-label={
|
|
119
|
-
<img class:list={['pc-ev-map-img', { 'is-light': Boolean(loc.mapImageDark) }]} src={loc.mapImage} alt={
|
|
120
|
-
{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" />}
|
|
121
135
|
</a>
|
|
122
136
|
{mapCredit && <figcaption class="pc-ev-map-credit">{mapCredit}</figcaption>}
|
|
123
137
|
</figure>
|
|
@@ -125,7 +139,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
|
|
|
125
139
|
{repeats && (
|
|
126
140
|
<div class="pc-ev-fact">
|
|
127
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>
|
|
128
|
-
<div><b>{repeats}</b><span>
|
|
142
|
+
<div><b>{repeats}</b><span>{showing}</span></div>
|
|
129
143
|
</div>
|
|
130
144
|
)}
|
|
131
145
|
{fieldRows.map((r) => (
|
|
@@ -164,33 +178,33 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
|
|
|
164
178
|
<details class="pc-ev-add">
|
|
165
179
|
<summary>
|
|
166
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>
|
|
167
|
-
|
|
181
|
+
{ew.addToCalendar}
|
|
168
182
|
</summary>
|
|
169
183
|
<div class="pc-ev-add-menu">
|
|
170
184
|
<a href={links.google} target="_blank" rel="noopener">Google Calendar</a>
|
|
171
185
|
<a href={links.ics}>Apple Calendar</a>
|
|
172
186
|
<a href={links.outlook} target="_blank" rel="noopener">Outlook</a>
|
|
173
|
-
<a href={links.ics} download={`${e.slug}.ics`}>
|
|
187
|
+
<a href={links.ics} download={`${e.slug}.ics`}>{ew.downloadIcs}</a>
|
|
174
188
|
</div>
|
|
175
189
|
</details>
|
|
176
|
-
<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}>
|
|
177
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>
|
|
178
|
-
<span>
|
|
192
|
+
<span>{ew.share}</span>
|
|
179
193
|
</button>
|
|
180
194
|
</aside>
|
|
181
195
|
</div>
|
|
182
196
|
|
|
183
197
|
{upcoming.length > 0 && (
|
|
184
198
|
<section class="pc-ev-more">
|
|
185
|
-
<h2>
|
|
186
|
-
<div class="pc-ev-cards" data-layout="grid" set:html={
|
|
187
|
-
<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>
|
|
188
202
|
</section>
|
|
189
203
|
)}
|
|
190
204
|
</article>
|
|
191
205
|
|
|
192
206
|
<script>
|
|
193
|
-
// share sheet where available, else copy the link
|
|
207
|
+
// share sheet where available, else copy the link (words from the page's language)
|
|
194
208
|
document.querySelectorAll<HTMLButtonElement>('.pc-ev-share').forEach((b) => b.addEventListener('click', async () => {
|
|
195
209
|
const url = b.dataset.url ?? location.href;
|
|
196
210
|
if (navigator.share) {
|
|
@@ -198,7 +212,7 @@ const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.s
|
|
|
198
212
|
}
|
|
199
213
|
await navigator.clipboard.writeText(url);
|
|
200
214
|
const label = b.querySelector('span');
|
|
201
|
-
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); }
|
|
202
216
|
}));
|
|
203
217
|
// one open menu; outside click closes
|
|
204
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>` : '') +
|