@acms-subir/core 0.3.38 → 0.3.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/docs/mobile.md +1 -1
  3. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +11 -7
  4. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +11 -7
  5. package/package.json +1 -1
  6. package/src/actions/index.ts +2 -1
  7. package/src/components/EventPage.astro +14 -5
  8. package/src/components/MobileApp.tsx +44 -23
  9. package/src/components/editor/EventPanel.tsx +2 -1
  10. package/src/components/editor/PostEditor.tsx +1 -1
  11. package/src/components/editor/event/RegistrationCard.tsx +51 -1
  12. package/src/components/editor/event/registration.ts +18 -1
  13. package/src/lib/booking/emails.ts +3 -1
  14. package/src/lib/booking/service.ts +37 -10
  15. package/src/lib/calendar/events.ts +11 -0
  16. package/src/lib/calendar/rsvp-words.ts +31 -2
  17. package/src/lib/content-service.ts +26 -14
  18. package/src/lib/drafts.ts +2 -0
  19. package/src/lib/editor/documents.ts +3 -1
  20. package/src/lib/editor/schemas.ts +1 -1
  21. package/src/lib/email/booking-words.ts +10 -1
  22. package/src/lib/mobile/bookings.ts +1 -1
  23. package/src/lib/mobile/http.ts +1 -1
  24. package/src/lib/mobile/openapi.ts +1 -1
  25. package/src/lib/mobile/rsvp.ts +14 -8
  26. package/src/lib/mobile/schemas.ts +5 -2
  27. package/src/lib/push/api.ts +7 -8
  28. package/src/lib/push/service.ts +21 -16
  29. package/src/lib/push/types.ts +18 -8
  30. package/src/lib/repo/sql.ts +3 -0
  31. package/src/lib/repo/types.ts +3 -2
  32. package/src/lib/schema.ts +5 -1
  33. package/src/pages/events/[slug].astro +10 -3
  34. package/src/styles/editor.css +4 -0
  35. package/src/styles/mobile-app.css +7 -0
  36. package/src/styles/site.css +3 -0
  37. package/src/theme-sdk/index.ts +1 -1
