@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/migrations.generated.mjs +15 -0
  3. package/docs/mobile.md +12 -2
  4. package/integration.mjs +4 -0
  5. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +66 -31
  6. package/mobile-template/recipes/push/files/app/link-phone.tsx +43 -0
  7. package/mobile-template/recipes/push/files/app/notifications.tsx +14 -4
  8. package/mobile-template/recipes/push/recipe.json +4 -2
  9. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +66 -31
  10. package/mobile-template/recipes/rsvp/recipe.json +2 -2
  11. package/package.json +1 -1
  12. package/src/actions/index.ts +1 -1
  13. package/src/components/Announcements.tsx +11 -2
  14. package/src/components/EventPage.astro +16 -8
  15. package/src/components/MobileApp.tsx +20 -8
  16. package/src/components/TestPhones.tsx +177 -0
  17. package/src/components/editor/EventPanel.tsx +2 -2
  18. package/src/components/editor/PostEditor.tsx +1 -1
  19. package/src/components/editor/event/RegistrationCard.tsx +5 -0
  20. package/src/components/editor/event/registration.ts +1 -1
  21. package/src/lib/announcements/api.ts +19 -2
  22. package/src/lib/announcements/service.ts +1 -1
  23. package/src/lib/booking/agent.ts +1 -1
  24. package/src/lib/booking/emails.ts +20 -5
  25. package/src/lib/booking/service.ts +148 -14
  26. package/src/lib/calendar/rsvp-words.ts +69 -8
  27. package/src/lib/content-service.ts +11 -6
  28. package/src/lib/drafts.ts +2 -2
  29. package/src/lib/editor/bookings.ts +5 -3
  30. package/src/lib/editor/documents.ts +3 -2
  31. package/src/lib/editor/schemas.ts +1 -1
  32. package/src/lib/email/booking-words.ts +26 -2
  33. package/src/lib/email/service.ts +14 -2
  34. package/src/lib/email/store.ts +6 -2
  35. package/src/lib/email/templates.ts +20 -0
  36. package/src/lib/forms/events.ts +4 -2
  37. package/src/lib/legal/app-privacy.ts +4 -4
  38. package/src/lib/legal/text-en.ts +2 -2
  39. package/src/lib/legal/text-es.ts +2 -2
  40. package/src/lib/mobile/bookings.ts +4 -3
  41. package/src/lib/mobile/guest.ts +2 -1
  42. package/src/lib/mobile/manage.ts +17 -3
  43. package/src/lib/mobile/openapi.ts +22 -5
  44. package/src/lib/mobile/rsvp.ts +10 -5
  45. package/src/lib/mobile/schemas.ts +14 -3
  46. package/src/lib/push/api.ts +37 -8
  47. package/src/lib/push/service.ts +6 -2
  48. package/src/lib/push/staff.ts +162 -0
  49. package/src/lib/push/store.ts +49 -2
  50. package/src/lib/push/types.ts +4 -1
  51. package/src/lib/repo/migrations.ts +15 -0
  52. package/src/lib/repo/sql.ts +57 -14
  53. package/src/lib/repo/types.ts +11 -4
  54. package/src/lib/scheduler.ts +3 -1
  55. package/src/lib/schema.ts +10 -2
  56. package/src/pages/admin/calendar/bookings.astro +57 -13
  57. package/src/pages/api/announcements.ts +2 -2
  58. package/src/pages/api/calendar/bookings/index.ts +1 -1
  59. package/src/pages/api/calendar/docs.ts +2 -2
  60. package/src/pages/api/push/admin.ts +1 -1
  61. package/src/pages/api/push/phones.ts +25 -0
  62. package/src/pages/api/v1/bookings/manage/confirm.ts +6 -0
  63. package/src/pages/api/v1/devices/[token]/staff-link.ts +20 -0
  64. package/src/pages/book/manage/[token].astro +57 -15
  65. package/src/pages/events/[slug].astro +3 -2
  66. package/src/styles/admin.css +3 -1
  67. package/src/styles/site.css +10 -1
  68. package/src/styles/test-phones.css +30 -0
  69. package/src/theme-sdk/index.ts +1 -1
