@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
@@ -1,16 +1,19 @@
1
- import { Fragment, useEffect, useState, type ReactNode } from 'react';
1
+ import { Fragment, useEffect, useRef, 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';
5
5
  import { isAcmsError, type FormField } from '@acms-subir/client';
6
- import { Button, answerProblemText, answerProblems, countWords, fill, rsvpDeadlineText, seatChoices, useCancelBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme } from '@acms-subir/native';
6
+ import { Button, answerProblemText, answerProblems, canJoinWaitlist, countWords, fill, holdText, isReleased, rsvpDeadlineText, seatChoices, useCancelBooking, useConfirmBooking, useGuest, useLocale, useManagedBooking, useRsvp, useRsvpStatus, useSite, useStrings, useTheme, waitlistLineText, waitlistSeatChoices } from '@acms-subir/native';
7
7
 
8
8
  // RSVP to one date of an event: <RsvpPanel slug={event.slug} start={occurrence.start} />,
9
9
  // with `start` exactly as useEvents returns it. Shows going and spots left,
10
10
  // seats, contact details and the event's questions, then the person's RSVP
11
11
  // with a way to cancel it; or why the date takes no RSVPs. Same rules as the
12
12
  // event's web page: open until the event's deadline or the date ends, one RSVP
13
- // per email and date, and the organiser may approve each RSVP. useRsvp keeps
13
+ // per email and date, and the organiser may approve each RSVP. A full date
14
+ // whose event keeps a waitlist offers "Join the waitlist" with the same form;
15
+ // then it shows their place in line ("#3 in line · 2 seats"), and when a spot
16
+ // opens, until when it's held, with Confirm my spot and Release it. useRsvp keeps
14
17
  // one idempotency key per RSVP, so sending again after a timeout can't
15
18
  // register anyone twice. Its words come from
16
19
  // useStrings() in the app's language; the event's own form title, labels and
@@ -37,9 +40,11 @@ const withOther = (f: FormField, value: string | string[] | undefined, other: st
37
40
  };
38
41
  // a count in bold inside its words ("{count} going")
39
42
  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));
43
+ // the site holds a spot freed from the waitlist this long
44
+ const HOLD_HOURS = '24';
40
45
 
41
- // children: shown under the person's RSVP (not a declined one), e.g. an offer
42
- // to get notified if the date, time or place changes
46
+ // children: shown under the person's RSVP (confirmed or waiting for approval,
47
+ // not a place in line), e.g. an offer to get notified if the date, time or place changes
43
48
  export function RsvpPanel({ slug, start, children }: { slug: string; start: string; children?: ReactNode }) {
44
49
  const t = useTheme();
45
50
  const words = useStrings();
@@ -55,6 +60,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
55
60
  const changes = site?.features.bookingChanges === true;
56
61
  const managed = useManagedBooking(changes ? stored : null);
57
62
  const cancel = useCancelBooking(stored);
63
+ const take = useConfirmBooking(stored);
58
64
  const [confirming, setConfirming] = useState(false);
59
65
  const [notice, setNotice] = useState('');
60
66
  const [name, setName] = useState('');
@@ -77,13 +83,17 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
77
83
  }, [site?.features.guestSignIn, guest.email]);
78
84
  // cancelled (here, from the email link or by the organiser) or gone: forget the token
79
85
  const gone = managed.data?.booking.status === 'cancelled' || (isAcmsError(managed.error) && managed.error.status === 404);
86
+ // whether it was a place in line or a held spot before then
87
+ const waited = useRef(false);
88
+ if (managed.data && managed.data.booking.status !== 'cancelled') waited.current = ['waitlisted', 'offered'].includes(managed.data.booking.status);
80
89
  useEffect(() => {
81
90
  if (!key || !stored || !gone) return;
82
91
  void tokens.remove(key);
83
92
  setStored(null);
84
93
  setConfirming(false);
85
94
  rsvp.reset();
86
- if (managed.data?.booking.status === 'cancelled') setNotice(words.rsvpCancelled);
95
+ take.reset();
96
+ if (managed.data?.booking.status === 'cancelled') setNotice(waited.current ? words.offWaitlist : words.rsvpCancelled);
87
97
  }, [key, stored, gone]);
88
98
 
89
99
  if (!s) return status.isPending ? <ActivityIndicator color={t.colors.accent} /> : null;
@@ -94,10 +104,12 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
94
104
  const seatsText = (n: number) => countWords(n, words.seat, words.seats, locale);
95
105
  // "RSVPs close Fri, Oct 24 at 9 PM", or once past it "They closed on …"
96
106
  const deadline = rsvpDeadlineText(s, words, locale);