@@ -187,7 +187,8 @@ export interface ContentRepository {
187
187
 
188
188
  // bookings (+ staff blocks and event RSVPs). createBooking is the only
189
189
  // write that can fail on a race: it inserts atomically only if the held
190
- // range is still free (resource bookings) or seats remain (RSVPs), and
190
+ // range is still free (resource bookings) or seats remain (RSVPs; with
191
+ // oncePerEmail, and the address has no active RSVP for that date), and
191
192
  // returns null when it lost.
192
193
  /** from/to: overlaps the window; endedBy: ended at or before; default order start ascending, at most 5000 rows */
193
194
  listBookings(filter?: { resourceId?: string; eventId?: string; email?: string; from?: number; to?: number; endedBy?: number; statuses?: BookingStatus[]; kinds?: Booking['kind'][]; newestFirst?: boolean; limit?: number; offset?: number }): Promise<Booking[]>;
@@ -198,7 +199,7 @@ export interface ContentRepository {
198
199
  // locale: the booker's language; '' (the main language) when left out
199
200
  createBooking(
200
201
  data: Omit<Booking, 'id' | 'createdAt' | 'updatedAt' | 'reminderSentAt' | 'notificationVersion' | 'locale'> & { locale?: string },
201
- guard?: { capacity?: number; idempotency?: { keyHash: string; requestHash: string } }
202
+ guard?: { capacity?: number; oncePerEmail?: boolean; idempotency?: { keyHash: string; requestHash: string } }
202
203
  ): Promise<Booking | null>;
203
204
  // moves a booking only if the new held range is free (reschedule)
204
205
  moveBooking(id: string, times: { start: number; end: number; holdStart: number; holdEnd: number }): Promise<boolean>;
package/src/lib/schema.ts CHANGED
@@ -415,12 +415,16 @@ export const CalEvent = z.object({
415
415
  // UID of the feed event this was imported from ('' = authored here)
416
416
  sourceUid: z.string().default(''),
417
417
  // RSVP: seats per occurrence (0 = unlimited) and whether RSVPs confirm at
418
- // once; RSVPs are stored as bookings of kind 'rsvp'
418
+ // once; RSVPs are stored as bookings of kind 'rsvp'. They close at closesAt
419
+ // (wall time in the event's zone, like start; every date), else closeBefore
420
+ // minutes before each date's start, else when the date ends.
419
421
  rsvp: z.object({
420
422
  enabled: z.boolean().default(false),
421
423
  capacity: z.number().int().default(0),
422
424
  autoConfirm: z.boolean().default(true),
423
425
  maxSeats: z.number().int().default(4),
426
+ closesAt: z.string().nullable().default(null),
427
+ closeBefore: z.number().int().default(0),
424
428
  form: FormDefinition.optional(),
425
429
  }).default({}),
426
430
  // rooms/people this event uses: they can't be booked while it happens (or
@@ -19,7 +19,7 @@ import { effectiveSettings } from '../../lib/designPreview';
19
19
  import { resolveDesign } from '../../lib/theme';
20
20
  import { siteOrigin } from '../../lib/seo';
21
21
  import { CALENDAR, eventUrl, withoutPrivateFields } from '../../lib/calendar/config';
22
- import { eventIsPublic, featuredOccurrence } from '../../lib/calendar/events';
22
+ import { eventIsPublic, featuredOccurrence, rsvpDeadline } from '../../lib/calendar/events';
23
23
  import { eventJsonLd } from '../../lib/calendar/seo';
24
24
  import { upcomingEvents } from '../../lib/calendar/cards';
25
25
  import { seatsTaken, BookingError } from '../../lib/booking/service';
@@ -50,6 +50,7 @@ const origin = siteOrigin(settings, Astro.url);
50
50
  // RSVP: "Save my seat" posts back here and lands on the manage page, in the
51
51
  // visitor's language (its emails speak it too)
52
52
  let rsvpError = '';
53
+ let rsvpDuplicate = false;
53
54
  let formValues: Record<string,string> = {};
54
55
  let coreValues = { name: '', email: '', seats: '1' };
55
56
  let requestId: string = crypto.randomUUID();
@@ -66,13 +67,19 @@ if (Astro.request.method === 'POST' && event.rsvp.enabled) {
66
67
  const booking = await submitEventForm(repo, event, occurrence, form, requestId, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) }, locale);
67
68
  return Astro.redirect(`${localePath(manageUrl(booking.manageToken), locale)}?new=1`, 303);
68
69
  } catch (err) {
69
- rsvpError = err instanceof BookingError || err instanceof ApiError ? err.message : rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings)).saveError;
70
+ const w = rsvpStrings((I18N.enabled ? locale : '') || siteLang(settings));
71
+ // the address already has one: a notice in the page's words (templates that don't know it show it as the error)
72
+ rsvpDuplicate = err instanceof BookingError && err.code === 'duplicate';
73
+ rsvpError = rsvpDuplicate ? `${w.duplicate} ${w.duplicateNote}` : err instanceof BookingError || err instanceof ApiError ? err.message : w.saveError;
70
74
  }
71
75
  }
72
76
  const formHtml = hiddenFields(await formVersion(definition), requestId) + `<input type="hidden" name="occurrence" value="${occurrence.start}">`;
73
77
  const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
78
+ // the event's RSVP deadline for this date (null: open until it ends)
79
+ const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
74
80
  const rsvp = event.rsvp.enabled
75
- ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues, open: occurrence.end > now && !occurrence.cancelled }
81
+ ? { going: taken, left: event.rsvp.capacity ? Math.max(0, event.rsvp.capacity - taken) : null, maxSeats: event.rsvp.maxSeats, error: rsvpError, duplicate: rsvpDuplicate, formHtml, fieldsHtml: renderFields(definition, formValues, 'event-registration'), values: coreValues,
82
+ open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt }
76
83
  : null;