@@ -2,16 +2,16 @@
2
2
  import Site from '../../../layouts/Site.astro';
3
3
  import { getRepo } from '../../../lib/repo';
4
4
  import { CALENDAR, bookingLinkUrl, bookingsEnabled, eventUrl, resourceUrl } from '../../../lib/calendar/config';
5
- import { guestCancel, guestChanges, BookingError } from '../../../lib/booking/service';
5
+ import { confirmOffer, guestCancel, guestChanges, waitlistPosition, BookingError, WAITLIST_HOLD } from '../../../lib/booking/service';
6
6
  import { bookingIcs, whenText } from '../../../lib/booking/emails';
7
7
  import { resourceZone } from '../../../lib/booking/availability';
8
8
  import { eventTimeZone, locationLine } from '../../../lib/calendar/events';
9
9
  import { siteOrigin } from '../../../lib/seo';
10
10
  import { waitUntilFrom } from '../../../lib/webhooks';
11
11
  import { I18N, localePath, requestLocale } from '../../../lib/i18n';
12
- import { siteLang } from '../../../lib/dates';
12
+ import { siteLang, siteLocale } from '../../../lib/dates';
13
13
  import { isExtraLocale, localizeDoc } from '../../../lib/translations';
14
- import { rsvpStrings } from '../../../lib/calendar/rsvp-words';
14
+ import { deadlineParts, rsvpStrings } from '../../../lib/calendar/rsvp-words';
15
15
  import { bookingStrings } from '../../../lib/email/booking-words';
16
16
  import { fill } from '../../../lib/live/words';
17
17
 
@@ -21,7 +21,10 @@ import { fill } from '../../../lib/live/words';
21
21
  // speaks the request's language, for RSVPs and room bookings alike
22
22
  // (/es/book/manage/… in Spanish, as the emails of a booking made in Spanish
23
23
  // link it); opened without the prefix it shows the main language, with no
24
- // redirect. The room booking flow it links back to stays in English.
24
+ // redirect. The room booking flow it links back to stays in English. An RSVP
25
+ // on a full date's waitlist shows its place in line ("#3 in line · 2 seats")
26
+ // or the spot held for it ("A spot is yours until …", Confirm my spot), and
27
+ // can leave the line or release the spot.
25
28
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
26
29
  const repo = await getRepo(Astro.locals);
27
30
  const booking = await repo.getBookingByToken(Astro.params.token ?? '');
@@ -52,31 +55,62 @@ if (Astro.url.searchParams.get('ics')) {
52
55
  }
53
56
  let error = '';
54
57
  let current = booking;
58
+ // what this request just did: left the line (or released a held spot), or took the spot
59
+ let left = false, took = false;
55
60
  if (Astro.request.method === 'POST') {
56
61
  const form = await Astro.request.formData();
57
- if (form.get('op') === 'cancel' && (booking.status === 'pending' || booking.status === 'confirmed')) {
62
+ const flow = { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) };
63
+ if (form.get('op') === 'cancel' && ['pending', 'confirmed', 'waitlisted', 'offered'].includes(booking.status)) {
58
64
  try {
59
- current = await guestCancel(repo, booking, { origin, locals: Astro.locals, waitUntil: waitUntilFrom(Astro.locals) });
65
+ current = await guestCancel(repo, booking, flow);
66
+ left = booking.status === 'waitlisted' || booking.status === 'offered';
60
67
  } catch (err) {
61
68
  error = err instanceof BookingError ? err.message : w.cancelError;
62
69
  }
63
70
  }
71
+ if (form.get('op') === 'confirm' && booking.status === 'offered') {
72
+ try {
73
+ current = await confirmOffer(repo, booking, flow);
74
+ took = true;
75
+ } catch (err) {
76
+ current = (await repo.getBooking(booking.id)) ?? booking;
77
+ // released: the page says so in its own words
78
+ error = current.status === 'expired' ? '' : err instanceof BookingError ? err.message : w.confirmError;
79
+ }
80
+ }
64
81
  }