107
+ // full, and the event keeps a waitlist: the same form takes a place in line
108
+ const joining = canJoinWaitlist(s);
97
109
  const going = (
98
110
  <Text style={t.styles.text.body}>
99
111
  {bold(s.going === 1 ? words.personGoing : words.peopleGoing, num(s.going))}
100
- {s.left !== null && <> · {bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
112
+ {s.left !== null && <> · {joining ? words.noSpotsLeft : bold(s.left === 1 ? words.spotLeft : words.spotsLeft, num(s.left))}</>}
101
113
  </Text>
102
114
  );
103
115
 
@@ -114,39 +126,59 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
114
126
  </View>
115
127
  );
116
128
  if ((stored && mine) || made) {
117
- const state = mine?.booking.status ?? made!.status;
129
+ // a held spot that ran out (before the site's next sweep, or while they confirmed it) reads as released
130
+ const ranOut = isReleased(take.error) || (mine?.booking.status === 'offered' && !mine.can.confirm);
131
+ const state = ranOut ? 'expired' : mine?.booking.status ?? made!.status;
132
+ const seats = mine?.booking.seats ?? made!.seats;
118
133
  const who = mine?.booking.email ?? made!.email;
119
134
  const thanks = mine ? mine.confirmation : made!.formResponse?.definition.confirmation || null;
120
135
  const canCancel = !!stored && changes && (mine ? mine.can.cancel : false);
136
+ // declined, or a held spot they didn't confirm in time: nothing left to change
137
+ const over = state === 'declined' || state === 'expired';
138
+ const forget = () => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); take.reset(); };
139
+ // leaving the line or releasing a held spot asks first, like cancelling
140
+ const leave = state === 'waitlisted' ? { label: words.leaveWaitlist, ask: words.leaveWaitlistAsk, busy: words.leaving }
141
+ : state === 'offered' ? { label: words.releaseSpot, ask: words.releaseSpotAsk, busy: words.releasing }
142
+ : { label: words.cancelRsvp, ask: fill(words.cancelRsvpFor, { seats: seatsText(seats) }), busy: words.cancelling };
143
+ const heading = state === 'waitlisted' ? words.onWaitlist : state === 'offered' ? words.spotOpened : state === 'expired' ? words.spotReleased
144
+ : !made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent;
145
+ const line = state === 'waitlisted' ? waitlistLineText({ position: mine?.booking.position, seats }, words, locale)
146
+ : state === 'offered' || state === 'expired' ? seatsText(seats)
147
+ : `${seatsText(seats)} · ${state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}`;
148
+ const body = state === 'waitlisted' ? fill(words.waitlistPromise, { hours: HOLD_HOURS })
149
+ : state === 'offered' ? holdText(mine?.booking.holdUntil, words, locale)
150
+ : state === 'expired' ? fill(words.spotReleasedNote, { hours: HOLD_HOURS })
151
+ : state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
152
+ : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined;
153
+ const failed = (e: unknown) => (isAcmsError(e) && e.status === 0 ? words.checkConnection : e instanceof Error ? e.message : '');
121
154
  return (<>
122
155
  <View style={[t.styles.card, { gap: 8 }]}>
123
- <Text accessibilityRole="header" style={t.styles.text.heading}>{!made ? words.yourRsvp : state === 'confirmed' ? words.confirmed : words.requestSent}</Text>
124
- <Text style={[t.styles.text.label, { color: t.colors.ink }]}>
125
- {seatsText(mine?.booking.seats ?? made!.seats)} · {state === 'confirmed' ? words.confirmed : state === 'pending' ? words.awaitingApproval : words.notConfirmed}
126
- </Text>
127
- <Text style={t.styles.text.body}>
128
- {state === 'confirmed' ? thanks || fill(words.confirmationOnItsWay, { email: who })
129
- : state === 'pending' ? fill(words.emailWhenApproved, { email: who }) : words.rsvpDeclined}
130
- </Text>
131
- {state === 'declined' && <Button label={words.rsvpAgain} variant="outline" onPress={() => { if (key) void tokens.remove(key); setStored(null); rsvp.reset(); }} />}
132
- {canCancel && state !== 'declined' && (confirming ? (
156
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{heading}</Text>
157
+ <Text style={[t.styles.text.label, { color: t.colors.ink }]}>{line}</Text>
158
+ {!!body && <Text style={t.styles.text.body}>{body}</Text>}
159
+ {state === 'offered' && !confirming && (
160
+ <Button label={take.isPending ? words.confirming : words.confirmSpot} disabled={take.isPending} onPress={() => take.mutate()} />
161
+ )}
162
+ {over && <Button label={words.rsvpAgain} variant="outline" onPress={forget} />}
163
+ {canCancel && !over && (confirming ? (
133
164
  <View style={{ gap: 8 }}>
134
- <Text style={t.styles.text.body}>{fill(words.cancelRsvpFor, { seats: seatsText(mine!.booking.seats) })}</Text>
165
+ <Text style={t.styles.text.body}>{leave.ask}</Text>
135
166
  <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
136
- <Button label={cancel.isPending ? words.cancelling : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
167
+ <Button label={cancel.isPending ? leave.busy : state === 'waitlisted' || state === 'offered' ? leave.label : words.yesCancel} disabled={cancel.isPending} onPress={() => cancel.mutate()} />
137
168
  <Button label={words.keepIt} variant="outline" disabled={cancel.isPending} onPress={() => setConfirming(false)} />
138
169
  </View>
139
170
  </View>
140
- ) : <Button label={words.cancelRsvp} variant="outline" onPress={() => setConfirming(true)} />)}
141
- {cancel.error && <Text style={errorText}>{isAcmsError(cancel.error) && cancel.error.status === 0 ? words.checkConnection : cancel.error.message}</Text>}
142
- {!canCancel && state !== 'declined' && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
171
+ ) : <Button label={leave.label} variant="outline" disabled={take.isPending} onPress={() => setConfirming(true)} />)}
172
+ {cancel.error && <Text style={errorText}>{failed(cancel.error)}</Text>}
173
+ {take.error && !isReleased(take.error) && <Text style={errorText}>{failed(take.error)}</Text>}
174
+ {!canCancel && !over && <Text style={t.styles.text.small}>{words.changeByEmail}</Text>}
143
175
  </View>
144
- {state !== 'declined' && children}
176
+ {(state === 'confirmed' || state === 'pending') && children}
145
177
  </>);
146
178
  }
147
179
 
148
180
  if (s.reason === 'disabled') return null;
149
- if (!s.open) return (
181
+ if (!s.open && !joining) return (
150
182
  <View style={[t.styles.card, { gap: 6 }]}>
151
183
  <Text accessibilityRole="header" style={t.styles.text.heading}>{s.reason === 'full' ? words.full : s.reason === 'cancelled' ? words.dateCancelled : words.rsvpClosed}</Text>
152
184
  {s.reason === 'full' ? going : <Text style={t.styles.text.muted}>{s.reason === 'cancelled' ? words.noRsvpNeeded : s.reason === 'closed' ? deadline || words.rsvpClosed : words.dateEnded}</Text>}
@@ -162,12 +194,14 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
162
194
  const emailProblem = !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) ? words.emailProblem : '';
163
195
  // the form may ask for a phone itself
164
196
  const askPhone = !fields.some((f) => f.type === 'phone');
165
- const chosen = Math.min(seats, s.maxSeats) || 1;
197
+ // a place in line may ask for the event's own limit (the status's maxSeats is what's left)
198
+ const choices = joining ? waitlistSeatChoices(s) : seatChoices(s);
199
+ const chosen = Math.min(seats, choices.length) || 1;
166
200
  const submit = () => {
167
201
  setTried(true);
168
202
  setNotice('');
169
203
  if (nameProblem || emailProblem || Object.keys(problems).length) return;
170
- rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers }, {
204
+ rsvp.mutate({ name, email, phone: askPhone ? phone : '', seats: chosen, answers, waitlist: joining }, {
171
205
  onSuccess: (data) => { if (key) void tokens.set(key, data.manageToken); setStored(data.manageToken); },
172
206
  });
173
207
  };
@@ -243,20 +277,21 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
243
277
  return (
244
278
  <View style={[t.styles.card, { gap: 16 }]}>
245
279
  <View style={{ gap: 4 }}>
246
- <Text accessibilityRole="header" style={t.styles.text.heading}>{form?.title || words.rsvpTitle}</Text>
280
+ <Text accessibilityRole="header" style={t.styles.text.heading}>{joining ? words.full : form?.title || words.rsvpTitle}</Text>
247
281
  {going}
248
282
  {!!deadline && <Text style={t.styles.text.muted}>{deadline}</Text>}
283
+ {joining && <Text style={t.styles.text.muted}>{words.waitlistNote}</Text>}
249
284
  {!!notice && <Text style={t.styles.text.muted}>{notice}</Text>}
250
285
  {!!form?.intro && <Text style={t.styles.text.muted}>{form.intro}</Text>}
251
286
  </View>
252
287
  {text(words.nameField, name, setName, { required: true, auto: 'name', problem: tried ? nameProblem : '' })}
253
288
  {text(words.emailField, email, setEmail, { required: true, keyboard: 'email-address', auto: 'email', problem: tried ? emailProblem : '' })}
254
289
  {askPhone && text(words.phoneField, phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
255
- {s.maxSeats > 1 && (
290
+ {choices.length > 1 && (
256
291
  <View style={{ gap: 6 }}>
257
292
  {label(words.seatsField, false)}
258
293
  <View accessibilityRole="radiogroup" style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
259
- {seatChoices(s).map((n) => (
294
+ {choices.map((n) => (
260
295
  <Pressable key={n} accessibilityRole="radio" accessibilityLabel={seatsText(n)} accessibilityState={{ checked: n === chosen }} onPress={() => setSeats(n)} style={[chip(n === chosen), { minWidth: 48, alignItems: 'center' }]}>
261
296
  <Text style={[t.styles.text.body, n === chosen && { fontWeight: '600' }]}>{num(n)}</Text>
262
297
  </Pressable>
@@ -274,7 +309,7 @@ export function RsvpPanel({ slug, start, children }: { slug: string; start: stri
274
309
  </View>
275
310
  )}
276
311
  {tried && (nameProblem || emailProblem || Object.keys(problems).length > 0) && <Text style={errorText}>{words.checkAnswers}</Text>}
277
- <Button label={rsvp.isPending ? words.sending : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
312
+ <Button label={rsvp.isPending ? words.sending : joining ? words.joinWaitlist : form?.submitLabel || (s.autoConfirm ? words.confirmSeat : words.requestSeat)} disabled={rsvp.isPending} onPress={submit} />
278
313
  <Text style={t.styles.text.small}>{words.rsvpPrivacy}</Text>
279
314
  </View>
280
315
  );
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "kind": "client",
3
3
  "title": "RSVP",
4
- "description": "An RSVP panel for an event date: going and spots left, seats, the event's questions with inline errors, then the person's RSVP with Cancel, or why the date is full or closed. Already part of the events recipe; add it to an event screen of your own.",
4
+ "description": "An RSVP panel for an event date: going and spots left, seats, the event's questions with inline errors, then the person's RSVP with Cancel, or why the date is full or closed. A full date whose event keeps a waitlist takes places in line, with Confirm my spot when one opens. Already part of the events recipe; add it to an event screen of your own.",
5
5
  "packages": [
6
6
  "expo-crypto",
7
7
  "expo-secure-store"
@@ -11,6 +11,6 @@
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
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
+ "Anything you pass as children shows under the person's RSVP (confirmed or waiting for approval, not a place in line), 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)"
15
15
  ]
16
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.40",
3
+ "version": "0.3.42",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -995,7 +995,7 @@ export const server = {
995
995
  calendarIds: z.array(z.string()).max(50).optional(),
996
996
  fields: z.record(z.string().max(2000)).optional(),
997
997
  rsvp: z.object({ enabled: z.boolean(), capacity: z.number().int().min(0).max(100000), autoConfirm: z.boolean(), maxSeats: z.number().int().min(1).max(50),
998
- closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), form: z.any().optional() }).optional(),
998
+ closesAt: z.string().max(40).nullable().optional(), closeBefore: z.number().int().min(0).max(86400).optional(), notifyStaff: z.boolean().optional(), waitlist: z.boolean().optional(), form: z.any().optional() }).optional(),
999
999
  roomHold: z.object({ resourceIds: z.array(z.string()).max(50), span: z.enum(['event', 'day']), before: z.number().int().min(0).max(1440), after: z.number().int().min(0).max(1440), whileDraft: z.boolean(), keep: z.array(z.string()).max(500).default([]) }).optional(),
1000
1000
  scheduledAt: nullableTs.optional(),
1001
1001
  }),
@@ -3,6 +3,7 @@ import type { AdminAnnouncementAction, AdminAnnouncementsState } from '../lib/an
3
3
  import type { Announcement, AnnouncementInput, AnnouncementKind, AnnouncementWords } from '../lib/announcements/types';
4
4
  import type { PushNotification } from '../lib/push/types';
5
5
  import { announcementStrings } from '../lib/announcements/words';
6
+ import TestPhones, { SendTest } from './TestPhones';
6
7
  import '../styles/announcements.css';
7
8
 
8
9
  // Admin → Announcements: write a message once, choose where it shows (the
@@ -274,6 +275,7 @@ function List({ state, setState, tab, setTab, flash, setFlash, focusTabs, onEdit
274
275
  {state.base && <a href={`${state.site.origin}${state.base}`} target="_blank" rel="noopener">View on the site ↗</a>}
275
276
  </p>
276
277
  )}
278
+ {state.push.enabled && <TestPhones />}
277
279
  <dialog className="modal" ref={dialog} aria-labelledby="an-del-h" onClose={() => setDoomed(null)}>
278
280
  <div className="modal-head"><span id="an-del-h">Delete this announcement?</span>
279
281
  <button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
@@ -681,7 +683,12 @@ function Composer({ state, setState, startId, onClose }: {
681
683
  {tz && state.site.timezone && tz !== state.site.timezone && <p className="hint an-after">Times are in this device’s time zone ({tz}); the site uses {state.site.timezone}.</p>}
682
684
  </div>
683
685
  </div>
684
- <Preview state={state} form={form} lang={previewLang} setLang={setPreviewLang} />
686
+ <Preview state={state} form={form} lang={previewLang} setLang={setPreviewLang}>
687
+ {state.push.enabled && (
688
+ <SendTest problem={!localize(form, previewLang, main.code).title.trim() ? 'Add a title first.' : badLink || (form.linkRef === CUSTOM && !link) ? 'Fix the button’s address first.' : undefined}
689
+ send={() => api<{ notification: PushNotification }>({ action: 'test', announcement: input, locale: previewLang }).then((r) => r.notification)} />
690
+ )}
691
+ </Preview>
685
692
  </div>
686
693
 
687
694
  <dialog className="modal an-review" ref={review} aria-labelledby={`${uid}-review-h`}>
@@ -764,7 +771,8 @@ function Toggle({ label, note, checked, disabled, onChange }: { label: string; n
764
771
 
765
772
  // A phone lock screen with the push, and the website banner when pinned.
766
773
  // Device and site previews: fixed neutral colours, not admin tokens.
767
- function Preview({ state, form, lang, setLang }: { state: State; form: Form; lang: string; setLang: (l: string) => void }) {
774
+ // children: "Send test to my phone", under the phone
775
+ function Preview({ state, form, lang, setLang, children }: { state: State; form: Form; lang: string; setLang: (l: string) => void; children?: ReactNode }) {
768
776
  const [open, setOpen] = useState(false);
769
777
  const [time, setTime] = useState(() => new Date());
770
778
  useEffect(() => { const t = setInterval(() => setTime(new Date()), 30_000); return () => clearInterval(t); }, []);
@@ -803,6 +811,7 @@ function Preview({ state, form, lang, setLang }: { state: State; form: Form; lan
803
811
  </div>
804
812
  </div>
805
813
  <p className="hint an-cap">{push ? 'Phones shorten long text; keep the key words first.' : 'Not pushed. Tick Push to phones to send it.'}</p>
814
+ {children}
806
815
  {form.pinned && (
807
816
  <div className="an-site">
808
817
  <small className="an-site-label">Website banner</small>
@@ -28,8 +28,9 @@ interface Props {
28
28
  preview?: boolean;
29
29
  // RSVP state when the event takes RSVPs (null otherwise). closesAt: this
30
30
  // date's RSVP deadline (ms; null = open until it ends); duplicate: the
31
- // address already has an RSVP for it (error then holds the same words)
32
- rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; closesAt?: number | null; duplicate?: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
31
+ // address already has an RSVP for it (error then holds the same words);
32
+ // waitlist: a full date takes places in line (the form posts waitlist=1)
33
+ rsvp?: { going: number; left: number | null; maxSeats: number; error: string; open: boolean; closesAt?: number | null; duplicate?: boolean; waitlist?: boolean; formHtml?: string; fieldsHtml?: string; values?: { name: string; email: string; seats: string } } | null;
33
34
  }
34
35
  const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
35
36
  // the whole event, or just this date of a repeating one
@@ -81,6 +82,10 @@ const w = rsvpStrings(lang);
81
82
  const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
82
83
  const left = rsvp && rsvp.left !== null ? aroundCount(rsvp.left, w.spotLeft, w.spotsLeft) : ['', ''];
83
84
  const taking = Boolean(rsvp?.open) && (rsvp!.left === null || rsvp!.left > 0);
85
+ // full, with a waitlist: "Full · 40 going · no spots left", then "Join the waitlist" with the same form
86
+ const joining = Boolean(rsvp?.open && rsvp.waitlist && rsvp.left === 0);
87
+ const formClass = joining ? 'pc-ev-rsvpform is-waitlist' : 'pc-ev-rsvpform';
88
+ const seatOptions = rsvp ? Math.min(rsvp.maxSeats, joining ? rsvp.maxSeats : rsvp.left ?? rsvp.maxSeats) : 0;
84
89
  // "RSVPs close Fri, Oct 24 at 9 PM" while open; once past, "They closed on Fri, Oct 24."
85
90
  const deadline = typeof rsvp?.closesAt === 'number' ? deadlineParts(rsvp.closesAt, tz, locale) : null;
86
91
  const closesLine = deadline && taking ? fill(w.closesOn, deadline) : '';
@@ -157,13 +162,16 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
157
162
  ))}
158
163
  {rsvp ? (
159
164
  <div class="pc-ev-rsvp">
160
- <p class="pc-ev-going">{going[0]}<b>{rsvp.going}</b>{going[1]}{rsvp.left !== null && <> · {left[0]}<b>{rsvp.left}</b>{left[1]}</>}</p>
165
+ {joining && <p class="pc-ev-full is-waitlist">{w.fullTag}</p>}
166
+ <p class="pc-ev-going">{going[0]}<b>{rsvp.going}</b>{going[1]}{rsvp.left !== null && (joining ? <> · {w.noSpotsLeft}</> : <> · {left[0]}<b>{rsvp.left}</b>{left[1]}</>)}</p>
161
167
  {closesLine && <p class="pc-ev-closes">{closesLine}</p>}
162
- {taking ? (
163
- <details class="pc-ev-rsvpform" open={Boolean(rsvp.error)}>
164
- <summary class="pc-ev-cta">{w.saveSeat}</summary>
168
+ {joining && <p class="pc-ev-waitlist">{w.waitlistNote}</p>}
169
+ {taking || joining ? (
170
+ <details class={formClass} open={Boolean(rsvp.error)}>
171
+ <summary class="pc-ev-cta">{joining ? w.joinWaitlist : w.saveSeat}</summary>
165
172
  <form method="post">
166
173
  <Fragment set:html={rsvp.formHtml ?? ''}/>
174
+ {joining && <input type="hidden" name="waitlist" value="1" />}
167
175
  {e.rsvp.form?.title && <h3>{e.rsvp.form.title}</h3>}
168
176
  {e.rsvp.form?.intro && <p>{e.rsvp.form.intro}</p>}
169
177
  {rsvp.duplicate ? <p class="pc-ev-notice" role="status"><b>{w.duplicate}</b> {w.duplicateNote}</p> : rsvp.error && <p class="pc-bk-error">{rsvp.error}</p>}
@@ -171,11 +179,11 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
171
179
  <label class="pc-bk-field"><span>{w.emailField}</span><input name="email" type="email" required maxlength="200" autocomplete="email" value={rsvp.values?.email} /></label>
172
180
  {rsvp.maxSeats > 1 && (
173
181
  <label class="pc-bk-field"><span>{w.seatsField}</span>
174
- <select name="seats">{Array.from({ length: Math.min(rsvp.maxSeats, rsvp.left ?? rsvp.maxSeats) }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
182
+ <select name="seats">{Array.from({ length: seatOptions }, (_, i) => <option value={i + 1} selected={Number(rsvp.values?.seats || 1) === i + 1}>{i + 1}</option>)}</select>
175
183
  </label>
176
184
  )}
177
185
  <Fragment set:html={rsvp.fieldsHtml ?? ''}/>
178
- <button class="pc-bk-submit" type="submit">{e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? w.confirmSeat : w.requestSeat)}</button>
186
+ <button class="pc-bk-submit" type="submit">{joining ? w.joinWaitlist : e.rsvp.form?.submitLabel || (e.rsvp.autoConfirm ? w.confirmSeat : w.requestSeat)}</button>
179
187
  </form>
180
188
  </details>
181
189
  ) : <p class="pc-ev-full">{rsvp.open ? w.full : w.closed}</p>}
@@ -1,6 +1,7 @@
1
- import { useEffect, useId, useMemo, useRef, useState } from 'react';
1
+ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import type { AdminPushState } from '../lib/push/api';
3
3
  import type { ChangeRule, PushAuto, PushNotification } from '../lib/push/types';
4
+ import TestPhones, { SendTest } from './TestPhones';
4
5
  import '../styles/mobile-app.css';
5
6
 
6
7
  // Admin → Mobile app: send a notification to the site's own app, choose the
@@ -37,7 +38,7 @@ function ago(ms: number, now: number) {
37
38
  : d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
38
39
  }
39
40
 
40
- const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed' };
41
+ const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live', announcement: 'Announcement by', change: 'Event changed', test: 'Test by' };
41
42
 
42
43
  export default function MobileApp() {
43
44
  const [state, setState] = useState<AdminPushState | null>(null);
@@ -74,6 +75,7 @@ function MobileAppView({ state, setState }: { state: AdminPushState; setState: (
74
75
  </div>
75
76
  )}
76
77
  <Composer state={state} setState={setState} topicLabel={topicLabel} />
78
+ <TestPhones />
77
79
  <div className="ma-grid ma-grid-2">
78
80
  <Automatic state={state} setState={setState} />
79
81
  <Audience state={state} topicLabel={topicLabel} />
@@ -116,8 +118,14 @@ function Composer({ state, setState, topicLabel }: { state: AdminPushState; setS
116
118
  }, [mode, topics.join('|'), state.stats.devices]);
117
119
 
118
120
  const toggle = (id: string) => setTopics((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
119
- const problem = !title.trim() ? 'Add a title.' : mode === 'topics' && !topics.length ? 'Choose at least one topic.'
120
- : linkRef === CUSTOM && url && !/^(\/(?!\/)|https?:\/\/)/i.test(url) ? 'Links start with / or https://.' : '';
121
+ const badLink = linkRef === CUSTOM && url && !/^(\/(?!\/)|https?:\/\/)/i.test(url) ? 'Links start with / or https://.' : '';
122
+ const problem = !title.trim() ? 'Add a title.' : mode === 'topics' && !topics.length ? 'Choose at least one topic.' : badLink;
123
+ // a test goes to the sender's own phones: the audience doesn't matter
124
+ const sendTest = async () => {
125
+ const r = await api<{ notification: PushNotification; state: AdminPushState }>({ action: 'test', title: title.trim(), body: body.trim(), url });
126
+ setState(r.state);
127
+ return r.notification;
128
+ };
121
129
 
122
130
  const send = async () => {
123
131
  setBusy(true); setMsg({});
@@ -194,7 +202,9 @@ function Composer({ state, setState, topicLabel }: { state: AdminPushState; setS
194
202
  <button type="button" className="btn primary" disabled={!!problem || busy} title={problem || undefined} onClick={() => dialog.current?.showModal()}>Send…</button>
195
203
  </div>
196
204
  </div>
197
- <LockScreen state={state} title={title} body={body} />
205
+ <LockScreen state={state} title={title} body={body}>
206
+ <SendTest send={sendTest} problem={!title.trim() ? 'Add a title.' : badLink || undefined} />
207
+ </LockScreen>
198
208
  <dialog className="modal" ref={dialog} aria-labelledby="ma-confirm-h">
199
209
  <div className="modal-head"><span id="ma-confirm-h">Send to {count === null ? 'these devices' : plural(count, 'device')}?</span>
200
210
  <button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
@@ -217,7 +227,8 @@ function Composer({ state, setState, topicLabel }: { state: AdminPushState; setS
217
227
 
218
228
  // A phone lock screen with the notification as it will show. Device preview:
219
229
  // fixed neutral colors (like the site previews), not admin tokens.
220
- function LockScreen({ state, title, body }: { state: AdminPushState; title: string; body: string }) {
230
+ // children: "Send test to my phone", under the phone
231
+ function LockScreen({ state, title, body, children }: { state: AdminPushState; title: string; body: string; children?: ReactNode }) {
221
232
  const [now, setNow] = useState(() => new Date());
222
233
  useEffect(() => { const t = setInterval(() => setNow(new Date()), 30_000); return () => clearInterval(t); }, []);
223
234
  return (
@@ -244,6 +255,7 @@ function LockScreen({ state, title, body }: { state: AdminPushState; title: stri
244
255
  </div>
245
256
  </div>
246
257
  <figcaption className="hint">Phones shorten long text; keep the key words first.</figcaption>
258
+ {children}
247
259
  </figure>
248
260
  );
249
261
  }
@@ -450,8 +462,8 @@ function DeliveryLog({ log, topicLabel }: { log: PushNotification[]; topicLabel:
450
462
  {n.body && <div className="ma-body">{n.body}</div>}
451
463
  {n.url && <div className="t-sub ma-url">{n.url}</div>}
452
464
  </td>
453
- <td data-l="Audience" className="ma-aud">{n.topics.map(topicLabel).join(', ')}</td>
454
- <td data-l="From">{SOURCE[n.source]}{n.source === 'manual' && n.author ? ` ${n.author}` : n.source === 'manual' ? ' an owner' : ''}</td>
465
+ <td data-l="Audience" className="ma-aud">{n.source === 'test' ? 'Their test phones' : n.topics.map(topicLabel).join(', ')}</td>
466
+ <td data-l="From">{SOURCE[n.source]}{(n.source === 'manual' || n.source === 'test') && n.author ? ` ${n.author}` : n.source === 'manual' || n.source === 'test' ? ' an owner' : ''}</td>
455
467
  <td data-l="Delivered" className="num ma-result">
456
468
  {n.status === 'sending' ? <span className="ma-st" data-tone="info">Sending…</span>
457
469
  : !n.recipients ? <span className="ma-st">No devices</span>
@@ -0,0 +1,177 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+ import type { TestPhonesState } from '../lib/push/staff';
3
+ import type { PushNotification } from '../lib/push/types';
4
+ import '../styles/test-phones.css';
5
+
6
+ // Test phones (Admin → Announcements and Admin → Mobile app): each staff
7
+ // member links their own phone once with a one-time code typed into the app,
8
+ // then "Send test to my phone" in a composer reaches only their phones. The
9
+ // owner password has no account (or email) to link a phone to.
10
+
11
+ const ENDPOINT = '/api/push/phones';
12
+ // LINK_CODE_MS (lib/push/staff, which brings the server)
13
+ const CODE_MS = 10 * 60_000;
14
+ type State = TestPhonesState & { code?: string };
15
+
16
+ async function api<T>(body?: Record<string, unknown>): Promise<T> {
17
+ const res = await fetch(ENDPOINT, {
18
+ method: body ? 'POST' : 'GET', credentials: 'same-origin',
19
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
20
+ body: body ? JSON.stringify(body) : undefined,
21
+ });
22
+ const data = await res.json().catch(() => null);
23
+ if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
24
+ return data as T;
25
+ }
26
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
27
+ const PLATFORM = { ios: 'iPhone or iPad', android: 'Android phone' } as const;
28
+ const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
29
+ function when(ms: number, now: number) {
30
+ const s = Math.round((now - ms) / 1000);
31
+ if (s < 60) return 'just now';
32
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
33
+ const d = new Date(ms);
34
+ return new Date(now).toDateString() === d.toDateString() ? `today, ${clock(ms)}`
35
+ : d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
36
+ }
37
+
38
+ /**
39
+ * The panel: this person's linked phones (Remove) and "Link my phone", which
40
+ * shows a code and waits for the app to use it. `embedded`: inside a dialog
41
+ * (no card, no anchor). `onPhones` hears how many are linked.
42
+ */
43
+ export default function TestPhones({ embedded = false, onPhones }: { embedded?: boolean; onPhones?: (n: number) => void }) {
44
+ const uid = useId();
45
+ const [state, setState] = useState<State | null>(null);
46
+ const [loadError, setLoadError] = useState('');
47
+ const [code, setCode] = useState<{ code: string; expiresAt: number } | null>(null);
48
+ const [busy, setBusy] = useState('');
49
+ const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
50
+ const root = useRef<HTMLElement>(null);
51
+ const show = (s: State) => { setState(s); onPhones?.(s.phones.length); };
52
+
53
+ useEffect(() => { api<State>().then(show, (e) => setLoadError(errText(e))); }, []);
54
+ // the link in the "A phone was linked" email lands here
55
+ useEffect(() => { if (state && !embedded && location.hash === '#test-phones') root.current?.scrollIntoView({ block: 'start' }); }, [state === null]);
56
+ // while a code waits, look for the phone that uses it
57
+ useEffect(() => {
58
+ if (!code) return;
59
+ const t = setInterval(async () => {
60
+ if (Date.now() >= code.expiresAt) { setCode(null); setMsg({ err: 'That code expired. Make a new one when your phone is ready.' }); return; }
61
+ const next = await api<State>().catch(() => null);
62
+ if (!next || next.codeExpiresAt !== null) return;
63
+ setCode(null);
64
+ show(next);
65
+ // used (a phone linked since it was made), or voided after too many wrong codes on the site
66
+ setMsg(next.phones.some((p) => p.linkedAt >= code.expiresAt - CODE_MS) ? { ok: 'Phone linked. Tests you send now reach it.' } : { err: 'That code stopped working. Make a new one.' });
67
+ }, 4000);
68
+ return () => clearInterval(t);
69
+ }, [code]);
70
+
71
+ const run = async (body: Record<string, unknown>, label: string) => {
72
+ setBusy(label); setMsg({});
73
+ try {
74
+ const next = await api<State>(body);
75
+ show(next);
76
+ if (next.code && next.codeExpiresAt) setCode({ code: next.code, expiresAt: next.codeExpiresAt });
77
+ else if (body.action === 'remove') setMsg({ ok: 'Removed. It won’t get your tests.' });
78
+ } catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(''); }
79
+ };
80
+
81
+ const now = Date.now();
82
+ return (
83
+ <section ref={root} className={embedded ? 'tp' : 'card card-pad tp'} id={embedded ? undefined : 'test-phones'} aria-labelledby={embedded ? undefined : `${uid}-h`}>
84
+ {!embedded && (
85
+ <header className="tp-head">
86
+ <h2 id={`${uid}-h`}>Test phones</h2>
87
+ <p>Link your own phone to see a notification on it before it goes out. Only you get your tests.</p>
88
+ </header>
89
+ )}
90
+ {loadError ? <p className="flash error" role="alert">{loadError}</p> : !state ? <p className="hint">Loading…</p> : !state.canLink ? (
91
+ <p className="hint tp-note">You’re signed in with the owner password, which has no account to link a phone to. Sign in with a Team account that has an email to link one. Owners create accounts in Team → Users.</p>
92
+ ) : (
93
+ <>
94
+ {state.phones.length > 0 ? (
95
+ <ul className="tp-list">
96
+ {state.phones.map((p) => (
97
+ <li key={p.id}>
98
+ <span className="tp-phone">
99
+ <b>{PLATFORM[p.platform] ?? p.platform}</b>
100
+ <small>Linked {when(p.linkedAt, now)} · App last opened {when(p.lastSeenAt, now)}</small>
101
+ </span>
102
+ <button type="button" className="btn sm" disabled={busy !== ''} onClick={() => run({ action: 'remove', id: p.id }, p.id)}>
103
+ {busy === p.id ? 'Removing…' : 'Remove'}</button>
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ ) : <p className="tp-empty">No phone linked yet.</p>}
108
+ {code ? (
109
+ <div className="tp-code" role="group" aria-label="Link code">
110
+ {/* read out digit by digit */}
111
+ <b className="tp-digits" aria-hidden="true">{code.code}</b>
112
+ <span className="sr-only">Code: {code.code.split('').join(' ')}.</span>
113
+ <p>In the app, open Notifications and choose Link this phone, then enter this code. It works once, until {clock(code.expiresAt)}.</p>
114
+ <button type="button" className="btn sm" disabled={busy !== ''} onClick={() => run({ action: 'code' }, 'code')}>New code</button>
115
+ </div>
116
+ ) : (
117
+ <button type="button" className="btn" disabled={busy !== ''} onClick={() => run({ action: 'code' }, 'code')}>
118
+ {busy === 'code' ? 'Making a code…' : state.phones.length ? 'Link another phone' : 'Link my phone'}</button>
119
+ )}
120
+ </>
121
+ )}
122
+ <div className="tp-flash" aria-live="polite">
123
+ {msg.ok && <p className="flash">{msg.ok}</p>}
124
+ {msg.err && <p className="flash error">{msg.err}</p>}
125
+ </div>
126
+ </section>
127
+ );
128
+ }
129
+
130
+ /** what a test send says: how many of the sender's phones it went to */
131
+ export function testResult(n: PushNotification): { ok?: string; err?: string } {
132
+ if (n.status === 'failed') return { err: `Not delivered: ${n.error || 'the push service refused it.'}` };
133
+ if (n.ok === n.sent) return { ok: n.sent === 1 ? 'Test sent to your phone.' : `Test sent to your ${n.sent} phones.` };
134
+ return { ok: `Test sent to ${n.ok} of your ${n.sent} phones.${n.error ? ` ${n.error}` : ''}` };
135
+ }
136
+
137
+ /**
138
+ * "Send test to my phone" for a composer. `send` posts the draft as it
139
+ * stands; `problem` disables it. With no phone linked yet it opens the Test
140
+ * phones panel in a dialog instead.
141
+ */
142
+ export function SendTest({ send, problem }: { send: () => Promise<PushNotification>; problem?: string }) {
143
+ const uid = useId();
144
+ const [phones, setPhones] = useState<number | null>(null);
145
+ const [busy, setBusy] = useState(false);
146
+ const [msg, setMsg] = useState<{ ok?: string; err?: string }>({});
147
+ const dialog = useRef<HTMLDialogElement>(null);
148
+ const run = async () => {
149
+ setBusy(true); setMsg({});
150
+ try { setMsg(testResult(await send())); } catch (e) { setMsg({ err: errText(e) }); } finally { setBusy(false); }
151
+ };
152
+ return (
153
+ <div className="tp-send">
154
+ {phones === 0 ? (
155
+ <button type="button" className="btn" onClick={() => { setMsg({}); dialog.current?.showModal(); }}>Link a phone to send tests…</button>
156
+ ) : (
157
+ <button type="button" className="btn" disabled={busy || !!problem} title={problem || undefined} onClick={run}>{busy ? 'Sending test…' : 'Send test to my phone'}</button>
158
+ )}
159
+ <div className="tp-flash" aria-live="polite">
160
+ {msg.ok && <p className="flash">{msg.ok}</p>}
161
+ {msg.err && <p className="flash error">{msg.err}</p>}
162
+ </div>
163
+ <dialog className="modal" ref={dialog} aria-labelledby={`${uid}-h`}>
164
+ <div className="modal-head"><span id={`${uid}-h`}>Test phones</span>
165
+ <button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
166
+ <div className="modal-body">
167
+ <p className="hint tp-intro">Link your own phone to see a notification on it before it goes out. Only you get your tests.</p>
168
+ {/* loads on mount (the dialog's content is always there) and says how many are linked */}
169
+ <TestPhones embedded onPhones={setPhones} />
170
+ <div className="modal-foot">
171
+ <button type="button" className="btn" onClick={() => dialog.current?.close()}>Done</button>
172
+ </div>
173
+ </div>
174
+ </dialog>
175
+ </div>
176
+ );
177
+ }