@acms-subir/core 0.3.41 → 0.3.43
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 +10 -1
- package/integration.mjs +2 -0
- 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/package.json +1 -1
- package/src/components/Announcements.tsx +11 -2
- package/src/components/EmailSettings.tsx +1 -1
- package/src/components/MobileApp.tsx +20 -8
- package/src/components/TestPhones.tsx +177 -0
- package/src/lib/announcements/api.ts +19 -2
- package/src/lib/announcements/service.ts +1 -1
- package/src/lib/email/service.ts +19 -2
- package/src/lib/email/store.ts +9 -1
- package/src/lib/email/templates.ts +20 -0
- package/src/lib/forms/alert-mail.ts +124 -0
- package/src/lib/forms/alerts.ts +137 -0
- package/src/lib/forms/schema.ts +15 -1
- package/src/lib/forms/store.ts +26 -8
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/text-en.ts +2 -2
- package/src/lib/legal/text-es.ts +2 -2
- package/src/lib/mobile/forms.ts +2 -1
- package/src/lib/mobile/guest.ts +2 -1
- package/src/lib/mobile/openapi.ts +18 -3
- package/src/lib/mobile/schemas.ts +4 -0
- 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 +8 -1
- package/src/lib/repo/migrations.ts +15 -0
- package/src/lib/repo/sql.ts +4 -1
- package/src/lib/sync/catalog.ts +2 -1
- package/src/pages/admin/forms/[id]/share.astro +49 -12
- package/src/pages/api/announcements.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/devices/[token]/staff-link.ts +20 -0
- package/src/pages/forms/[id].astro +2 -1
- package/src/styles/forms-admin.css +8 -0
- package/src/styles/test-phones.css +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.43
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 8e9263a: Forms can email staff about new responses. On a form's **Share** tab, **Notifications** (off for every form, including existing ones) has **Email new responses** with a list of up to 10 addresses; the first time, it offers the contact email in Settings → Legal, or the owners' addresses. Each response sent from the website or the app emails everyone on the list, in the site's main language: "New response: Prayer requests", when it arrived, every question with its answer in order, an **Open responses in the admin** button, and where it came from and why they get it. When the form asks for an email address, replying writes to the person. A spam-trap, rate-limited or refused response, or a retry of one already saved, emails no one.
|
|
8
|
+
|
|
9
|
+
**Include the answers in the email** is on by default. Turn it off for forms that ask for sensitive information, such as prayer requests, health details or children's names: the email then only says a response arrived and when, with nothing about the person and no reply address. Turning it off also covers alerts still waiting to go out, and a deleted response or an address taken off the list drops its alert. With push notifications on, **Also notify their linked phones** sends staff on the list who linked a phone under Test phones a push, "New response: Prayer requests" / "Received Mar 14, 3:41 PM", that never carries answers and opens nothing; the delivery log shows it as "New form response". A form sends at most 50 alerts a day; the 50th says so, and later responses that day wait in Responses.
|
|
10
|
+
|
|
11
|
+
Alerts need email set up and **Staff notifications** on in Email settings, which now also turns these off (including ones already queued); the Share tab says when alerts can't go out. Saving them needs **Manage forms**, and emailing the answers also needs **View form responses**, so the list can't send anyone responses they couldn't read in the admin. Event registration forms are unchanged: their hosts get the RSVP email. Environment sync leaves the alert settings out; backups keep them. Push data `source` can now be `form`. Migration 63 adds `forms.alerts`.
|
|
12
|
+
|
|
13
|
+
The privacy notice now says that, on sites with email, responses or a notice that one arrived may be emailed to the staff who handle that form, and that staff phones linked for tests also get notices that a form response arrived, so its "Last updated" date moves.
|
|
14
|
+
|
|
15
|
+
## 0.3.42
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- b5964fd: Staff can send a test notification to their own phone before anything goes out. Admin → Announcements and Admin → Mobile app have a **Test phones** panel: **Link my phone** shows a 6-digit code that works once for 10 minutes; in the app, open Notifications, choose **Staff? Link this phone** and enter it. The panel lists your linked phones (when linked, when the app was last opened) with **Remove**; each person sees only their own. Signed in with the owner password, there's no account to link a phone to, so the panel says to use a Team account with an email. When a phone is linked, its owner gets an email, "A phone was linked to your account", in the site's main language with a link back to the panel; it goes out whenever email is set up, even with staff notifications off, because it's a security notice.
|
|
20
|
+
|
|
21
|
+
**Send test to my phone**, under the preview in the announcement composer and the Mobile app composer, sends the draft as it stands (title, message, and the button's link or the app's home as the tap) to your linked phones only, titled "[Test] …" in the language chosen in the preview ("[Prueba]" in Spanish, "[Teste]" in Portuguese). An urgent draft uses the urgent channel, so you see how it looks. Tests show in the delivery log as "Test by …", never count toward the weekly pace, and are limited to 10 an hour per person. With no phone linked, the button opens the Test phones panel.
|
|
22
|
+
|
|
23
|
+
Codes are stored only as a hash. Guessing is capped: 5 tries per phone and 5 per address in 10 minutes, and after 20 wrong codes across the site every waiting code stops working (make a new one). The app API adds `POST /api/v1/devices/{token}/staff-link` (body `{ code }`, answers `{ linked, staff: { name } }`) and `DELETE` to unlink; device responses carry `staff` (the name only, never an email), test pushes carry `data.test: true` and `source: 'test'`, and turning notifications off unlinks the phone. Migration 62 adds `push_devices.staff_user_id` and `staff_linked_at` and the `push_link_codes` table.
|
|
24
|
+
|
|
25
|
+
The privacy notice now says staff can link their own phone to their account to receive test notifications and that the link is removed when they remove it or turn notifications off, so its "Last updated" date moves on sites with push; the app store answers say the device ID is linked to a staff member only then. The push recipe's Notifications screen gets the row (only while notifications are on) and a new `/link-phone` screen; update it with `npx acms mobile recipe add push --force`, which overwrites the recipe's files, including any changes you made to them, and needs this release's `@acms-subir/native`. Describe the screen in App Review Notes: "Notifications has a staff-only screen to receive test notifications; it needs a code from the website admin."
|
|
26
|
+
|
|
3
27
|
## 0.3.41
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
|
@@ -1121,6 +1121,21 @@ var MIGRATIONS = [
|
|
|
1121
1121
|
// everything else, so older rows need no backfill
|
|
1122
1122
|
{ version: 61, statements: [
|
|
1123
1123
|
`ALTER TABLE bookings ADD COLUMN hold_until INTEGER`
|
|
1124
|
+
] },
|
|
1125
|
+
// staff test phones: a device a staff member linked with a one-time code
|
|
1126
|
+
// (only its SHA-256 is stored, keyed by it: the app sends the code alone)
|
|
1127
|
+
// gets their "Send test to my phone" pushes. Not public, so no cache trigger.
|
|
1128
|
+
{ version: 62, statements: [
|
|
1129
|
+
`ALTER TABLE push_devices ADD COLUMN staff_user_id TEXT`,
|
|
1130
|
+
`ALTER TABLE push_devices ADD COLUMN staff_linked_at INTEGER`,
|
|
1131
|
+
`CREATE INDEX push_devices_staff ON push_devices (staff_user_id)`,
|
|
1132
|
+
`CREATE TABLE push_link_codes (code_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`,
|
|
1133
|
+
`CREATE INDEX push_link_codes_user ON push_link_codes (user_id)`
|
|
1134
|
+
] },
|
|
1135
|
+
// staff alerts for a form's new responses (SavedForm.alerts: who gets them,
|
|
1136
|
+
// with or without the answers); '{}' = off. Not public, so no cache trigger.
|
|
1137
|
+
{ version: 63, statements: [
|
|
1138
|
+
`ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`
|
|
1124
1139
|
] }
|
|
1125
1140
|
];
|
|
1126
1141
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
package/docs/mobile.md
CHANGED
|
@@ -289,6 +289,14 @@ plus automatic ones: new post, new event, "starting soon", event changes,
|
|
|
289
289
|
`<AcmsProvider>` in `app/_layout.tsx`. Link to `/notifications`.
|
|
290
290
|
3. Taps carry `data.url` / `data.path` and go through the provider's
|
|
291
291
|
`onNavigate`, so route `/events/<slug>` and post paths to your screens there.
|
|
292
|
+
4. Staff test phones: the recipe's Notifications screen ends with a visible
|
|
293
|
+
"Staff? Link this phone" row (only while notifications are on) that opens
|
|
294
|
+
`/link-phone`, where staff type the code from Admin → Announcements or
|
|
295
|
+
Mobile app → Test phones (`useLinkStaffPhone()`); then "Send test to my
|
|
296
|
+
phone" in the admin reaches their phone. Never hide it behind a long press
|
|
297
|
+
or gesture (App Review 2.3.1(a), 2.5.19). In App Store Connect's **App
|
|
298
|
+
Review Notes**, say: "Notifications has a staff-only screen to receive test
|
|
299
|
+
notifications; it needs a code from the website admin."
|
|
292
300
|
|
|
293
301
|
Rules: ask for permission from a button after saying why (the recipe does),
|
|
294
302
|
never on first launch. Topic ids and names come from the site
|
|
@@ -322,7 +330,8 @@ a pre-ticked choice doesn't count. So:
|
|
|
322
330
|
completes it in **Settings → Legal**, which also lists the App Privacy and
|
|
323
331
|
Data safety answers. Link to it inside the app: `useSite().data.legal.privacy`
|
|
324
332
|
(and `.terms`). The site stores only the Expo token, platform, language and
|
|
325
|
-
topics, with no name or email
|
|
333
|
+
topics, with no name or email, except a staff member's own test phone, which
|
|
334
|
+
records who linked it (the notice and the store answers say so).
|
|
326
335
|
|
|
327
336
|
## Editor apps (staff sign-in)
|
|
328
337
|
|
package/integration.mjs
CHANGED
|
@@ -765,8 +765,10 @@ export default function acms(options = {}) {
|
|
|
765
765
|
routes.push(
|
|
766
766
|
['/api/v1/devices', 'api/v1/devices/index.ts'],
|
|
767
767
|
['/api/v1/devices/[token]', 'api/v1/devices/[token].ts'],
|
|
768
|
+
['/api/v1/devices/[token]/staff-link', 'api/v1/devices/[token]/staff-link.ts'],
|
|
768
769
|
['/api/v1/push/topics', 'api/v1/push/topics.ts'],
|
|
769
770
|
['/api/push/admin', 'api/push/admin.ts'],
|
|
771
|
+
['/api/push/phones', 'api/push/phones.ts'],
|
|
770
772
|
);
|
|
771
773
|
}
|
|
772
774
|
// Admin → Mobile app: app access switches always; notifications when push is on
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ScrollView, Text, TextInput, View } from 'react-native';
|
|
3
|
+
import { Stack } from 'expo-router';
|
|
4
|
+
import { Button, fill, linkCodeInput, staffLinkProblemText, useLayout, useLinkStaffPhone, useStrings, useTheme } from '@acms-subir/native';
|
|
5
|
+
|
|
6
|
+
// Staff only: link this phone with the 6-digit code from the website admin
|
|
7
|
+
// (Announcements or Mobile app → Test phones), so "Send test to my phone"
|
|
8
|
+
// reaches it. Opened from the last row of /notifications while notifications
|
|
9
|
+
// are on; turning them off unlinks it. Words: useStrings() in the app's language.
|
|
10
|
+
export default function LinkPhoneScreen() {
|
|
11
|
+
const t = useTheme();
|
|
12
|
+
const { gutter } = useLayout();
|
|
13
|
+
const words = useStrings();
|
|
14
|
+
const staff = useLinkStaffPhone();
|
|
15
|
+
const [code, setCode] = useState('');
|
|
16
|
+
const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 22, letterSpacing: 4, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
|
|
17
|
+
const link = async () => { if (await staff.link(code)) setCode(''); };
|
|
18
|
+
return (
|
|
19
|
+
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }} keyboardShouldPersistTaps="handled">
|
|
20
|
+
<Stack.Screen options={{ title: words.linkPhoneTitle }} />
|
|
21
|
+
<Text style={t.styles.text.muted}>{words.linkPhoneIntro}</Text>
|
|
22
|
+
|
|
23
|
+
{staff.status === 'off' ? (
|
|
24
|
+
<Text style={t.styles.text.body}>{words.linkNeedsNotifications}</Text>
|
|
25
|
+
) : staff.linked ? (
|
|
26
|
+
<View style={[t.styles.card, { flexDirection: 'row', alignItems: 'center', gap: 12 }]}>
|
|
27
|
+
<Text style={[t.styles.text.body, { flex: 1 }]}>{fill(words.linkedTo, staff.linked)}</Text>
|
|
28
|
+
<Button variant="outline" label={words.unlinkPhone} disabled={staff.status !== 'ready'} onPress={() => { staff.unlink(); }} />
|
|
29
|
+
</View>
|
|
30
|
+
) : (
|
|
31
|
+
<View style={{ gap: 6 }}>
|
|
32
|
+
<Text style={t.styles.text.label}>{words.linkCode}</Text>
|
|
33
|
+
<TextInput value={code} onChangeText={(v) => setCode(linkCodeInput(v))} onSubmitEditing={link} keyboardType="number-pad" textContentType="oneTimeCode"
|
|
34
|
+
autoComplete="one-time-code" maxLength={6} accessibilityLabel={words.linkCode} placeholder="000000" placeholderTextColor={t.colors.muted} style={input} />
|
|
35
|
+
</View>
|
|
36
|
+
)}
|
|
37
|
+
{!!staff.problem && <Text accessibilityRole="alert" style={[t.styles.text.small, { color: t.colors.ink, fontWeight: '600' }]}>{staffLinkProblemText(staff.problem, words)}</Text>}
|
|
38
|
+
{staff.status !== 'off' && !staff.linked && (
|
|
39
|
+
<Button label={staff.status === 'linking' ? words.linkingPhone : words.linkPhone} disabled={code.length !== 6 || staff.status !== 'ready'} onPress={link} />
|
|
40
|
+
)}
|
|
41
|
+
</ScrollView>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { Linking, ScrollView, Switch, Text, View } from 'react-native';
|
|
2
|
-
import { Stack } from 'expo-router';
|
|
3
|
-
import { Button, useLayout, usePushRegistration, usePushTopics, useTheme } from '@acms-subir/native';
|
|
1
|
+
import { Linking, Pressable, ScrollView, Switch, Text, View } from 'react-native';
|
|
2
|
+
import { Link, Stack } from 'expo-router';
|
|
3
|
+
import { Button, fill, useLayout, useLinkStaffPhone, usePushRegistration, usePushTopics, useStrings, useTheme } from '@acms-subir/native';
|
|
4
4
|
import { STARTING_TOPICS } from '../lib/push';
|
|
5
5
|
|
|
6
6
|
// Turn notifications on, choose topics, or turn them off. Topics and their
|
|
7
7
|
// names come from the site, so new calendars or collections appear here
|
|
8
|
-
// without an app update.
|
|
8
|
+
// without an app update. While notifications are on, the last row opens
|
|
9
|
+
// /link-phone, where staff link their own phone for test notifications (a
|
|
10
|
+
// visible row, so App Review can find it: describe it in the Review Notes).
|
|
9
11
|
export default function NotificationsScreen() {
|
|
10
12
|
const t = useTheme();
|
|
11
13
|
const { gutter } = useLayout();
|
|
14
|
+
const words = useStrings();
|
|
12
15
|
const push = usePushRegistration({ topics: STARTING_TOPICS });
|
|
16
|
+
const staff = useLinkStaffPhone();
|
|
13
17
|
const offered = usePushTopics();
|
|
14
18
|
const toggle = (id: string, on: boolean) => {
|
|
15
19
|
const next = on ? [...push.topics, id] : push.topics.filter((x) => x !== id);
|
|
@@ -57,6 +61,12 @@ export default function NotificationsScreen() {
|
|
|
57
61
|
</View>
|
|
58
62
|
<Text style={t.styles.text.small}>Announcements from the site reach everyone with notifications on.</Text>
|
|
59
63
|
<Button variant="outline" label="Turn off notifications" onPress={() => { push.unregister().catch(() => {}); }} />
|
|
64
|
+
<Link href="/link-phone" asChild>
|
|
65
|
+
<Pressable accessibilityRole="button" style={[t.styles.card, { gap: 2, minHeight: 52, justifyContent: 'center' }]}>
|
|
66
|
+
<Text style={t.styles.text.body}>{words.staffRow}</Text>
|
|
67
|
+
<Text style={t.styles.text.small}>{staff.linked ? fill(words.linkedTo, staff.linked) : words.staffRowNote}</Text>
|
|
68
|
+
</Pressable>
|
|
69
|
+
</Link>
|
|
60
70
|
</>
|
|
61
71
|
)}
|
|
62
72
|
</ScrollView>
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"expo-device"
|
|
8
8
|
],
|
|
9
9
|
"routes": [
|
|
10
|
-
"/notifications"
|
|
10
|
+
"/notifications",
|
|
11
|
+
"/link-phone"
|
|
11
12
|
],
|
|
12
13
|
"next": [
|
|
13
14
|
"Turn push on for the site: acms({ mobile: { push: true } }) in astro.config.mjs, then restart or deploy",
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
"Push tokens need an EAS project id: npx eas-cli init (saves extra.eas.projectId in app.json)",
|
|
16
17
|
"Mount <PushSetup /> inside <AcmsProvider> in app/_layout.tsx (re-registers on start, opens tapped notifications)",
|
|
17
18
|
"Link to the screen: <Link href=\"/notifications\">Notifications</Link> (expo-router)",
|
|
18
|
-
"Send from the site: Admin → Mobile app"
|
|
19
|
+
"Send from the site: Admin → Mobile app",
|
|
20
|
+
"Staff test their notifications on their own phone: Notifications → \"Staff? Link this phone\" (/link-phone, shown while notifications are on) takes the code from Admin → Announcements or Mobile app → Test phones. Describe it in App Store Connect's App Review Notes: \"Notifications has a staff-only screen to receive test notifications; it needs a code from the website admin.\""
|
|
19
21
|
]
|
|
20
22
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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>
|
|
@@ -477,7 +477,7 @@ export default function EmailSettings() {
|
|
|
477
477
|
</label>
|
|
478
478
|
<label className="ems-toggle">
|
|
479
479
|
<input type="checkbox" checked={notify.staff} onChange={(e) => setNotify((n) => ({ ...n, staff: e.target.checked }))} />
|
|
480
|
-
<span>Staff notifications<small>Tell site staff about new
|
|
480
|
+
<span>Staff notifications<small>Tell site staff about new bookings, RSVPs, event requests and form responses</small></span>
|
|
481
481
|
</label>
|
|
482
482
|
{pwField(notify.pw, (v) => setNotify((n) => ({ ...n, pw: v })), 'pw-notify')}
|
|
483
483
|
<div className="ems-actions">
|
|
@@ -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', form: 'New form response' };
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
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.source === 'form' ? 'Staff on the form’s alert list' : 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. Forms that email you about new responses can notify it too.</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. Forms that email you about new responses can notify it too.</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
|
+
}
|
|
@@ -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]];
|