65
82
  const tz = event ? eventTimeZone(event, settings) : resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
66
- const when = whenText(current, tz, isExtraLocale(locale) ? { ...settings, locale } : settings, link?.mode === 'day' || event?.allDay);
67
- const active = current.status === 'pending' || current.status === 'confirmed';
83
+ const dateSettings = isExtraLocale(locale) ? { ...settings, locale } : settings;
84
+ const when = whenText(current, tz, dateSettings, link?.mode === 'day' || event?.allDay);
85
+ const waiting = current.status === 'waitlisted' || current.status === 'offered';
86
+ const active = current.status === 'pending' || current.status === 'confirmed' || waiting;
68
87
  const upcoming = current.end > Date.now();
69
88
  const can = guestChanges(current, link);
70
- const isNew = Astro.url.searchParams.has('new');
89
+ const isNew = Astro.url.searchParams.has('new') || took;
71
90
  const moved = Astro.url.searchParams.has('moved');
72
- const heading = current.status === 'cancelled' ? w.cancelledTitle
91
+ const hours = String(WAITLIST_HOLD / 3_600_000);
92
+ // "#3 in line · 2 seats"; "A spot is yours until Fri, Oct 24 at 9 PM"
93
+ const position = await waitlistPosition(repo, current);
94
+ const inLine = position === null ? '' : fill(w.inLine, { position: String(position), seats: fill(current.seats === 1 ? w.seat : w.seats, { count: String(current.seats) }) });
95
+ const holdUntil = current.status === 'offered' && current.holdUntil !== null ? deadlineParts(current.holdUntil, tz, siteLocale(dateSettings)) : null;
96
+ const heading = current.status === 'cancelled' ? (left ? w.offWaitlist : w.cancelledTitle)
73
97
  : current.status === 'declined' ? w.notAvailable
98
+ : current.status === 'waitlisted' ? w.onWaitlist
99
+ : current.status === 'offered' ? w.spotOpened
100
+ : current.status === 'expired' ? w.released
74
101
  : current.status === 'pending' ? (isNew ? w.requestSent : w.awaitingApproval)
75
102
  : moved ? bw.moved : isNew ? w.booked : w.yours;
76
103
  const lede = current.status === 'pending' ? fill(w.emailWhenApproved, { email: current.email })
77
104
  : current.status === 'confirmed' ? (current.formResponse?.definition.confirmation || link?.confirmMessage || fill(w.confirmationOnItsWay, { email: current.email }))
78
105
  : current.status === 'declined' ? w.declined
79
- : w.cancelledText;
106
+ : current.status === 'waitlisted' ? fill(w.waitlistPromise, { hours })
107
+ : current.status === 'offered' ? (holdUntil ? fill(w.spotYours, holdUntil) : '')
108
+ : current.status === 'expired' ? fill(w.releasedText, { hours })
109
+ : left ? w.offWaitlistText : w.cancelledText;
110
+ // a room booking's own words list only its four statuses
111
+ const statusLabel = (w.statuses as Record<string, string>)[current.status] ?? rsvpStrings(lang).statuses[current.status];
112
+ const cancelLabel = current.status === 'waitlisted' ? w.leaveWaitlist : current.status === 'offered' ? w.releaseSpot : w.cancel;
113
+ const cancelAsk = `return confirm(${JSON.stringify(current.status === 'waitlisted' ? w.leaveConfirm : current.status === 'offered' ? w.releaseConfirm : w.cancelConfirm)})`;
80
114
  const cutoff = fill(bw.cutoff, { hours: String(can.cutoffHours) });
