@acms-subir/core 0.3.40 → 0.3.42
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 +24 -0
- package/cli/migrations.generated.mjs +15 -0
- package/docs/mobile.md +12 -2
- package/integration.mjs +4 -0
- package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/push/files/app/link-phone.tsx +43 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +14 -4
- package/mobile-template/recipes/push/recipe.json +4 -2
- package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
- package/mobile-template/recipes/rsvp/recipe.json +2 -2
- package/package.json +1 -1
- package/src/actions/index.ts +1 -1
- package/src/components/Announcements.tsx +11 -2
- package/src/components/EventPage.astro +16 -8
- package/src/components/MobileApp.tsx +20 -8
- package/src/components/TestPhones.tsx +177 -0
- package/src/components/editor/EventPanel.tsx +2 -2
- package/src/components/editor/PostEditor.tsx +1 -1
- package/src/components/editor/event/RegistrationCard.tsx +5 -0
- package/src/components/editor/event/registration.ts +1 -1
- package/src/lib/announcements/api.ts +19 -2
- package/src/lib/announcements/service.ts +1 -1
- package/src/lib/booking/agent.ts +1 -1
- package/src/lib/booking/emails.ts +20 -5
- package/src/lib/booking/service.ts +148 -14
- package/src/lib/calendar/rsvp-words.ts +69 -8
- package/src/lib/content-service.ts +11 -6
- package/src/lib/drafts.ts +2 -2
- package/src/lib/editor/bookings.ts +5 -3
- package/src/lib/editor/documents.ts +3 -2
- package/src/lib/editor/schemas.ts +1 -1
- package/src/lib/email/booking-words.ts +26 -2
- package/src/lib/email/service.ts +14 -2
- package/src/lib/email/store.ts +6 -2
- package/src/lib/email/templates.ts +20 -0
- package/src/lib/forms/events.ts +4 -2
- package/src/lib/legal/app-privacy.ts +4 -4
- package/src/lib/legal/text-en.ts +2 -2
- package/src/lib/legal/text-es.ts +2 -2
- package/src/lib/mobile/bookings.ts +4 -3
- package/src/lib/mobile/guest.ts +2 -1
- package/src/lib/mobile/manage.ts +17 -3
- package/src/lib/mobile/openapi.ts +22 -5
- package/src/lib/mobile/rsvp.ts +10 -5
- package/src/lib/mobile/schemas.ts +14 -3
- package/src/lib/push/api.ts +37 -8
- package/src/lib/push/service.ts +6 -2
- package/src/lib/push/staff.ts +162 -0
- package/src/lib/push/store.ts +49 -2
- package/src/lib/push/types.ts +4 -1
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +57 -14
- package/src/lib/repo/types.ts +11 -4
- package/src/lib/scheduler.ts +3 -1
- package/src/lib/schema.ts +10 -2
- package/src/pages/admin/calendar/bookings.astro +57 -13
- package/src/pages/api/announcements.ts +2 -2
- package/src/pages/api/calendar/bookings/index.ts +1 -1
- package/src/pages/api/calendar/docs.ts +2 -2
- package/src/pages/api/push/admin.ts +1 -1
- package/src/pages/api/push/phones.ts +25 -0
- package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
- package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -0
- package/src/pages/book/manage/[token].astro +57 -15
- package/src/pages/events/[slug].astro +3 -2
- package/src/styles/admin.css +3 -1
- package/src/styles/site.css +10 -1
- package/src/styles/test-phones.css +30 -0
- package/src/theme-sdk/index.ts +1 -1
|
@@ -21,8 +21,8 @@ export interface EventMeta {
|
|
|
21
21
|
calendarIds: string[];
|
|
22
22
|
fields: Record<string, string>;
|
|
23
23
|
// closesAt: wall time like start; closeBefore: minutes before each date (0 = until it ends);
|
|
24
|
-
// notifyStaff: the hosts get an email about each new or cancelled RSVP
|
|
25
|
-
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; form?: FormDefinition };
|
|
24
|
+
// notifyStaff: the hosts get an email about each new or cancelled RSVP; waitlist: a full date takes places in line
|
|
25
|
+
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean; form?: FormDefinition };
|
|
26
26
|
roomHold: RoomHoldValue;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -169,7 +169,7 @@ const NO_MAPS: MapsStatus = { enabled: false, provider: '', label: '', search: f
|
|
|
169
169
|
const NO_EVENT: EventMeta = {
|
|
170
170
|
start: '', end: '', allDay: false, timezone: '', rrule: '', exdates: [], cancelled: false,
|
|
171
171
|
location: NO_LOCATION, cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
|
|
172
|
-
rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true },
|
|
172
|
+
rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true, waitlist: false },
|
|
173
173
|
roomHold: NO_HOLD,
|
|
174
174
|
};
|
|
175
175
|
|
|
@@ -77,6 +77,11 @@ export function RegistrationCard({ meta, onChange, eventId, title = '' }: { even
|
|
|
77
77
|
<span><b>Email the hosts</b><small>About each new RSVP and cancellation. Without hosts, the contact email in Settings → Legal gets them</small></span>
|
|
78
78
|
<input type="checkbox" role="switch" className="pe-switch" checked={meta.rsvp.notifyStaff !== false} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, notifyStaff: e.target.checked } })} />
|
|
79
79
|
</label>
|
|
80
|
+
{/* a waitlist needs a limit to be full */}
|
|
81
|
+
<label className="pe-switch-row">
|
|
82
|
+
<span><b>Offer a waitlist when it’s full</b><small>{meta.rsvp.capacity ? 'People join a line. When a seat opens, the next person is emailed and it’s held for them for 24 hours' : 'Set a number of seats to use it'}</small></span>
|
|
83
|
+
<input type="checkbox" role="switch" className="pe-switch" disabled={!meta.rsvp.capacity} checked={Boolean(meta.rsvp.waitlist && meta.rsvp.capacity)} onChange={(e) => onChange({ rsvp: { ...meta.rsvp, waitlist: e.target.checked } })} />
|
|
84
|
+
</label>
|
|
80
85
|
</div>
|
|
81
86
|
<Deadline meta={meta} onChange={onChange} />
|
|
82
87
|
<div className="pe-evr-form">
|
|
@@ -9,7 +9,7 @@ export type RegistrationMode = 'none' | 'link' | 'rsvp';
|
|
|
9
9
|
|
|
10
10
|
interface RegistrationData {
|
|
11
11
|
cta: EventCta;
|
|
12
|
-
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean };
|
|
12
|
+
rsvp: { enabled: boolean; capacity: number; autoConfirm: boolean; maxSeats: number; closesAt?: string | null; closeBefore?: number; notifyStaff?: boolean; waitlist?: boolean };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function registrationMode({ cta, rsvp }: RegistrationData): RegistrationMode {
|
|
@@ -10,8 +10,9 @@ import { localeOnlyQuery, localeQuery, mobileLocale } from '../mobile/locale';
|
|
|
10
10
|
import { PUSH } from '../push/config';
|
|
11
11
|
import { deviceLocale, knownTopics, pushTopics, siteOriginOf, sitePath } from '../push/service';
|
|
12
12
|
import { linkTargets } from '../push/api';
|
|
13
|
+
import { sendTest } from '../push/staff';
|
|
13
14
|
import { ANNOUNCEMENTS } from './config';
|
|
14
|
-
import { announcementJson, isLive, publishAnnouncement, type PublishOptions } from './service';
|
|
15
|
+
import { URGENT_CHANNEL, announcementJson, isLive, localizeAnnouncement, publishAnnouncement, type PublishOptions } from './service';
|
|
15
16
|
import { announcementStrings } from './words';
|
|
16
17
|
import { ANNOUNCEMENT_KINDS, AnnouncementInput, AnnouncementKind, LIMITS, type Announcement, type AnnouncementWords } from './types';
|
|
17
18
|
|
|
@@ -115,6 +116,8 @@ export const AdminAnnouncementAction = z.discriminatedUnion('action', [
|
|
|
115
116
|
z.object({ action: z.literal('archive'), id }).strict(),
|
|
116
117
|
z.object({ action: z.literal('restore'), id }).strict(),
|
|
117
118
|
z.object({ action: z.literal('delete'), id }).strict(),
|
|
119
|
+
// "Send test to my phone": the draft as it stands, in the preview's language, to the sender's linked phones
|
|
120
|
+
z.object({ action: z.literal('test'), announcement: AnnouncementInput, locale: z.string().max(35).optional() }).strict(),
|
|
118
121
|
]);
|
|
119
122
|
export type AdminAnnouncementAction = z.infer<typeof AdminAnnouncementAction>;
|
|
120
123
|
|
|
@@ -156,7 +159,7 @@ export function scheduleProblem(a: Pick<Announcement, 'title' | 'message' | 'lis
|
|
|
156
159
|
return null;
|
|
157
160
|
}
|
|
158
161
|
|
|
159
|
-
export interface AdminContext extends PublishOptions { url: URL; author: string }
|
|
162
|
+
export interface AdminContext extends PublishOptions { url: URL; author: string; userId?: string }
|
|
160
163
|
|
|
161
164
|
export async function adminAnnouncementAction(repo: ContentRepository, input: AdminAnnouncementAction, ctx: AdminContext) {
|
|
162
165
|
requireAnnouncements();
|
|
@@ -166,6 +169,7 @@ export async function adminAnnouncementAction(repo: ContentRepository, input: Ad
|
|
|
166
169
|
return { recipients: await countRecipients(repo, input.kind === 'urgent' ? ['*'] : await checkAudience(repo, input.audience)) };
|
|
167
170
|
}
|
|
168
171
|
if (input.action === 'save') return save(repo, input, ctx, now);
|
|
172
|
+
if (input.action === 'test') return test(repo, input, ctx);
|
|
169
173
|
const a = await repo.announcements.get(input.id);
|
|
170
174
|
if (!a) throw new ApiError(404, 'Announcement not found.');
|
|
171
175
|
if (input.action === 'archive') {
|
|
@@ -184,6 +188,19 @@ export async function adminAnnouncementAction(repo: ContentRepository, input: Ad
|
|
|
184
188
|
return { deleted: a.id };
|
|
185
189
|
}
|
|
186
190
|
|
|
191
|
+
// a tap opens the button's link, else the app (a draft has no page yet); urgent uses its channel so it looks the part
|
|
192
|
+
async function test(repo: ContentRepository, input: Extract<AdminAnnouncementAction, { action: 'test' }>, ctx: AdminContext) {
|
|
193
|
+
if (!PUSH.enabled) throw new ApiError(400, 'Push notifications are off on this site (acms({ mobile: { push } })).');
|
|
194
|
+
const a = input.announcement;
|
|
195
|
+
const w = localizeAnnouncement({ ...a, translations: cleanTranslations(a.translations) }, input.locale ?? null);
|
|
196
|
+
if (!w.title) throw new ApiError(400, 'Add a title.');
|
|
197
|
+
const notification = await sendTest(repo, {
|
|
198
|
+
userId: ctx.userId ?? '', author: ctx.author, title: w.title, body: w.message, url: a.link, locale: input.locale ?? null,
|
|
199
|
+
channel: a.kind === 'urgent' ? URGENT_CHANNEL : undefined, data: { announcementKind: a.kind },
|
|
200
|
+
}, { ...ctx, origin: ctx.origin ?? siteOriginOf(await repo.getSettings(), ctx.url.origin) });
|
|
201
|
+
return { notification };
|
|
202
|
+
}
|
|
203
|
+
|
|
187
204
|
async function save(repo: ContentRepository, input: Extract<AdminAnnouncementAction, { action: 'save' }>, ctx: AdminContext, now: number) {
|
|
188
205
|
const body = input.announcement;
|
|
189
206
|
const existing = input.id ? await repo.announcements.get(input.id) : null;
|
|
@@ -15,7 +15,7 @@ import type { Announcement, AnnouncementWords } from './types';
|
|
|
15
15
|
export const URGENT_CHANNEL = 'urgent';
|
|
16
16
|
|
|
17
17
|
/** Each field in `locale`, falling back to the main words; `translated` = the language has its own title. */
|
|
18
|
-
export function localizeAnnouncement(a: Announcement, locale: string | null | undefined): AnnouncementWords & { translated: boolean } {
|
|
18
|
+
export function localizeAnnouncement(a: AnnouncementWords & Pick<Announcement, 'translations'>, locale: string | null | undefined): AnnouncementWords & { translated: boolean } {
|
|
19
19
|
const main = { title: a.title, message: a.message, details: a.details, buttonLabel: a.buttonLabel };
|
|
20
20
|
if (!locale || locale === I18N.defaultLocale) return { ...main, translated: true };
|
|
21
21
|
const t = a.translations[locale] ?? a.translations[locale.toLowerCase().split('-')[0]];
|
package/src/lib/booking/agent.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const BookingToolInputs = [
|
|
|
18
18
|
minutes: z.number().int().optional(),
|
|
19
19
|
}).strict() }).strict(),
|
|
20
20
|
z.object({ tool: z.literal('list_bookings'), arguments: z.object({
|
|
21
|
-
status: z.enum(['pending', 'confirmed', 'declined', 'cancelled']).optional(), resource: z.string().optional(),
|
|
21
|
+
status: z.enum(['pending', 'confirmed', 'declined', 'cancelled', 'waitlisted', 'offered', 'expired']).optional(), resource: z.string().optional(),
|
|
22
22
|
from: z.string().optional(), limit: z.number().int().min(1).max(100).default(25),
|
|
23
23
|
}).strict() }).strict(),
|
|
24
24
|
z.object({ tool: z.literal('create_booking'), arguments: z.object({
|
|
@@ -44,6 +44,11 @@ export function whenText(b: Pick<Booking, 'start' | 'end'>, tz: string, settings
|
|
|
44
44
|
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();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** until when a freed spot is held, in the booking's language and the event's zone: "Fri, Nov 7, 6:00 PM UTC" */
|
|
48
|
+
export function untilText(at: number, tz: string, settings: Settings): string {
|
|
49
|
+
return new Intl.DateTimeFormat(siteLocale(settings), { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', timeZone: tz }).format(at);
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
const utc = (ms: number) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
48
53
|
|
|
49
54
|
// lang: the description's language ("Manage: <url>")
|
|
@@ -80,6 +85,8 @@ interface Ctx {
|
|
|
80
85
|
rsvpStaff?: { date: string; taken: number; capacity: number; why: RsvpStaffNotice['why']; via: Booking['source'] };
|
|
81
86
|
/** manageUrl is the recipient's My events page (a host without Manage bookings) */
|
|
82
87
|
mineLink?: boolean;
|
|
88
|
+
/** waitlist emails: the place in line (null once it isn't in line), how many hours a freed spot is held, and until when this one is */
|
|
89
|
+
waitlist?: { position: number | null; hours: number; until: string };
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
// own: a staff email's own rows and footer instead of the details and reference
|
|
@@ -104,8 +111,11 @@ ${button ? `<p style="margin:22px 0 0"><a href="${esc(button.href)}" style="disp
|
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
// link: the booking's own link again, after someone tried to make it twice.
|
|
107
|
-
//
|
|
108
|
-
|
|
114
|
+
// waitlisted / offered / left: they joined a full date's waitlist, a spot
|
|
115
|
+
// opened and is held for them, or they're off the waitlist (left it, released
|
|
116
|
+
// the spot or staff removed them). staff-rsvp(-cancelled): the hosts hear
|
|
117
|
+
// about a new RSVP, or one the person cancelled
|
|
118
|
+
export type BookingEmail = 'received' | 'confirmed' | 'declined' | 'cancelled' | 'rescheduled' | 'updated' | 'reminder' | 'link' | 'waitlisted' | 'offered' | 'left' | 'staff-new' | 'staff-rsvp' | 'staff-rsvp-cancelled';
|
|
109
119
|
|
|
110
120
|
export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
111
121
|
const w = bookingStrings(c.lang);
|
|
@@ -115,8 +125,9 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
115
125
|
// null: no button (nothing left to change)
|
|
116
126
|
const guest = (k: Exclude<BookingEmail, `staff-${string}`>, extra: Partial<Ctx> = {}, button: { href: string; label: string } | null = manage) => {
|
|
117
127
|
const copy = mailCopy(w, k, rsvp);
|
|
118
|
-
const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when
|
|
119
|
-
|
|
128
|
+
const vars = { name: c.booking.name || c.booking.email, title: c.title, when: c.when,
|
|
129
|
+
position: String(c.waitlist?.position ?? ''), hours: String(c.waitlist?.hours ?? ''), until: c.waitlist?.until ?? '' };
|
|
130
|
+
return { to: c.booking.email, subject: fill(copy.subject, vars), ...layout(fill(copy.heading, vars), fill(copy.intro, vars), { ...c, ...extra }, w, button ?? undefined) };
|
|
120
131
|
};
|
|
121
132
|
switch (kind) {
|
|
122
133
|
case 'received': return guest('received');
|
|
@@ -126,7 +137,11 @@ export function bookingEmail(kind: BookingEmail, c: Ctx): Mail {
|
|
|
126
137
|
case 'updated': return { ...guest('updated'), attachments: ics() };
|
|
127
138
|
case 'rescheduled': return { ...guest('rescheduled'), attachments: ics() };
|
|
128
139
|
case 'reminder': return guest('reminder');
|
|
129
|
-
|
|
140
|
+
// asked again while in line or holding a spot: that email again
|
|
141
|
+
case 'link': return c.booking.status === 'waitlisted' || c.booking.status === 'offered' ? bookingEmail(c.booking.status, c) : guest('link');
|
|
142
|
+
case 'waitlisted': return guest('waitlisted', {}, { href: c.manageUrl, label: w.waitlist.manage });
|
|
143
|
+
case 'offered': return guest('offered', {}, { href: c.manageUrl, label: w.waitlist.confirm });
|
|
144
|
+
case 'left': return guest('left', {}, null);
|
|
130
145
|
case 'staff-new': {
|
|
131
146
|
const pending = c.booking.status === 'pending';
|
|
132
147
|
const who = [c.booking.name, c.booking.email, c.booking.phone].filter(Boolean).join(' · ');
|
|
@@ -18,7 +18,7 @@ import { eventTimeZone, locationLine, occurrenceKey, rsvpDeadline } from '../cal
|
|
|
18
18
|
import { normalizePageLocale } from '../i18n';
|
|
19
19
|
import { localizeDoc } from '../translations';
|
|
20
20
|
import { bookingStrings } from '../email/booking-words';
|
|
21
|
-
import { bookingEmail, bookingLanguage, bookingManagePath, whenText, type BookingEmail } from './emails';
|
|
21
|
+
import { bookingEmail, bookingLanguage, bookingManagePath, untilText, whenText, type BookingEmail } from './emails';
|
|
22
22
|
import { holdOf, isBookable, resourceZone, spanEnd, type AvailabilityContext, type Interval } from './availability';
|
|
23
23
|
import { decideStatus, linkApproval } from './approval';
|
|
24
24
|
import { eventHolds } from './holds';
|
|
@@ -43,6 +43,9 @@ const token = () => {
|
|
|
43
43
|
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
/** how long a spot freed for the next person on a waitlist is held for them (ms) */
|
|
47
|
+
export const WAITLIST_HOLD = 24 * 3_600_000;
|
|
48
|
+
|
|
46
49
|
// ---- availability context ------------------------------------------------
|
|
47
50
|
|
|
48
51
|
const busyCache = new Map<string, { at: number; intervals: Interval[] }>();
|
|
@@ -147,11 +150,16 @@ export async function bookingMail(repo: ContentRepository, b: Booking, kind: Boo
|
|
|
147
150
|
// a host without Manage bookings (a member who hosts) gets their My events
|
|
148
151
|
// page for the event instead of an admin page they can't open
|
|
149
152
|
const mine = counts && d.event && recipient ? await memberHostPath(repo, d.event, recipient) : '';
|
|
153
|
+
// a place in line (its number when sent) or a held spot (until when, in the event's zone)
|
|
154
|
+
const waitlist = b.status === 'waitlisted' || b.status === 'offered' ? {
|
|
155
|
+
position: await waitlistPosition(repo, b), hours: WAITLIST_HOLD / 3_600_000,
|
|
156
|
+
until: b.holdUntil === null ? '' : untilText(b.holdUntil, d.tz, settings),
|
|
157
|
+
} : undefined;
|
|
150
158
|
return bookingEmail(kind, { booking:b,resource:d.resource,link:d.link,title:d.title,location:d.location,when:d.when,
|
|
151
159
|
manageUrl:mine ? origin + mine : staff ? adminBookingUrl(origin, b, kind) : origin + bookingManagePath({ manageToken: b.manageToken, locale: language.locale }),
|
|
152
160
|
mineLink: !!mine,
|
|
153
161
|
siteTitle:settings.siteTitle,host:new URL(origin).host,organizer:parseFrom(from)?.email,message:snapshot?.message,
|
|
154
|
-
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts });
|
|
162
|
+
allDay:d.link?.mode==='day'||!!d.event?.allDay||(!d.event&&!!snapshot?.eventAllDay), lang:language.lang, rsvpStaff: counts, waitlist });
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
/** The member's My events page for an event they host, when they can't manage bookings in the admin ('' otherwise). */
|
|
@@ -239,10 +247,13 @@ export function guestChanges(b: Booking, link: BookingLink | null, now = Date.no
|
|
|
239
247
|
const active = b.status === 'pending' || b.status === 'confirmed';
|
|
240
248
|
const cutoff = b.kind === 'booking' ? (link?.changeCutoff ?? 0) : 0;
|
|
241
249
|
const closed = active && cutoff > 0 && b.start - now < cutoff * 3_600_000;
|
|
242
|
-
|
|
250
|
+
// a place in line or a held spot can be left until the date ends
|
|
251
|
+
const open = (active || b.status === 'waitlisted' || b.status === 'offered') && b.end > now && !closed;
|
|
243
252
|
return {
|
|
244
253
|
cancel: open,
|
|
245
|
-
reschedule: open && b.kind === 'booking' && b.start > now && !!link?.active,
|
|
254
|
+
reschedule: open && active && b.kind === 'booking' && b.start > now && !!link?.active,
|
|
255
|
+
// a spot offered from the waitlist, still held for them
|
|
256
|
+
confirm: b.status === 'offered' && (b.holdUntil ?? 0) > now && b.end > now,
|
|
246
257
|
// hours before the start when guest changes close; 0 = until it ends
|
|
247
258
|
cutoffHours: cutoff,
|
|
248
259
|
closed,
|
|
@@ -255,8 +266,9 @@ export async function guestCancel(repo: ContentRepository, b: Booking, opts: Flo
|
|
|
255
266
|
if (b.status === 'cancelled') return b;
|
|
256
267
|
const can = guestChanges(b, link, opts.now);
|
|
257
268
|
if (!can.cancel) throw new BookingError(409, can.closed ? `Changes close ${can.cutoffHours} hours before the start. Please contact us to cancel.` : 'This booking can’t be cancelled.');
|
|
258
|
-
// the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff
|
|
259
|
-
|
|
269
|
+
// the person cancelled an RSVP: its hosts hear about it (staff cancelling never emails staff;
|
|
270
|
+
// leaving the waitlist or a held spot isn't an RSVP to them)
|
|
271
|
+
const event = b.kind === 'rsvp' && b.eventId && (b.status === 'pending' || b.status === 'confirmed') ? await repo.getEvent(b.eventId) : null;
|
|
260
272
|
return setBookingStatus(repo, b, 'cancelled', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
|
|
261
273
|
}
|
|
262
274
|
|
|
@@ -313,14 +325,34 @@ export async function createBlock(repo: ContentRepository, resource: Resource, s
|
|
|
313
325
|
});
|
|
314
326
|
}
|
|
315
327
|
|
|
328
|
+
// pending, confirmed and held (offered) RSVPs take seats, as in the capacity
|
|
329
|
+
// check; a place in line doesn't
|
|
330
|
+
const seated = (s: BookingStatus) => s === 'pending' || s === 'confirmed' || s === 'offered';
|
|
316
331
|
export async function seatsTaken(repo: ContentRepository, eventId: string, start: number): Promise<number> {
|
|
317
|
-
const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] });
|
|
332
|
+
const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['pending', 'confirmed', 'offered'], kinds: ['rsvp'] });
|
|
318
333
|
return list.filter((b) => b.start === start).reduce((n, b) => n + b.seats, 0);
|
|
319
334
|
}
|
|
320
335
|
|
|
336
|
+
/** whether a full date takes places in line: the event keeps a waitlist and has a capacity */
|
|
337
|
+
export const waitlistOn = (e: CalEvent) => e.rsvp.enabled && e.rsvp.waitlist && e.rsvp.capacity > 0;
|
|
338
|
+
|
|
339
|
+
/** a date's waitlist in order: who joined first, then by id */
|
|
340
|
+
export async function waitlistLine(repo: ContentRepository, eventId: string, start: number): Promise<Booking[]> {
|
|
341
|
+
const list = await repo.listBookings({ eventId, from: start - 1, to: start + 1, statuses: ['waitlisted'], kinds: ['rsvp'] });
|
|
342
|
+
return list.filter((b) => b.start === start).sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** a waitlisted RSVP's place in its date's line (1 = next), else null */
|
|
346
|
+
export async function waitlistPosition(repo: ContentRepository, b: Booking): Promise<number | null> {
|
|
347
|
+
if (b.status !== 'waitlisted') return null;
|
|
348
|
+
const i = (await waitlistLine(repo, b.eventId, b.start)).findIndex((x) => x.id === b.id);
|
|
349
|
+
return i < 0 ? null : i + 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
321
352
|
export async function createRsvp(
|
|
322
353
|
repo: ContentRepository,
|
|
323
|
-
|
|
354
|
+
// waitlist: a full date (fewer seats left than asked) takes a place in line instead, when the event keeps one
|
|
355
|
+
args: { event: CalEvent; start: number; end: number; seats: number; booker: BookerInput; source?: Booking['source']; waitlist?: boolean },
|
|
324
356
|
opts: FlowOptions
|
|
325
357
|
): Promise<Booking> {
|
|
326
358
|
const e = args.event;
|
|
@@ -343,15 +375,20 @@ export async function createRsvp(
|
|
|
343
375
|
const once = args.source !== 'admin';
|
|
344
376
|
const source = args.source ?? 'web';
|
|
345
377
|
// the hosts' email is queued with the RSVP: a duplicate or a replay inserts nothing, so it emails no one
|
|
346
|
-
const
|
|
347
|
-
kind: 'rsvp', resourceId: '', linkId: '', eventId: e.id, status: e.rsvp.autoConfirm ? 'confirmed' : 'pending',
|
|
378
|
+
const data = {
|
|
379
|
+
kind: 'rsvp' as const, resourceId: '', linkId: '', eventId: e.id, status: (e.rsvp.autoConfirm ? 'confirmed' : 'pending') as BookingStatus,
|
|
348
380
|
start: args.start, end: args.end, holdStart: args.start, holdEnd: args.end, timezone: e.timezone, ...booker, seats,
|
|
349
381
|
formResponse, notes: '', manageToken: token(), source, locale: normalizePageLocale(args.booker.locale ?? ''),
|
|
350
|
-
}
|
|
382
|
+
};
|
|
383
|
+
let b = await repo.createBooking(data, { capacity: e.rsvp.capacity, oncePerEmail: once, idempotency: opts.idempotency, staff: await rsvpStaff(repo, e, settings, source) });
|
|
384
|
+
// full: a place in line, which takes no seats and emails no staff (still one per address and date)
|
|
385
|
+
if (!b && args.waitlist && waitlistOn(e) && !(opts.idempotency && await repo.getBookingRequest(opts.idempotency.keyHash))) {
|
|
386
|
+
b = await repo.createBooking({ ...data, status: 'waitlisted' }, { oncePerEmail: once, idempotency: opts.idempotency });
|
|
387
|
+
}
|
|
351
388
|
if (!b) {
|
|
352
389
|
// a retry of a request that just committed: the caller replays that one
|
|
353
390
|
const retry = opts.idempotency ? await repo.getBookingRequest(opts.idempotency.keyHash) : null;
|
|
354
|
-
const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
|
|
391
|
+
const mine = once && !retry ? (await repo.listBookings({ eventId: e.id, email: booker.email, from: args.start - 1, to: args.start + 1, statuses: ['pending', 'confirmed', 'offered', 'waitlisted'], kinds: ['rsvp'] })).find((x) => x.start === args.start) : undefined;
|
|
355
392
|
if (mine) {
|
|
356
393
|
await resendManageLink(repo, mine, args.booker.locale === undefined ? mine.locale : normalizePageLocale(args.booker.locale), opts);
|
|
357
394
|
// anyone can type an address: say only that it has one
|
|
@@ -365,6 +402,95 @@ export async function createRsvp(
|
|
|
365
402
|
return b;
|
|
366
403
|
}
|
|
367
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Offers a date's freed seats to its waitlist, in order: each person whose
|
|
407
|
+
* seats fit what's free gets them held for WAITLIST_HOLD, with an email to
|
|
408
|
+
* confirm; larger requests keep their place. Runs after anything that frees
|
|
409
|
+
* seats (a cancellation, a decline, a spot that ran out, a capacity raised)
|
|
410
|
+
* and from the scheduler. Only while the event keeps a waitlist and before
|
|
411
|
+
* the date starts, past its RSVP deadline too: those in line joined in time.
|
|
412
|
+
* Each offer re-checks the seats in the database, so racing runs (or a new
|
|
413
|
+
* RSVP) never take more than the capacity. Returns how many it offered.
|
|
414
|
+
*/
|
|
415
|
+
export async function offerWaitlist(repo: ContentRepository, eventId: string, start: number, opts: FlowOptions): Promise<number> {
|
|
416
|
+
const now = opts.now ?? Date.now();
|
|
417
|
+
if (start <= now) return 0;
|
|
418
|
+
const line = await waitlistLine(repo, eventId, start);
|
|
419
|
+
if (!line.length) return 0;
|
|
420
|
+
const e = await repo.getEvent(eventId);
|
|
421
|
+
// turning the waitlist off only stops new people joining: those already in line still get their turn
|
|
422
|
+
if (!e || !e.rsvp.enabled || e.cancelled) return 0;
|
|
423
|
+
if (e.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, start, await repo.getSettings()))) return 0;
|
|
424
|
+
const cap = e.rsvp.capacity;
|
|
425
|
+
const free = async () => (cap ? cap - await seatsTaken(repo, eventId, start) : Infinity);
|
|
426
|
+
let left = await free(), offered = 0;
|
|
427
|
+
for (const b of line) {
|
|
428
|
+
if (left <= 0) break;
|
|
429
|
+
if (b.seats > left) continue;
|
|
430
|
+
if (await repo.offerBooking(b.id, now + WAITLIST_HOLD, cap)) { left -= b.seats; offered++; }
|
|
431
|
+
// someone took seats meanwhile: count again
|
|
432
|
+
else left = await free();
|
|
433
|
+
}
|
|
434
|
+
if (offered) await kickEmail(repo, opts.locals, opts.waitUntil);
|
|
435
|
+
return offered;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** every upcoming date of one event with people in line (after an edit that may have freed seats) */
|
|
439
|
+
export async function offerEventWaitlist(repo: ContentRepository, eventId: string, opts: FlowOptions): Promise<number> {
|
|
440
|
+
const now = opts.now ?? Date.now();
|
|
441
|
+
const starts = new Set((await repo.listBookings({ eventId, from: now, statuses: ['waitlisted'], kinds: ['rsvp'] })).map((b) => b.start));
|
|
442
|
+
let offered = 0;
|
|
443
|
+
for (const start of starts) offered += await offerWaitlist(repo, eventId, start, opts);
|
|
444
|
+
return offered;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The person takes the spot held for them (manage page, app): confirmed, or
|
|
449
|
+
* pending when the event approves each RSVP, with the usual email and the
|
|
450
|
+
* hosts' "New RSVP" one. Past its hold the spot is released (410) and goes
|
|
451
|
+
* to the next person in line. Repeating it is harmless.
|
|
452
|
+
*/
|
|
453
|
+
export async function confirmOffer(repo: ContentRepository, b: Booking, opts: FlowOptions, via: Booking['source'] = 'web'): Promise<Booking> {
|
|
454
|
+
const now = opts.now ?? Date.now();
|
|
455
|
+
if (b.status === 'confirmed' || b.status === 'pending') return b;
|
|
456
|
+
if (b.status === 'offered' && (b.holdUntil ?? 0) <= now) {
|
|
457
|
+
await releaseOffer(repo, b, opts);
|
|
458
|
+
throw new BookingError(410, 'This held spot was released.');
|
|
459
|
+
}
|
|
460
|
+
if (b.status === 'expired') throw new BookingError(410, 'This held spot was released.');
|
|
461
|
+
if (b.status !== 'offered') throw new BookingError(409, 'There’s no held spot to confirm.');
|
|
462
|
+
if (b.end <= now) throw new BookingError(410, 'This event has already happened.');
|
|
463
|
+
const event = b.eventId ? await repo.getEvent(b.eventId) : null;
|
|
464
|
+
try {
|
|
465
|
+
return await setBookingStatus(repo, b, event?.rsvp.autoConfirm === false ? 'pending' : 'confirmed', opts, undefined, event ? await rsvpStaff(repo, event, await repo.getSettings(), via) : undefined);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if ((await repo.getBooking(b.id))?.status === 'expired') throw new BookingError(410, 'This held spot was released.');
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// a held spot nobody confirmed in time: expired (no email; only if it's still
|
|
473
|
+
// offered), and its seats go to the next person in line
|
|
474
|
+
async function releaseOffer(repo: ContentRepository, b: Booking, opts: FlowOptions) {
|
|
475
|
+
if (await repo.expireBooking(b.id, opts.now ?? Date.now())) await offerWaitlist(repo, b.eventId, b.start, opts);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// cron: held spots past their time run out, and every upcoming date with
|
|
479
|
+
// people in line is offered what's free (catching up on seats freed by edits,
|
|
480
|
+
// or by a change that lost a race)
|
|
481
|
+
export async function sweepWaitlist(repo: ContentRepository, opts: FlowOptions): Promise<number> {
|
|
482
|
+
const now = opts.now ?? Date.now();
|
|
483
|
+
const list = await repo.listBookings({ from: now, statuses: ['waitlisted', 'offered'], kinds: ['rsvp'] });
|
|
484
|
+
const dates = new Map<string, { eventId: string; start: number }>();
|
|
485
|
+
for (const b of list) {
|
|
486
|
+
if (b.status === 'offered' && (b.holdUntil ?? 0) <= now) await repo.expireBooking(b.id, now);
|
|
487
|
+
dates.set(`${b.eventId}:${b.start}`, { eventId: b.eventId, start: b.start });
|
|
488
|
+
}
|
|
489
|
+
let offered = 0;
|
|
490
|
+
for (const d of dates.values()) offered += await offerWaitlist(repo, d.eventId, d.start, opts);
|
|
491
|
+
return offered;
|
|
492
|
+
}
|
|
493
|
+
|
|
368
494
|
// The address gets its RSVP's own link again, in the language it just used,
|
|
369
495
|
// at most once per 10 minutes per RSVP so the form can't flood an inbox.
|
|
370
496
|
async function resendManageLink(repo: ContentRepository, b: Booking, locale: string, opts: FlowOptions) {
|
|
@@ -379,6 +505,10 @@ const TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
|
|
|
379
505
|
confirmed: ['cancelled'],
|
|
380
506
|
declined: ['confirmed'],
|
|
381
507
|
cancelled: ['confirmed'],
|
|
508
|
+
// only offerWaitlist offers a place in line, and only the clock expires a held spot
|
|
509
|
+
waitlisted: ['cancelled'],
|
|
510
|
+
offered: ['confirmed', 'pending', 'cancelled'],
|
|
511
|
+
expired: [],
|
|
382
512
|
};
|
|
383
513
|
|
|
384
514
|
// staff: who to tell that the person cancelled (guestCancel); staff changes tell no one
|
|
@@ -391,13 +521,17 @@ export async function setBookingStatus(repo: ContentRepository, b: Booking, stat
|
|
|
391
521
|
.some((x) => x.id !== b.id && x.kind !== 'rsvp' && x.holdStart < b.holdEnd && x.holdEnd > b.holdStart);
|
|
392
522
|
if (clash) throw new BookingError(409, 'That time has been booked since — it can’t be restored.');
|
|
393
523
|
}
|
|
394
|
-
|
|
524
|
+
// only from the status it was read in: a spot that ran out meanwhile can't be confirmed
|
|
525
|
+
await repo.updateBooking(b.id, { status, from: b.status, notificationMessage: message, staff });
|
|
395
526
|
const next = (await repo.getBooking(b.id))!;
|
|
396
527
|
if (!next || next.status !== status) throw new BookingError(409, 'This booking changed. Refresh and try again.');
|
|
397
528
|
if (b.kind !== 'block') {
|
|
398
529
|
await notify(repo, next, [status === 'confirmed' ? 'confirmed' : status === 'declined' ? 'declined' : 'cancelled'], opts, message);
|
|
399
|
-
|
|
530
|
+
// a held spot taken up for approval is no webhook yet (approving it is)
|
|
531
|
+
if (status !== 'pending') await hook(repo, status === 'confirmed' ? 'booking.confirmed' : status === 'declined' ? 'booking.declined' : 'booking.cancelled', next, opts);
|
|
400
532
|
}
|
|
533
|
+
// the seats it held go to the next people in line
|
|
534
|
+
if (b.kind === 'rsvp' && seated(b.status) && !seated(status)) await offerWaitlist(repo, b.eventId, b.start, opts);
|
|
401
535
|
return next;
|
|
402
536
|
}
|
|
403
537
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Words of the built-in RSVP: the event page's RSVP block, the RSVP's own
|
|
2
2
|
// page (book/manage) and the app's default button (GET /events/:slug/rsvp).
|
|
3
3
|
// The same wording as the app's RSVP panel (packages/native strings.ts).
|
|
4
|
-
// Plain strings with {count}, {email}, {title}, {date}
|
|
5
|
-
// (live/words.ts `fill`); of a pair
|
|
4
|
+
// Plain strings with {count}, {email}, {title}, {date}, {time}, {position},
|
|
5
|
+
// {seats} and {hours} slots (live/words.ts `fill`); of a pair
|
|
6
|
+
// (`spotLeft`/`spotsLeft`), the first is for 1 and the
|
|
6
7
|
// second for any other number. The event's own form title, questions and
|
|
7
8
|
// button keep their words. Other languages fall back to English.
|
|
8
9
|
export interface RsvpStrings {
|
|
@@ -28,6 +29,31 @@ export interface RsvpStrings {
|
|
|
28
29
|
duplicate: string;
|
|
29
30
|
duplicateNote: string;
|
|
30
31
|
saveError: string;
|
|
32
|
+
/** a full date with a waitlist: "Full", "40 going · no spots left", the note and the button (also the form's) */
|
|
33
|
+
fullTag: string;
|
|
34
|
+
noSpotsLeft: string;
|
|
35
|
+
waitlistNote: string;
|
|
36
|
+
joinWaitlist: string;
|
|
37
|
+
/** the RSVP's own page for a place in line: "#3 in line · 2 seats" ({seats}: `seat`/`seats`), and the promise ({hours}) */
|
|
38
|
+
onWaitlist: string;
|
|
39
|
+
inLine: string;
|
|
40
|
+
seat: string;
|
|
41
|
+
seats: string;
|
|
42
|
+
waitlistPromise: string;
|
|
43
|
+
leaveWaitlist: string;
|
|
44
|
+
leaveConfirm: string;
|
|
45
|
+
/** off the line: they left it, released the spot or staff removed them */
|
|
46
|
+
offWaitlist: string;
|
|
47
|
+
offWaitlistText: string;
|
|
48
|
+
/** a spot held for them ({date} and {time}: until when), and one they didn't confirm in time */
|
|
49
|
+
spotOpened: string;
|
|
50
|
+
spotYours: string;
|
|
51
|
+
confirmSpot: string;
|
|
52
|
+
releaseSpot: string;
|
|
53
|
+
releaseConfirm: string;
|
|
54
|
+
released: string;
|
|
55
|
+
releasedText: string;
|
|
56
|
+
confirmError: string;
|
|
31
57
|
/** the RSVP's own page */
|
|
32
58
|
booked: string;
|
|
33
59
|
yours: string;
|
|
@@ -44,7 +70,7 @@ export interface RsvpStrings {
|
|
|
44
70
|
where: string;
|
|
45
71
|
status: string;
|
|
46
72
|
reference: string;
|
|
47
|
-
statuses: Record<'pending' | 'confirmed' | 'cancelled' | 'declined', string>;
|
|
73
|
+
statuses: Record<'pending' | 'confirmed' | 'cancelled' | 'declined' | 'waitlisted' | 'offered' | 'expired', string>;
|
|
48
74
|
addToCalendar: string;
|
|
49
75
|
downloadIcs: string;
|
|
50
76
|
cancel: string;
|
|
@@ -61,11 +87,18 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
61
87
|
full: 'This event is full.', closed: 'RSVPs are closed.', saveError: 'Couldn’t save your RSVP. Please try again.',
|
|
62
88
|
closesOn: 'RSVPs close {date} at {time}', closedOn: 'They closed on {date}. Questions? Contact us.',
|
|
63
89
|
duplicate: 'You already have an RSVP for this date.', duplicateNote: 'We sent you an email with a link to change it. Need more seats? Change it there.',
|
|
90
|
+
fullTag: 'Full', noSpotsLeft: 'no spots left', waitlistNote: 'Join the waitlist and we’ll email you if a spot opens.', joinWaitlist: 'Join the waitlist',
|
|
91
|
+
onWaitlist: 'You’re on the waitlist', inLine: '#{position} in line · {seats}', seat: '{count} seat', seats: '{count} seats',
|
|
92
|
+
waitlistPromise: 'If a spot opens, we’ll email you and save it for {hours} hours.', leaveWaitlist: 'Leave the waitlist', leaveConfirm: 'Leave the waitlist?',
|
|
93
|
+
offWaitlist: 'You’re off the waitlist', offWaitlistText: 'You’re no longer on the waitlist for this date.',
|
|
94
|
+
spotOpened: 'A spot opened', spotYours: 'A spot is yours until {date} at {time}', confirmSpot: 'Confirm my spot', releaseSpot: 'Release it',
|
|
95
|
+
releaseConfirm: 'Release this spot? It goes to the next person in line.', released: 'This held spot was released',
|
|
96
|
+
releasedText: 'We held it for {hours} hours, then offered it to the next person in line.', confirmError: 'Couldn’t confirm — please try again.',
|
|
64
97
|
booked: 'You’re booked', yours: 'Your booking', requestSent: 'Request sent', awaitingApproval: 'Waiting for approval', notAvailable: 'Not available', cancelledTitle: 'Booking cancelled',
|
|
65
98
|
emailWhenApproved: 'We’ll email {email} once it’s approved.', confirmationOnItsWay: 'A confirmation is on its way to {email}.',
|
|
66
99
|
declined: 'Sorry, the organiser couldn’t confirm it.', cancelledText: 'This booking is cancelled.',
|
|
67
100
|
what: 'What', when: 'When', where: 'Where', status: 'Status', reference: 'Reference',
|
|
68
|
-
statuses: { pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', declined: 'Declined' },
|
|
101
|
+
statuses: { pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', declined: 'Declined', waitlisted: 'On the waitlist', offered: 'Spot held for you', expired: 'Released' },
|
|
69
102
|
addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', cancel: 'Cancel RSVP', cancelConfirm: 'Cancel this RSVP?', cancelError: 'Couldn’t cancel — please try again.',
|
|
70
103
|
backTo: 'Back to {title}', keepLink: 'Keep this link — anyone with it can change this booking.',
|
|
71
104
|
},
|
|
@@ -75,11 +108,18 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
75
108
|
full: 'Este evento está lleno.', closed: 'Las confirmaciones están cerradas.', saveError: 'No se pudo guardar tu confirmación. Inténtalo de nuevo.',
|
|
76
109
|
closesOn: 'Las confirmaciones cierran el {date}, {time}', closedOn: 'Cerraron el {date}. ¿Preguntas? Contáctanos.',
|
|
77
110
|
duplicate: 'Ya tienes una confirmación para esta fecha.', duplicateNote: 'Te enviamos un correo con un enlace para cambiarla. ¿Necesitas más lugares? Cámbiala ahí.',
|
|
111
|
+
fullTag: 'Lleno', noSpotsLeft: 'no quedan lugares', waitlistNote: 'Únete a la lista de espera y te escribiremos si se libera un lugar.', joinWaitlist: 'Unirme a la lista de espera',
|
|
112
|
+
onWaitlist: 'Estás en la lista de espera', inLine: '#{position} en la fila · {seats}', seat: '{count} lugar', seats: '{count} lugares',
|
|
113
|
+
waitlistPromise: 'Si se libera un lugar, te escribiremos y te lo apartaremos {hours} horas.', leaveWaitlist: 'Salir de la lista de espera', leaveConfirm: '¿Salir de la lista de espera?',
|
|
114
|
+
offWaitlist: 'Ya no estás en la lista de espera', offWaitlistText: 'Ya no estás en la lista de espera para esta fecha.',
|
|
115
|
+
spotOpened: 'Se liberó un lugar', spotYours: 'El lugar es tuyo hasta el {date}, {time}', confirmSpot: 'Confirmar mi lugar', releaseSpot: 'Liberarlo',
|
|
116
|
+
releaseConfirm: '¿Liberar este lugar? Pasará a la siguiente persona en la fila.', released: 'Se liberó el lugar que te apartamos',
|
|
117
|
+
releasedText: 'Te lo apartamos {hours} horas y luego se lo ofrecimos a la siguiente persona en la fila.', confirmError: 'No se pudo confirmar. Inténtalo de nuevo.',
|
|
78
118
|
booked: 'Ya tienes tu lugar', yours: 'Tu confirmación', requestSent: 'Solicitud enviada', awaitingApproval: 'En espera de aprobación', notAvailable: 'No se pudo confirmar', cancelledTitle: 'Confirmación cancelada',
|
|
79
119
|
emailWhenApproved: 'Te escribiremos a {email} cuando se apruebe.', confirmationOnItsWay: 'Te enviamos la confirmación a {email}.',
|
|
80
120
|
declined: 'Lo sentimos, el organizador no pudo confirmarla.', cancelledText: 'Esta confirmación se canceló.',
|
|
81
121
|
what: 'Qué', when: 'Cuándo', where: 'Dónde', status: 'Estado', reference: 'Referencia',
|
|
82
|
-
statuses: { pending: 'Pendiente', confirmed: 'Confirmado', cancelled: 'Cancelado', declined: 'Rechazado' },
|
|
122
|
+
statuses: { pending: 'Pendiente', confirmed: 'Confirmado', cancelled: 'Cancelado', declined: 'Rechazado', waitlisted: 'En lista de espera', offered: 'Lugar apartado para ti', expired: 'Liberado' },
|
|
83
123
|
addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', cancel: 'Cancelar mi confirmación', cancelConfirm: '¿Cancelar tu confirmación?', cancelError: 'No se pudo cancelar. Inténtalo de nuevo.',
|
|
84
124
|
backTo: 'Volver a {title}', keepLink: 'Guarda este enlace: quien lo tenga puede cambiar esta confirmación.',
|
|
85
125
|
},
|
|
@@ -89,11 +129,18 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
89
129
|
full: 'Cet événement est complet.', closed: 'Les inscriptions sont closes.', saveError: 'Impossible d’enregistrer votre inscription. Veuillez réessayer.',
|
|
90
130
|
closesOn: 'Les inscriptions ferment le {date} à {time}', closedOn: 'Elles ont fermé le {date}. Des questions ? Contactez-nous.',
|
|
91
131
|
duplicate: 'Vous avez déjà une inscription pour cette date.', duplicateNote: 'Nous vous avons envoyé un e-mail avec un lien pour la modifier. Besoin de plus de places ? Modifiez-la depuis ce lien.',
|
|
132
|
+
fullTag: 'Complet', noSpotsLeft: 'plus de place', waitlistNote: 'Inscrivez-vous sur la liste d’attente : nous vous écrirons si une place se libère.', joinWaitlist: 'Rejoindre la liste d’attente',
|
|
133
|
+
onWaitlist: 'Vous êtes sur la liste d’attente', inLine: 'N° {position} dans la file · {seats}', seat: '{count} place', seats: '{count} places',
|
|
134
|
+
waitlistPromise: 'Si une place se libère, nous vous écrirons et vous la garderons {hours} heures.', leaveWaitlist: 'Quitter la liste d’attente', leaveConfirm: 'Quitter la liste d’attente ?',
|
|
135
|
+
offWaitlist: 'Vous n’êtes plus sur la liste d’attente', offWaitlistText: 'Vous n’êtes plus sur la liste d’attente pour cette date.',
|
|
136
|
+
spotOpened: 'Une place s’est libérée', spotYours: 'Une place est à vous jusqu’au {date} à {time}', confirmSpot: 'Confirmer ma place', releaseSpot: 'La libérer',
|
|
137
|
+
releaseConfirm: 'Libérer cette place ? Elle ira à la personne suivante dans la file.', released: 'La place gardée pour vous a été libérée',
|
|
138
|
+
releasedText: 'Nous l’avons gardée {hours} heures, puis proposée à la personne suivante dans la file.', confirmError: 'Impossible de confirmer. Veuillez réessayer.',
|
|
92
139
|
booked: 'Votre place est réservée', yours: 'Votre inscription', requestSent: 'Demande envoyée', awaitingApproval: 'En attente de validation', notAvailable: 'Non confirmée', cancelledTitle: 'Inscription annulée',
|
|
93
140
|
emailWhenApproved: 'Nous écrirons à {email} dès qu’elle sera validée.', confirmationOnItsWay: 'Une confirmation est en route vers {email}.',
|
|
94
141
|
declined: 'Désolés, l’organisateur n’a pas pu la confirmer.', cancelledText: 'Cette inscription est annulée.',
|
|
95
142
|
what: 'Quoi', when: 'Quand', where: 'Où', status: 'Statut', reference: 'Référence',
|
|
96
|
-
statuses: { pending: 'En attente', confirmed: 'Confirmée', cancelled: 'Annulée', declined: 'Refusée' },
|
|
143
|
+
statuses: { pending: 'En attente', confirmed: 'Confirmée', cancelled: 'Annulée', declined: 'Refusée', waitlisted: 'Sur liste d’attente', offered: 'Place gardée pour vous', expired: 'Libérée' },
|
|
97
144
|
addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', cancel: 'Annuler mon inscription', cancelConfirm: 'Annuler cette inscription ?', cancelError: 'Impossible d’annuler. Veuillez réessayer.',
|
|
98
145
|
backTo: 'Retour à {title}', keepLink: 'Conservez ce lien : toute personne qui l’a peut modifier cette inscription.',
|
|
99
146
|
},
|
|
@@ -103,11 +150,18 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
103
150
|
full: 'Diese Veranstaltung ist ausgebucht.', closed: 'Anmeldungen sind geschlossen.', saveError: 'Ihre Anmeldung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
|
|
104
151
|
closesOn: 'Anmeldung bis {date}, {time}', closedOn: 'Die Anmeldung endete am {date}. Fragen? Kontaktieren Sie uns.',
|
|
105
152
|
duplicate: 'Sie haben für diesen Termin schon eine Anmeldung.', duplicateNote: 'Wir haben Ihnen eine E-Mail mit einem Link zum Ändern geschickt. Mehr Plätze nötig? Ändern Sie sie dort.',
|
|
153
|
+
fullTag: 'Ausgebucht', noSpotsLeft: 'keine Plätze frei', waitlistNote: 'Tragen Sie sich auf die Warteliste ein. Wird ein Platz frei, schreiben wir Ihnen.', joinWaitlist: 'Auf die Warteliste',
|
|
154
|
+
onWaitlist: 'Sie stehen auf der Warteliste', inLine: 'Nr. {position} auf der Warteliste · {seats}', seat: '{count} Platz', seats: '{count} Plätze',
|
|
155
|
+
waitlistPromise: 'Wird ein Platz frei, schreiben wir Ihnen und halten ihn {hours} Stunden für Sie frei.', leaveWaitlist: 'Warteliste verlassen', leaveConfirm: 'Die Warteliste verlassen?',
|
|
156
|
+
offWaitlist: 'Sie stehen nicht mehr auf der Warteliste', offWaitlistText: 'Sie stehen für diesen Termin nicht mehr auf der Warteliste.',
|
|
157
|
+
spotOpened: 'Ein Platz ist frei geworden', spotYours: 'Der Platz ist bis {date}, {time} für Sie reserviert', confirmSpot: 'Platz bestätigen', releaseSpot: 'Freigeben',
|
|
158
|
+
releaseConfirm: 'Diesen Platz freigeben? Er geht an die nächste Person auf der Warteliste.', released: 'Der für Sie reservierte Platz wurde freigegeben',
|
|
159
|
+
releasedText: 'Wir haben ihn {hours} Stunden für Sie freigehalten und dann der nächsten Person auf der Warteliste angeboten.', confirmError: 'Bestätigung fehlgeschlagen. Bitte versuchen Sie es erneut.',
|
|
106
160
|
booked: 'Ihr Platz ist reserviert', yours: 'Ihre Anmeldung', requestSent: 'Anfrage gesendet', awaitingApproval: 'Wartet auf Freigabe', notAvailable: 'Nicht bestätigt', cancelledTitle: 'Anmeldung storniert',
|
|
107
161
|
emailWhenApproved: 'Wir schreiben an {email}, sobald sie freigegeben ist.', confirmationOnItsWay: 'Eine Bestätigung ist unterwegs an {email}.',
|
|
108
162
|
declined: 'Leider konnte der Veranstalter sie nicht bestätigen.', cancelledText: 'Diese Anmeldung ist storniert.',
|
|
109
163
|
what: 'Was', when: 'Wann', where: 'Wo', status: 'Status', reference: 'Referenz',
|
|
110
|
-
statuses: { pending: 'Ausstehend', confirmed: 'Bestätigt', cancelled: 'Storniert', declined: 'Abgelehnt' },
|
|
164
|
+
statuses: { pending: 'Ausstehend', confirmed: 'Bestätigt', cancelled: 'Storniert', declined: 'Abgelehnt', waitlisted: 'Auf der Warteliste', offered: 'Platz für Sie reserviert', expired: 'Freigegeben' },
|
|
111
165
|
addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', cancel: 'Anmeldung stornieren', cancelConfirm: 'Diese Anmeldung stornieren?', cancelError: 'Stornieren fehlgeschlagen. Bitte versuchen Sie es erneut.',
|
|
112
166
|
backTo: 'Zurück zu {title}', keepLink: 'Bewahren Sie diesen Link auf – wer ihn hat, kann diese Anmeldung ändern.',
|
|
113
167
|
},
|
|
@@ -117,11 +171,18 @@ const STRINGS: Record<string, RsvpStrings> = {
|
|
|
117
171
|
full: 'Este evento está lotado.', closed: 'As inscrições estão encerradas.', saveError: 'Não foi possível salvar sua inscrição. Tente novamente.',
|
|
118
172
|
closesOn: 'As inscrições se encerram em {date}, {time}', closedOn: 'Elas se encerraram em {date}. Dúvidas? Fale com a gente.',
|
|
119
173
|
duplicate: 'Você já tem uma inscrição para esta data.', duplicateNote: 'Enviamos um e-mail com um link para alterá-la. Precisa de mais vagas? Altere por lá.',
|
|
174
|
+
fullTag: 'Lotado', noSpotsLeft: 'nenhuma vaga', waitlistNote: 'Entre na lista de espera e avisaremos por e-mail se abrir uma vaga.', joinWaitlist: 'Entrar na lista de espera',
|
|
175
|
+
onWaitlist: 'Você está na lista de espera', inLine: 'Nº {position} na fila · {seats}', seat: '{count} vaga', seats: '{count} vagas',
|
|
176
|
+
waitlistPromise: 'Se abrir uma vaga, avisaremos por e-mail e vamos guardá-la por {hours} horas.', leaveWaitlist: 'Sair da lista de espera', leaveConfirm: 'Sair da lista de espera?',
|
|
177
|
+
offWaitlist: 'Você não está mais na lista de espera', offWaitlistText: 'Você não está mais na lista de espera desta data.',
|
|
178
|
+
spotOpened: 'Abriu uma vaga', spotYours: 'A vaga é sua até {date}, {time}', confirmSpot: 'Confirmar minha vaga', releaseSpot: 'Liberar',
|
|
179
|
+
releaseConfirm: 'Liberar esta vaga? Ela passa para a próxima pessoa da fila.', released: 'A vaga guardada para você foi liberada',
|
|
180
|
+
releasedText: 'Nós a guardamos por {hours} horas e depois a oferecemos à próxima pessoa da fila.', confirmError: 'Não foi possível confirmar. Tente novamente.',
|
|
120
181
|
booked: 'Sua vaga está reservada', yours: 'Sua inscrição', requestSent: 'Solicitação enviada', awaitingApproval: 'Aguardando aprovação', notAvailable: 'Não confirmada', cancelledTitle: 'Inscrição cancelada',
|
|
121
182
|
emailWhenApproved: 'Vamos escrever para {email} assim que for aprovada.', confirmationOnItsWay: 'Uma confirmação está a caminho de {email}.',
|
|
122
183
|
declined: 'Desculpe, o organizador não pôde confirmá-la.', cancelledText: 'Esta inscrição foi cancelada.',
|
|
123
184
|
what: 'O quê', when: 'Quando', where: 'Onde', status: 'Status', reference: 'Referência',
|
|
124
|
-
statuses: { pending: 'Pendente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Recusada' },
|
|
185
|
+
statuses: { pending: 'Pendente', confirmed: 'Confirmada', cancelled: 'Cancelada', declined: 'Recusada', waitlisted: 'Na lista de espera', offered: 'Vaga guardada para você', expired: 'Liberada' },
|
|
125
186
|
addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', cancel: 'Cancelar inscrição', cancelConfirm: 'Cancelar esta inscrição?', cancelError: 'Não foi possível cancelar. Tente novamente.',
|
|
126
187
|
backTo: 'Voltar para {title}', keepLink: 'Guarde este link: qualquer pessoa com ele pode alterar esta inscrição.',
|
|
127
188
|
},
|