77
84
  const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
78
85
  const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
@@ -1168,6 +1168,10 @@
1168
1168
  .pe-evr-modes + .pe-evs-fields { margin-top: 22px; }
1169
1169
  .pe-evr-link { grid-template-columns: minmax(0, 1fr) 150px; }
1170
1170
  .pe-evr-seats { grid-template-columns: repeat(2, minmax(0, 200px)); }
1171
+ .pe-evr-when { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
1172
+ .pe-evr-when > span { font-size: 13px; color: var(--muted); }
1173
+ .pe-field .pe-evr-when input, .pe-field .pe-evr-when select { width: auto; }
1174
+ .pe-field .pe-evr-when input[type='number'] { width: 84px; }
1171
1175
  .pe-evr-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--panel); }
1172
1176
  .pe-evr-form-t { flex: 1 1 240px; min-width: 0; }
1173
1177
  .pe-evr-form-t b { display: block; font-size: 13.5px; font-weight: 600; color: var(--text); }
@@ -88,6 +88,13 @@
88
88
  .ma-tag { display: inline-flex; align-items: center; gap: 5px; background: var(--sel); color: var(--text-2); font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: var(--r-full); }
89
89
  .ma-tag button { background: none; border: 0; color: inherit; cursor: pointer; font-size: 10px; padding: 0; }
90
90
  .ma-tags input { flex: 1; min-width: 70px; background: none !important; border: 0 !important; box-shadow: none !important; padding: 2px !important; }
91
+ /* "when an event changes": a card per rule */
92
+ .ma-subrules { display: grid; gap: 8px; margin-top: 10px; }
93
+ .ma-subrule { padding: 6px 12px 12px; border: 1px solid var(--line); border-radius: var(--r-md); min-width: 0; }
94
+ .ma-subrule-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 30px; }
95
+ .ma-subrule-head b { font-size: 12.5px; color: var(--text-2); }
96
+ .ma-subrule .ma-choices { margin-top: 2px; }
97
+ .ma-add { margin-top: 10px; }
91
98
  .ma .form-actions { margin-top: 6px; }
92
99
  .ma-saved { color: var(--green); font-size: 12.5px; font-weight: 500; }
93
100
 
@@ -1147,6 +1147,9 @@ li::marker { color: var(--muted); }
1147
1147
  .pc-ev-rsvpform[open] > summary { display: none; }
1148
1148
  .pc-ev-rsvpform form { margin-top: 4px; }
1149
1149
  .pc-ev-full { font-size: 14px; font-weight: 600; color: var(--muted); }
1150
+ .pc-ev-closes { font-size: 13.5px; color: var(--muted); margin: 0 0 10px; }
1151
+ .pc-ev-full + .pc-ev-closes { margin-top: 4px; }
1152
+ .pc-ev-notice { font-size: 14px; line-height: 1.5; background: var(--soft); border-radius: var(--r-sm); padding: 9px 12px; margin: 0 0 14px; }
1150
1153
 
1151
1154
  @media (max-width: 860px) {
1152
1155
  .pc-bk { grid-template-columns: 1fr; }
@@ -11,7 +11,7 @@ export interface HomeProps { settings: Settings; page: Page | null; posts: Post[
11
11
  export interface PageProps { settings: Settings; page: Page; html: string }
12
12
  export interface PostProps { settings: Settings; post: Post; html: string; authors: User[]; related: Post[] }
13
13
  import type { Occurrence } from '../lib/calendar/events';
14
- export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean}|null }
14
+ export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean;closesAt?:number|null;duplicate?:boolean}|null }
15
15
  /**
16
16
  * Props for a coded section. `data` holds resolved values: link fields arrive
17
17
  * as LinkValue, lists as row arrays, source fields as fresh item arrays