81
115
  const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchParams({ action: 'TEMPLATE', text: title, dates: `${new Date(current.start).toISOString().replace(/[-:]|\.\d{3}/g, '')}/${new Date(current.end).toISOString().replace(/[-:]|\.\d{3}/g, '')}`, details: url, location })}`;
82
116
  ---
@@ -85,10 +119,12 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
85
119
  <div class="pc-manage">
86
120
  <div class={`pc-manage-icon is-${current.status}`} aria-hidden="true">
87
121
  {current.status === 'confirmed' ? <svg viewBox="0 0 24 24"><path d="M5 12l5 5L19 7" /></svg>
88
- : current.status === 'pending' ? <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" /><path d="M12 8v4l3 2" /></svg>
122
+ : current.status === 'pending' || current.status === 'offered' ? <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" /><path d="M12 8v4l3 2" /></svg>
123
+ : current.status === 'waitlisted' ? <svg viewBox="0 0 24 24"><path d="M5 7h14M5 12h14M5 17h8" /></svg>
89
124
  : <svg viewBox="0 0 24 24"><path d="M7 7l10 10M17 7 7 17" /></svg>}
90
125
  </div>
91
126
  <h1>{heading}</h1>
127
+ {inLine && <p class="pc-manage-wait">{inLine}</p>}
92
128
  <p class="pc-manage-lede">{lede}</p>
93
129
  {error && <p class="pc-bk-error">{error}</p>}
94
130
  <dl class="pc-manage-card">
@@ -96,7 +132,7 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
96
132
  <div><dt>{w.when}</dt><dd>{when}</dd></div>
97
133
  {location && <div><dt>{w.where}</dt><dd>{location}</dd></div>}
98
134
  {current.seats > 1 && <div><dt>{w.seatsField}</dt><dd>{current.seats}</dd></div>}
99
- <div><dt>{w.status}</dt><dd class={`pc-manage-status is-${current.status}`}>{w.statuses[current.status]}</dd></div>
135
+ <div><dt>{w.status}</dt><dd class={`pc-manage-status is-${current.status}`}>{statusLabel}</dd></div>
100
136
  <div><dt>{w.reference}</dt><dd class="mono">{current.id.slice(0, 8).toUpperCase()}</dd></div>
101
137
  </dl>
102
138
  {active && upcoming && (
@@ -111,13 +147,19 @@ const googleUrl = `https://calendar.google.com/calendar/render?${new URLSearchPa
111
147
  </div>
112
148
  </details>
113
149
  )}
150
+ {can.confirm && (
151
+ <form method="post">
152
+ <input type="hidden" name="op" value="confirm" />
153
+ <button class="pc-manage-btn is-primary" type="submit">{w.confirmSpot}</button>
154
+ </form>
155
+ )}
114
156
  {resource && link && can.reschedule && (
115
157
  <a class="pc-manage-btn" href={`${bookingLinkUrl(resource.slug, link.slug)}?reschedule=${encodeURIComponent(current.manageToken)}`}>{bw.reschedule}</a>
116
158
  )}
117
159
  {can.cancel && (
118
- <form method="post" onsubmit={`return confirm(${JSON.stringify(w.cancelConfirm)})`}>
160
+ <form method="post" onsubmit={cancelAsk}>
119
161
  <input type="hidden" name="op" value="cancel" />
120
- <button class="pc-manage-btn is-danger" type="submit">{w.cancel}</button>
162
+ <button class="pc-manage-btn is-danger" type="submit">{cancelLabel}</button>
121
163
  </form>
122
164
  )}
123
165
  </div>
@@ -22,7 +22,7 @@ import { CALENDAR, eventUrl, withoutPrivateFields } from '../../lib/calendar/con
22
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
- import { seatsTaken, BookingError } from '../../lib/booking/service';
25
+ import { seatsTaken, waitlistOn, BookingError } from '../../lib/booking/service';
26
26
  import { manageUrl } from '../../lib/calendar/config';
27
27
  import { waitUntilFrom } from '../../lib/webhooks';
28
28
 
@@ -77,9 +77,10 @@ const formHtml = hiddenFields(await formVersion(definition), requestId) + `<inpu
77
77
  const taken = event.rsvp.enabled ? await seatsTaken(repo, event.id, occurrence.start) : 0;
78
78
  // the event's RSVP deadline for this date (null: open until it ends)
79
79
  const closesAt = event.rsvp.enabled ? rsvpDeadline(occurrence, settings) : null;
80
+ // waitlist: a full date offers "Join the waitlist" (the same form, posting waitlist=1)
80
81
  const rsvp = event.rsvp.enabled
81
82
  ? { 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 }
83
+ open: occurrence.end > now && !occurrence.cancelled && !(closesAt !== null && closesAt <= now), closesAt, waitlist: waitlistOn(event) }
83
84
  : null;
84
85
  const calendars = event.calendarIds.map((id) => calMap.get(id)).filter((c): c is NonNullable<typeof c> => Boolean(c) && c!.visibility !== 'private');
85
86
  const html = highlightCodeHtml(await expandDynamicBlocks(event.bodyHtml, repo));
@@ -1476,7 +1476,9 @@ a.bk-row:hover, .bk-row.is-sel { background: var(--hover); }
1476
1476
  .bk-st { font-size: 12px; font-weight: 600; white-space: nowrap; }
1477
1477
  .bk-st.is-confirmed { color: var(--green); }
1478
1478
  .bk-st.is-pending { color: var(--amber); }
1479
- .bk-st.is-cancelled, .bk-st.is-declined, .bk-st.is-block { color: var(--muted); }
1479
+ .bk-st.is-cancelled, .bk-st.is-declined, .bk-st.is-block, .bk-st.is-expired { color: var(--muted); }
1480
+ .bk-st.is-waitlisted { color: var(--text-2); }
1481
+ .bk-st.is-offered { color: var(--accent); }
1480
1482
  .bk-panel { padding: 16px 18px; position: sticky; top: 64px; }
1481
1483
  .bk-panel-head { display: flex; justify-content: space-between; align-items: center; }
1482
1484
  .bk-panel h2 { font-size: 18px; margin: 6px 0 2px; }
@@ -1119,7 +1119,9 @@ li::marker { color: var(--muted); }
1119
1119
  .pc-manage-icon svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
1120
1120
  .pc-manage-icon.is-confirmed { background: color-mix(in srgb, var(--success) 14%, var(--panel)); color: var(--success); }
1121
1121
  .pc-manage-icon.is-pending { background: color-mix(in srgb, var(--warning) 14%, var(--panel)); color: var(--warning); }
1122
- .pc-manage-icon.is-cancelled, .pc-manage-icon.is-declined { background: var(--soft); color: var(--muted); }
1122
+ .pc-manage-icon.is-cancelled, .pc-manage-icon.is-declined, .pc-manage-icon.is-expired { background: var(--soft); color: var(--muted); }
1123
+ .pc-manage-icon.is-waitlisted { background: var(--soft); color: var(--ink); }
1124
+ .pc-manage-icon.is-offered { background: color-mix(in srgb, var(--success) 14%, var(--panel)); color: var(--success); }
1123
1125
  .pc-manage h1 { font-size: 30px; margin: 0 0 8px; }
1124
1126
  .pc-manage-lede { color: var(--muted); margin: 0 0 18px; }
1125
1127
  .pc-manage-card { text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); margin: 0; padding: 4px 18px; box-shadow: var(--sh-1); }
@@ -1130,12 +1132,17 @@ li::marker { color: var(--muted); }
1130
1132
  .pc-manage-card .mono { font-family: var(--font-mono); font-size: 13px; }
1131
1133
  .pc-manage-status.is-confirmed { color: var(--success); font-weight: 600; }
1132
1134
  .pc-manage-status.is-pending { color: var(--warning); font-weight: 600; }
1135
+ .pc-manage-status.is-waitlisted { font-weight: 600; }
1136
+ .pc-manage-status.is-offered { color: var(--success); font-weight: 600; }
1137
+ .pc-manage-wait { font-weight: 600; margin: 0 0 6px; }
1133
1138
  .pc-manage-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 16px; }
1134
1139
  .pc-manage-actions .pc-ev-add { margin: 0; }
1135
1140
  .pc-manage-actions .pc-ev-add > summary { width: auto; }
1136
1141
  .pc-manage-btn { display: inline-flex; align-items: center; height: 40px; padding: 0 14px; border: 1px solid var(--line-2); border-radius: 12px; background: var(--panel); font: 550 14px/1.2 var(--font-body); color: var(--ink); text-decoration: none; cursor: pointer; }
1137
1142
  .pc-manage-btn:hover { background: var(--soft); }
1138
1143
  .pc-manage-btn.is-danger { color: var(--danger); }
1144
+ .pc-manage-btn.is-primary { background: var(--btn); color: var(--btn-ink); border-color: transparent; }
1145
+ .pc-manage-btn.is-primary:hover { background: var(--btn); filter: brightness(1.08); }
1139
1146
  .pc-manage-note { font-size: 12.5px; color: var(--muted); margin-top: 22px; }
1140
1147
 
1141
1148
  /* RSVP on the event page */
@@ -1149,6 +1156,8 @@ li::marker { color: var(--muted); }
1149
1156
  .pc-ev-full { font-size: 14px; font-weight: 600; color: var(--muted); }
1150
1157
  .pc-ev-closes { font-size: 13.5px; color: var(--muted); margin: 0 0 10px; }
1151
1158
  .pc-ev-full + .pc-ev-closes { margin-top: 4px; }
1159
+ .pc-ev-full.is-waitlist { color: var(--ink); margin: 0 0 2px; }
1160
+ .pc-ev-waitlist { font-size: 13.5px; color: var(--muted); margin: 0 0 10px; }
1152
1161
  .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; }
1153
1162
 
1154
1163
  @media (max-width: 860px) {
@@ -0,0 +1,30 @@
1
+ /* Test phones panel and "Send test to my phone" (Admin → Announcements and
2
+ Admin → Mobile app). Scoped under .tp; admin.css tokens only. */
3
+ .tp { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; scroll-margin-top: 72px; }
4
+ .tp > * { align-self: stretch; }
5
+ .tp > .btn { align-self: flex-start; }
6
+ .tp-head h2 { font-size: 14.5px; margin: 0; letter-spacing: -0.01em; }
7
+ .tp-head p { margin: 2px 0 0; color: var(--muted); font-size: 13px; max-width: 64ch; }
8
+ .tp-note { margin: 0; max-width: 64ch; }
9
+ .tp-intro { margin: 0 0 14px; max-width: 64ch; }
10
+ .tp-empty { margin: 0; color: var(--text-2); font-size: 13px; }
11
+ .tp-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
12
+ .tp-list li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); }
13
+ .tp-phone { display: flex; flex-direction: column; min-width: 0; }
14
+ .tp-phone b { font-size: 13.5px; }
15
+ .tp-phone small { color: var(--muted); font-size: 12px; }
16
+ .tp-code { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 4px 16px; background: var(--panel-2); border-radius: var(--r-md); padding: 12px 14px; }
17
+ .tp-code p { margin: 0; color: var(--text-2); font-size: 13px; max-width: 52ch; }
18
+ .tp-code .btn { grid-column: 2; justify-self: start; margin-top: 4px; }
19
+ .tp-digits { grid-row: span 2; font: 700 30px/1.1 ui-monospace, 'SF Mono', Menlo, monospace; letter-spacing: 0.18em; font-variant-numeric: tabular-nums; }
20
+ .tp-flash .flash { margin: 0; }
21
+ /* an always-present live region; no gap while empty */
22
+ .tp > .tp-flash:empty { margin-top: -12px; }
23
+ .tp-send > .tp-flash:empty { margin-top: -8px; }
24
+ .tp-send { display: flex; flex-direction: column; align-items: center; gap: 8px; align-self: stretch; }
25
+ .tp-send .tp-flash { align-self: stretch; text-align: center; }
26
+ @media (max-width: 560px) {
27
+ .tp-code { grid-template-columns: minmax(0, 1fr); }
28
+ .tp-digits { grid-row: auto; }
29
+ .tp-code .btn { grid-column: 1; }
30
+ }
@@ -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;closesAt?:number|null;duplicate?: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;waitlist?: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