@acms-subir/core 0.3.31 → 0.3.33

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 (56) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/cli/main.mjs +1 -1
  3. package/cli/migrations.generated.mjs +35 -2
  4. package/docs/mobile.md +7 -3
  5. package/integration.mjs +49 -2
  6. package/mobile-template/client/AGENTS.md +1 -0
  7. package/mobile-template/recipes/announcements/files/app/announcements/[id].tsx +43 -0
  8. package/mobile-template/recipes/announcements/files/app/announcements/index.tsx +155 -0
  9. package/mobile-template/recipes/announcements/files/components/AnnouncementBanners.tsx +47 -0
  10. package/mobile-template/recipes/announcements/files/components/AnnouncementIcon.tsx +21 -0
  11. package/mobile-template/recipes/announcements/recipe.json +19 -0
  12. package/package.json +1 -1
  13. package/src/components/Announcements.tsx +806 -0
  14. package/src/components/MobileApp.tsx +1 -1
  15. package/src/layouts/Admin.astro +4 -0
  16. package/src/layouts/Site.astro +42 -2
  17. package/src/lib/agent-discovery.ts +6 -2
  18. package/src/lib/agent.ts +5 -0
  19. package/src/lib/announcements/agent.ts +49 -0
  20. package/src/lib/announcements/api.ts +222 -0
  21. package/src/lib/announcements/config.ts +8 -0
  22. package/src/lib/announcements/migration.ts +21 -0
  23. package/src/lib/announcements/pages.ts +54 -0
  24. package/src/lib/announcements/service.ts +132 -0
  25. package/src/lib/announcements/store.ts +88 -0
  26. package/src/lib/announcements/types.ts +78 -0
  27. package/src/lib/announcements/words.ts +55 -0
  28. package/src/lib/backup/content.ts +5 -1
  29. package/src/lib/mobile/config.mjs +18 -2
  30. package/src/lib/mobile/locale.ts +2 -0
  31. package/src/lib/mobile/openapi.ts +6 -2
  32. package/src/lib/mobile/schemas.ts +10 -3
  33. package/src/lib/mobile/site.ts +8 -3
  34. package/src/lib/push/api.ts +12 -8
  35. package/src/lib/push/config.ts +3 -1
  36. package/src/lib/push/service.ts +40 -12
  37. package/src/lib/push/store.ts +20 -7
  38. package/src/lib/push/types.ts +5 -3
  39. package/src/lib/repo/migrations.ts +4 -0
  40. package/src/lib/repo/sql.ts +6 -0
  41. package/src/lib/repo/types.ts +5 -0
  42. package/src/lib/scheduler.ts +3 -0
  43. package/src/lib/schema.ts +1 -0
  44. package/src/pages/admin/announcements.astro +18 -0
  45. package/src/pages/admin/integrations.astro +2 -1
  46. package/src/pages/admin/settings.astro +2 -0
  47. package/src/pages/announcements/[id].astro +35 -0
  48. package/src/pages/announcements/index.astro +61 -0
  49. package/src/pages/api/announcements.ts +32 -0
  50. package/src/pages/api/v1/announcements/[id].ts +7 -0
  51. package/src/pages/api/v1/announcements/index.ts +8 -0
  52. package/src/pages/api/webhooks/docs.ts +2 -0
  53. package/src/styles/announcements-site.css +32 -0
  54. package/src/styles/announcements.css +210 -0
  55. package/src/styles/site.css +5 -0
  56. package/src/virtual.d.ts +2 -1
@@ -0,0 +1,806 @@
1
+ import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type Ref } from 'react';
2
+ import type { AdminAnnouncementAction, AdminAnnouncementsState } from '../lib/announcements/api';
3
+ import type { Announcement, AnnouncementInput, AnnouncementKind, AnnouncementWords } from '../lib/announcements/types';
4
+ import type { PushNotification } from '../lib/push/types';
5
+ import { announcementStrings } from '../lib/announcements/words';
6
+ import '../styles/announcements.css';
7
+
8
+ // Admin → Announcements: write a message once, choose where it shows (the
9
+ // list on the website and in the app, a banner, a push) and when it goes up
10
+ // and comes down. The server publishes and pushes, now or from the scheduler;
11
+ // this island edits, previews and checks before anything goes out.
12
+
13
+ type State = AdminAnnouncementsState;
14
+ type Kind = AnnouncementKind;
15
+ type Words = AnnouncementWords;
16
+ type Tab = 'live' | 'scheduled' | 'drafts' | 'ended';
17
+ type Tone = 'ok' | 'warn' | 'bad';
18
+ interface Flash { ok?: string; warn?: string; err?: string }
19
+ interface Recipients { total: number; languages: { code: string; name: string; count: number }[] }
20
+ interface Saved { announcement: Announcement; notification?: PushNotification | null; pushError?: string; state: State }
21
+ interface Check { tone: Tone; text: string; fix?: { label: string; run: () => void } }
22
+
23
+ const ENDPOINT = '/api/announcements';
24
+ const CUSTOM = '__custom';
25
+ const BLANK: Words = { title: '', message: '', details: '', buttonLabel: '' };
26
+ const LINK_OK = /^(\/(?!\/)|https?:\/\/)/i;
27
+
28
+ async function api<T>(body?: AdminAnnouncementAction): Promise<T> {
29
+ const res = await fetch(ENDPOINT, {
30
+ method: body ? 'POST' : 'GET', credentials: 'same-origin',
31
+ headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
32
+ body: body ? JSON.stringify(body) : undefined,
33
+ });
34
+ const data = await res.json().catch(() => null);
35
+ if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
36
+ return data as T;
37
+ }
38
+ const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
39
+ const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
40
+
41
+ // ---- kinds ----------------------------------------------------------------------
42
+
43
+ // 24-unit line icons. The colour comes from the kind's tokens in the CSS
44
+ // (data-kind); red is for urgent only.
45
+ const KIND_ICON: Record<Kind, string> = {
46
+ event: 'M3 5.5h18v15H3zM3 10h18M8 3v4M16 3v4',
47
+ registration: 'M9 2.5h6a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1zM16 4.5h2a2 2 0 0 1 2 2V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6.5a2 2 0 0 1 2-2h2M9 14l2 2 4-4',
48
+ help: 'M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7z',
49
+ info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 16.5V11M12 7.5h.01',
50
+ urgent: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0zM12 9v4M12 17h.01',
51
+ };
52
+ const URGENT_NOTE = 'Urgent reaches everyone with notifications on. Use it for closures, cancellations and safety, never for promotions.';
53
+
54
+ const Icon = ({ d }: { d: string }) => (
55
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
56
+ );
57
+ const KindBadge = ({ kind, label }: { kind: Kind; label: string }) => (
58
+ <span className="an-kind" data-kind={kind}><i><Icon d={KIND_ICON[kind]} /></i>{label}</span>
59
+ );
60
+
61
+ // ---- time -----------------------------------------------------------------------
62
+ // The browser's local time, like <input type="datetime-local">; stored as Unix ms.
63
+
64
+ const pad = (n: number) => String(n).padStart(2, '0');
65
+ const toLocal = (ms: number) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; };
66
+ const fromLocal = (s: string) => { const t = s ? new Date(s).getTime() : NaN; return Number.isFinite(t) ? t : null; };
67
+ const day = (ms: number, now: number) => new Date(ms).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', ...(new Date(ms).getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
68
+ const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
69
+ /** "Sun, Oct 19, 8:00 AM" */
70
+ const at = (ms: number, now: number) => `${day(ms, now)}, ${clock(ms)}`;
71
+ /** the time when it's today, else the day */
72
+ const short = (ms: number, now: number) => (new Date(ms).toDateString() === new Date(now).toDateString() ? clock(ms) : day(ms, now));
73
+ /** "in 14 hours" */
74
+ function fromNow(ms: number, now: number) {
75
+ const m = Math.round((ms - now) / 60_000);
76
+ if (m < 1) return 'any minute now';
77
+ if (m < 60) return `in ${m} min`;
78
+ const h = Math.round(m / 60);
79
+ return h < 48 ? `in ${h} hour${h === 1 ? '' : 's'}` : `in ${Math.round(h / 24)} days`;
80
+ }
81
+ function ago(ms: number, now: number) {
82
+ const s = Math.round((now - ms) / 1000);
83
+ if (s < 60) return 'just now';
84
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
85
+ return short(ms, now);
86
+ }
87
+ const nextHour = (now: number) => { const d = new Date(now + 2 * 3_600_000); d.setMinutes(0, 0, 0); return d.getTime(); };
88
+ const weekLater = (from: number) => { const d = new Date(from + 7 * 86_400_000); d.setHours(21, 0, 0, 0); return d.getTime(); };
89
+
90
+ function useNow(every = 30_000) {
91
+ const [now, setNow] = useState(() => Date.now());
92
+ useEffect(() => { const t = setInterval(() => setNow(Date.now()), every); return () => clearInterval(t); }, [every]);
93
+ return now;
94
+ }
95
+
96
+ // ---- status -----------------------------------------------------------------------
97
+
98
+ // published but past its end counts as ended, even before the scheduler takes it down
99
+ const tabOf = (a: Announcement, now: number): Tab =>
100
+ a.status === 'draft' ? 'drafts' : a.status === 'scheduled' ? 'scheduled'
101
+ : a.status === 'published' && (a.showUntil === null || a.showUntil > now) ? 'live' : 'ended';
102
+
103
+ const TABS: { id: Tab; label: string; empty: [string, string] }[] = [
104
+ { id: 'live', label: 'Up now', empty: ['Nothing is up right now.', 'Announcements you put up show here until they end or you archive them.'] },
105
+ { id: 'scheduled', label: 'Scheduled', empty: ['Nothing scheduled.', 'Pick a start time when you schedule one and it waits here.'] },
106
+ { id: 'drafts', label: 'Drafts', empty: ['No drafts.', 'Save a draft to finish it later.'] },
107
+ { id: 'ended', label: 'Ended', empty: ['Nothing has ended yet.', 'Announcements move here when their end time passes or you archive them.'] },
108
+ ];
109
+ // newest first, except what's coming up: soonest first
110
+ const sortKey = (a: Announcement, tab: Tab) =>
111
+ tab === 'scheduled' ? -(a.publishAt ?? 0) : tab === 'live' ? a.publishedAt ?? a.updatedAt
112
+ : tab === 'ended' && a.status !== 'archived' ? a.showUntil ?? a.updatedAt : a.updatedAt;
113
+
114
+ function timeText(a: Announcement, now: number) {
115
+ switch (tabOf(a, now)) {
116
+ case 'live': return `Up since ${short(a.publishedAt ?? a.updatedAt, now)}${a.showUntil ? ` · until ${at(a.showUntil, now)}` : ''}`;
117
+ case 'scheduled': return a.publishAt ? `Goes up ${at(a.publishAt, now)} · ${fromNow(a.publishAt, now)}` : 'Going up now';
118
+ case 'drafts': return `Draft · edited ${ago(a.updatedAt, now)}${a.publishAt ? ` · set for ${at(a.publishAt, now)}` : ''}`;
119
+ default: return a.status === 'archived'
120
+ ? `Archived ${short(a.updatedAt, now)}${a.publishedAt ? '' : ' · never went up'}`
121
+ : `Ended ${short(a.showUntil ?? a.updatedAt, now)}`;
122
+ }
123
+ }
124
+ const STATUS_CLASS: Record<Tab, string> = { live: 'published', scheduled: 'scheduled', drafts: 'draft', ended: 'archived' };
125
+
126
+ // the state has no delivery log: notificationId set = the push went out
127
+ const pushResult = (a: Announcement): [Tone, string] | null =>
128
+ !a.push || a.publishedAt === null ? null : a.notificationId ? ['ok', 'Pushed'] : ['warn', 'Push not sent'];
129
+
130
+ function resultFlash(r: Saved, now: number): Flash {
131
+ const a = r.announcement;
132
+ const n = r.notification;
133
+ if (a.status === 'scheduled') return { ok: a.publishAt ? `Scheduled for ${at(a.publishAt, now)}.` : 'Going up now.' };
134
+ if (a.status === 'published') {
135
+ if (r.pushError) return { warn: `Up now, but the push wasn’t sent: ${r.pushError}` };
136
+ if (!n) return { ok: 'Up now.' };
137
+ if (n.status === 'failed') return { warn: `Up now, but the push failed: ${n.error || 'the push service refused it.'}` };
138
+ return { ok: n.recipients ? `Up now. Sent to ${plural(n.ok, 'phone')}${n.failed ? ` · ${n.failed} failed` : ''}.` : 'Up now. No phone matched the audience yet.' };
139
+ }
140
+ if (a.status === 'ended') return { warn: 'Saved, but its end time has passed, so it’s under Ended.' };
141
+ if (a.status === 'archived') return { ok: 'Saved. It’s archived: put it back to show it.' };
142
+ return { ok: 'Saved.' };
143
+ }
144
+
145
+ // ---- shared bits ------------------------------------------------------------------
146
+
147
+ function FlashLine({ flash }: { flash: Flash }) {
148
+ return (
149
+ <div className="an-flash" aria-live="polite">
150
+ {flash.err ? <p className="flash error" role="alert">{flash.err}</p>
151
+ : flash.warn ? <p className="flash an-warn">{flash.warn}</p>
152
+ : flash.ok ? <p className="flash">{flash.ok}</p> : null}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ // Segmented control with tab semantics: arrow keys, Home and End move between options.
158
+ function Tabs<T extends string>({ label, items, value, onChange, idPrefix, controls, disabled }: {
159
+ label: string; items: { id: T; label: ReactNode }[]; value: T; onChange: (v: T) => void;
160
+ idPrefix?: string; controls?: string; disabled?: boolean;
161
+ }) {
162
+ const refs = useRef<(HTMLButtonElement | null)[]>([]);
163
+ const onKey = (e: KeyboardEvent, i: number) => {
164
+ const n = items.length;
165
+ const next = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i - 1 + n) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
166
+ if (next < 0) return;
167
+ e.preventDefault();
168
+ onChange(items[next].id);
169
+ refs.current[next]?.focus();
170
+ };
171
+ return (
172
+ <div className="seg an-seg" role="tablist" aria-label={label}>
173
+ {items.map((it, i) => (
174
+ <button key={it.id} ref={(el) => { refs.current[i] = el; }} type="button" role="tab" id={idPrefix ? `${idPrefix}-${it.id}` : undefined}
175
+ aria-selected={value === it.id} aria-controls={controls} tabIndex={value === it.id ? 0 : -1} disabled={disabled}
176
+ onClick={() => onChange(it.id)} onKeyDown={(e) => onKey(e, i)}>{it.label}</button>
177
+ ))}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ // ---- page -----------------------------------------------------------------------
183
+
184
+ export default function Announcements() {
185
+ const [state, setState] = useState<State | null>(null);
186
+ const [loadError, setLoadError] = useState('');
187
+ useEffect(() => { api<State>().then(setState, (e) => setLoadError(errText(e))); }, []);
188
+ if (loadError) return <p className="flash error" role="alert">{loadError}</p>;
189
+ if (!state) return <div className="an-skel" aria-busy="true" aria-label="Loading"><i /><i /><i /></div>;
190
+ return <AnnouncementsView state={state} setState={setState} />;
191
+ }
192
+
193
+ function AnnouncementsView({ state, setState }: { state: State; setState: (s: State) => void }) {
194
+ const [editing, setEditing] = useState<{ id: string | null; key: number } | null>(null);
195
+ const [tab, setTab] = useState<Tab>('live');
196
+ const [flash, setFlash] = useState<Flash>({});
197
+ const [returned, setReturned] = useState(false);
198
+ // the panel is the scroll container on desktop, the window on phones
199
+ useEffect(() => { document.getElementById('acms-panel')?.scrollTo({ top: 0 }); window.scrollTo({ top: 0 }); }, [editing?.key]);
200
+ if (editing) {
201
+ return (
202
+ <Composer key={editing.key} state={state} setState={setState} startId={editing.id}
203
+ onClose={(msg, goTo) => { setEditing(null); setFlash(msg ?? {}); if (goTo) setTab(goTo); setReturned(true); }} />
204
+ );
205
+ }
206
+ return (
207
+ <List state={state} setState={setState} tab={tab} setTab={setTab} flash={flash} setFlash={setFlash} focusTabs={returned}
208
+ onEdit={(id) => { setFlash({}); setEditing({ id, key: Date.now() }); }} />
209
+ );
210
+ }
211
+
212
+ // ---- list -----------------------------------------------------------------------
213
+
214
+ function List({ state, setState, tab, setTab, flash, setFlash, focusTabs, onEdit }: {
215
+ state: State; setState: (s: State) => void; tab: Tab; setTab: (t: Tab) => void;
216
+ flash: Flash; setFlash: (f: Flash) => void; focusTabs: boolean; onEdit: (id: string | null) => void;
217
+ }) {
218
+ const now = useNow();
219
+ const [busy, setBusy] = useState('');
220
+ const [doomed, setDoomed] = useState<Announcement | null>(null);
221
+ const dialog = useRef<HTMLDialogElement>(null);
222
+ const root = useRef<HTMLDivElement>(null);
223
+ // back from the composer: keyboard focus returns to the list
224
+ useEffect(() => { if (focusTabs) root.current?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')?.focus(); }, []);
225
+
226
+ const kindLabel = (k: Kind) => state.kinds.find((x) => x.id === k)?.label ?? k;
227
+ const groups = useMemo(() => {
228
+ const out: Record<Tab, Announcement[]> = { live: [], scheduled: [], drafts: [], ended: [] };
229
+ for (const a of state.announcements) out[tabOf(a, now)].push(a);
230
+ for (const t of TABS) out[t.id].sort((x, y) => sortKey(y, t.id) - sortKey(x, t.id));
231
+ return out;
232
+ }, [state.announcements, now]);
233
+
234
+ const act = async (a: Announcement, action: 'archive' | 'restore' | 'delete') => {
235
+ setBusy(a.id); setFlash({});
236
+ try {
237
+ const r = await api<{ announcement?: Announcement; state: State }>({ action, id: a.id });
238
+ dialog.current?.close();
239
+ setState(r.state);
240
+ const s = r.announcement?.status;
241
+ setFlash({ ok: action === 'delete' ? 'Deleted.'
242
+ : action === 'archive' ? (a.status === 'scheduled' ? 'Archived. It won’t go up and its push won’t be sent.' : 'Archived. It’s off the site and the app.')
243
+ : s === 'published' ? 'Put back. It’s up again, without a new push.' : s === 'draft' ? 'Put back in Drafts.' : 'Put back. Its end time has passed, so it stays under Ended.' });
244
+ } catch (e) {
245
+ dialog.current?.close();
246
+ setFlash({ err: errText(e) });
247
+ } finally { setBusy(''); }
248
+ };
249
+ const confirmDelete = (a: Announcement) => { setDoomed(a); dialog.current?.showModal(); };
250
+
251
+ const rows = groups[tab];
252
+ const empty = TABS.find((t) => t.id === tab)!.empty;
253
+ return (
254
+ <div className="an" ref={root}>
255
+ <div className="an-bar">
256
+ <Tabs label="Announcements" idPrefix="an-tab" controls="an-list" value={tab} onChange={setTab}
257
+ items={TABS.map((t) => ({ id: t.id, label: <>{t.label}{groups[t.id].length > 0 && <em className="an-n">{groups[t.id].length}</em>}</> }))} />
258
+ <button type="button" className="btn primary" onClick={() => onEdit(null)}>New announcement</button>
259
+ </div>
260
+ <FlashLine flash={flash} />
261
+ <section className="card an-list" id="an-list" role="tabpanel" aria-labelledby={`an-tab-${tab}`}>
262
+ {!rows.length ? <div className="empty-state an-empty"><b>{empty[0]}</b><p>{empty[1]}</p></div> : (
263
+ <ul className="an-rows">
264
+ {rows.map((a) => (
265
+ <Row key={a.id} a={a} state={state} now={now} busy={busy !== ''} kindLabel={kindLabel(a.kind)}
266
+ onEdit={() => onEdit(a.id)} onAct={(action) => act(a, action)} onDelete={() => confirmDelete(a)} />
267
+ ))}
268
+ </ul>
269
+ )}
270
+ </section>
271
+ {(state.bar || state.base) && (
272
+ <p className="hint an-foot">
273
+ {state.bar && <span>While a pinned announcement is up, it shows instead of the bar from <a href="/admin/settings#announcement">Settings → Announcement bar</a>.</span>}
274
+ {state.base && <a href={`${state.site.origin}${state.base}`} target="_blank" rel="noopener">View on the site ↗</a>}
275
+ </p>
276
+ )}
277
+ <dialog className="modal" ref={dialog} aria-labelledby="an-del-h" onClose={() => setDoomed(null)}>
278
+ <div className="modal-head"><span id="an-del-h">Delete this announcement?</span>
279
+ <button type="button" className="modal-x" onClick={() => dialog.current?.close()} aria-label="Close">✕</button></div>
280
+ <div className="modal-body">
281
+ <p className="an-del">“{doomed?.title || 'Untitled'}” is removed for good. This can’t be undone.</p>
282
+ <div className="modal-foot">
283
+ <button type="button" className="btn" onClick={() => dialog.current?.close()}>Cancel</button>
284
+ <button type="button" className="btn danger" disabled={!doomed || busy !== ''} onClick={() => doomed && act(doomed, 'delete')}>{busy ? 'Deleting…' : 'Delete'}</button>
285
+ </div>
286
+ </div>
287
+ </dialog>
288
+ </div>
289
+ );
290
+ }
291
+
292
+ function Row({ a, state, now, busy, kindLabel, onEdit, onAct, onDelete }: {
293
+ a: Announcement; state: State; now: number; busy: boolean; kindLabel: string;
294
+ onEdit: () => void; onAct: (action: 'archive' | 'restore') => void; onDelete: () => void;
295
+ }) {
296
+ const titleId = `an-row-${a.id}`;
297
+ const push = pushResult(a);
298
+ const missing = tabOf(a, now) === 'ended' ? [] : state.languages.extra.filter((l) => !a.translations[l.code]?.title);
299
+ const where = [a.listed && 'List', a.push && 'Push', a.pinned && 'Banner'].filter(Boolean) as string[];
300
+ return (
301
+ <li className="an-row">
302
+ <KindBadge kind={a.kind} label={kindLabel} />
303
+ <div className="an-row-main">
304
+ <b id={titleId} className={`an-row-title${a.title ? '' : ' is-empty'}`}>{a.title || 'Untitled'}</b>
305
+ {a.message && <p className="an-row-msg">{a.message}</p>}
306
+ <div className="an-row-meta">
307
+ {where.length > 0 && <span className="an-where"><span className="sr-only">Shows in: </span>{where.map((w) => <span key={w} className="an-chip">{w}</span>)}</span>}
308
+ <span>{timeText(a, now)}</span>
309
+ {push && <span className="an-tone" data-tone={push[0]}>{push[1]}</span>}
310
+ {missing.map((l) => <span key={l.code} className="an-tone" data-tone="warn">{l.name} missing</span>)}
311
+ </div>
312
+ </div>
313
+ <div className="an-row-actions">
314
+ <button type="button" className="btn sm" aria-describedby={titleId} onClick={onEdit}>Edit</button>
315
+ {(a.status === 'published' || a.status === 'scheduled') && <button type="button" className="btn sm ghost" aria-describedby={titleId} disabled={busy} onClick={() => onAct('archive')}>Archive</button>}
316
+ {a.status === 'archived' && <button type="button" className="btn sm ghost" aria-describedby={titleId} disabled={busy} onClick={() => onAct('restore')}>Put back</button>}
317
+ {(a.status === 'draft' || a.status === 'ended' || a.status === 'archived') && <button type="button" className="btn sm ghost" aria-describedby={titleId} disabled={busy} onClick={onDelete}>Delete</button>}
318
+ </div>
319
+ </li>
320
+ );
321
+ }
322
+
323
+ // ---- composer -----------------------------------------------------------------------
324
+
325
+ interface Form {
326
+ kind: Kind;
327
+ words: Words;
328
+ /** every extra language, blank until written (the server drops blank ones) */
329
+ translations: Record<string, Words>;
330
+ linkRef: string;
331
+ customUrl: string;
332
+ listed: boolean; push: boolean; pinned: boolean;
333
+ everyone: boolean; topics: string[];
334
+ when: 'now' | 'at'; publishAt: string;
335
+ until: 'none' | 'at'; showUntil: string;
336
+ }
337
+
338
+ // the push audience a kind starts with (the site's config, else a topic named like the kind, else everyone)
339
+ function audienceFor(kind: Kind, state: State): Pick<Form, 'everyone' | 'topics'> {
340
+ const list = state.defaultAudience[kind] ?? ['*'];
341
+ return list.includes('*') || !list.length ? { everyone: true, topics: [] } : { everyone: false, topics: [...list] };
342
+ }
343
+
344
+ function toForm(a: Announcement | null, state: State): Form {
345
+ const translations = Object.fromEntries(state.languages.extra.map((l) => [l.code, { ...BLANK, ...a?.translations[l.code] }]));
346
+ if (!a) {
347
+ return { kind: 'info', words: { ...BLANK }, translations, linkRef: '', customUrl: '', listed: true, push: false, pinned: false,
348
+ ...audienceFor('info', state), when: 'now', publishAt: '', until: 'none', showUntil: '' };
349
+ }
350
+ const target = a.link ? state.links.find((l) => l.url === a.link) : undefined;
351
+ return {
352
+ kind: a.kind, words: { title: a.title, message: a.message, details: a.details, buttonLabel: a.buttonLabel }, translations,
353
+ linkRef: target?.ref ?? (a.link ? CUSTOM : ''), customUrl: target ? '' : a.link,
354
+ listed: a.listed, push: a.push, pinned: a.pinned,
355
+ everyone: a.audience.includes('*'), topics: a.audience.filter((t) => t !== '*'),
356
+ when: a.publishAt ? 'at' : 'now', publishAt: a.publishAt ? toLocal(a.publishAt) : '',
357
+ until: a.showUntil ? 'at' : 'none', showUntil: a.showUntil ? toLocal(a.showUntil) : '',
358
+ };
359
+ }
360
+
361
+ const linkOf = (f: Form, state: State) => (f.linkRef === CUSTOM ? f.customUrl.trim() : state.links.find((l) => l.ref === f.linkRef)?.url ?? '');
362
+
363
+ function toInput(f: Form, state: State, original: Announcement | null): AnnouncementInput {
364
+ // once it went up, the server keeps its push, audience and start as they were
365
+ const wentUp = original !== null && original.publishedAt !== null;
366
+ return {
367
+ kind: f.kind, ...f.words, link: linkOf(f, state), translations: f.translations,
368
+ listed: f.listed, pinned: f.pinned,
369
+ push: wentUp ? original.push : f.push && state.push.enabled,
370
+ audience: wentUp ? original.audience : f.kind === 'urgent' || f.everyone ? ['*'] : f.topics,
371
+ publishAt: f.when === 'at' ? fromLocal(f.publishAt) : null,
372
+ showUntil: f.until === 'at' ? fromLocal(f.showUntil) : null,
373
+ };
374
+ }
375
+
376
+ /** One language's words, each blank field falling back to the main language (as the server does). */
377
+ function localize(f: Form, lang: string, main: string): Words {
378
+ const t = lang === main ? null : f.translations[lang];
379
+ const m = f.words;
380
+ return t ? { title: t.title.trim() || m.title, message: t.message.trim() || m.message, details: t.details.trim() || m.details, buttonLabel: t.buttonLabel.trim() || m.buttonLabel } : m;
381
+ }
382
+
383
+ function Composer({ state, setState, startId, onClose }: {
384
+ state: State; setState: (s: State) => void; startId: string | null; onClose: (flash?: Flash, tab?: Tab) => void;
385
+ }) {
386
+ const now = useNow();
387
+ const uid = useId();
388
+ const [id, setId] = useState(startId);
389
+ const original = id ? state.announcements.find((a) => a.id === id) ?? null : null;
390
+ const wentUp = original !== null && original.publishedAt !== null;
391
+ const archived = original?.status === 'archived';
392
+ const [form, setForm] = useState(() => toForm(original, state));
393
+ const [baseline, setBaseline] = useState(() => JSON.stringify(form));
394
+ // a new announcement's audience follows its kind until the user picks one
395
+ const [touched, setTouched] = useState(startId !== null);
396
+ const { main, extra } = state.languages;
397
+ const [lang, setLang] = useState(main.code);
398
+ const [previewLang, setPreviewLang] = useState(main.code);
399
+ const [busy, setBusy] = useState<'' | 'draft' | 'schedule'>('');
400
+ const [msg, setMsg] = useState<Flash>({});
401
+ const [count, setCount] = useState<Recipients | 'error' | null>(null);
402
+ const [reviewErr, setReviewErr] = useState('');
403
+ const review = useRef<HTMLDialogElement>(null);
404
+ const heading = useRef<HTMLHeadingElement>(null);
405
+ const titleInput = useRef<HTMLInputElement>(null);
406
+ const countReq = useRef(0);
407
+
408
+ const dirty = JSON.stringify(form) !== baseline;
409
+ const set = (patch: Partial<Form>) => setForm((f) => ({ ...f, ...patch }));
410
+ const input = toInput(form, state, original);
411
+ const urgent = form.kind === 'urgent';
412
+ const link = linkOf(form, state);
413
+ const badLink = form.linkRef === CUSTOM && link !== '' && !LINK_OK.test(link);
414
+ const groups = [...new Set(state.links.map((l) => l.group))];
415
+ const topicName = (t: string) => state.push.topics.find((x) => x.id === t)?.label ?? t;
416
+
417
+ useEffect(() => { heading.current?.focus(); }, []);
418
+ // unsaved changes live only in this tab: warn before losing them
419
+ useEffect(() => {
420
+ if (!dirty) return;
421
+ const onLeave = (e: BeforeUnloadEvent) => e.preventDefault();
422
+ window.addEventListener('beforeunload', onLeave);
423
+ return () => window.removeEventListener('beforeunload', onLeave);
424
+ }, [dirty]);
425
+
426
+ const leave = () => { if (!dirty || confirm('Leave without saving? Your changes will be lost.')) onClose(); };
427
+ const switchLang = (code: string) => { setLang(code); setPreviewLang(code); };
428
+ const pickKind = (kind: Kind) => {
429
+ const patch: Partial<Form> = { kind };
430
+ if (!wentUp) {
431
+ // urgent sends to everyone (toInput), so the user's audience survives switching back
432
+ if (kind === 'urgent') Object.assign(patch, { push: state.push.enabled, pinned: true });
433
+ if (kind === 'registration') patch.pinned = true;
434
+ if (!touched && kind !== 'urgent') Object.assign(patch, audienceFor(kind, state));
435
+ }
436
+ set(patch);
437
+ };
438
+ const setWords = (patch: Partial<Words>) => setForm((f) => (lang === main.code
439
+ ? { ...f, words: { ...f.words, ...patch } }
440
+ : { ...f, translations: { ...f.translations, [lang]: { ...BLANK, ...f.translations[lang], ...patch } } }));
441
+
442
+ const save = async (intent: 'draft' | 'schedule') => {
443
+ setBusy(intent); setMsg({}); setReviewErr('');
444
+ try {
445
+ const r = await api<Saved>({ action: 'save', id: id ?? undefined, intent, announcement: input });
446
+ setState(r.state);
447
+ if (intent === 'schedule') {
448
+ review.current?.close();
449
+ onClose(resultFlash(r, Date.now()), tabOf(r.announcement, Date.now()));
450
+ return;
451
+ }
452
+ const next = toForm(r.announcement, r.state);
453
+ setId(r.announcement.id); setForm(next); setBaseline(JSON.stringify(next)); setTouched(true);
454
+ setMsg({ ok: wentUp || archived ? 'Changes saved.' : original?.status === 'scheduled' ? 'Saved as a draft. It won’t go up until you schedule it again.' : 'Draft saved.' });
455
+ } catch (e) {
456
+ if (intent === 'schedule') setReviewErr(errText(e)); else setMsg({ err: errText(e) });
457
+ } finally { setBusy(''); }
458
+ };
459
+
460
+ const openReview = () => {
461
+ setReviewErr(''); setCount(null);
462
+ review.current?.showModal();
463
+ const req = ++countReq.current;
464
+ if (input.push && input.audience.length) {
465
+ api<{ recipients: Recipients }>({ action: 'count', audience: input.audience, kind: input.kind })
466
+ .then((r) => { if (req === countReq.current) setCount(r.recipients); }, () => { if (req === countReq.current) setCount('error'); });
467
+ }
468
+ };
469
+
470
+ // ---- the checklist in "Before you schedule"
471
+ const start = input.publishAt !== null && input.publishAt > now ? input.publishAt : null;
472
+ const checks: Check[] = [];
473
+ {
474
+ const title = form.words.title.trim();
475
+ const message = form.words.message.trim();
476
+ if (!title) checks.push({ tone: 'bad', text: `Add a title in ${main.name}.` });
477
+ else if (input.push && !message) checks.push({ tone: 'bad', text: 'Add the short message: it’s the text of the push.' });
478
+ else checks.push({ tone: message ? 'ok' : 'warn', text: message ? `Title and message in ${main.name}` : 'No short message. Only the title shows.' });
479
+ if (!input.listed && !input.push && !input.pinned) checks.push({ tone: 'bad', text: 'Choose where it shows: the list, a push or the banner.' });
480
+ for (const l of extra) {
481
+ if (form.translations[l.code]?.title.trim()) continue;
482
+ const phones = input.push && count && count !== 'error' ? count.languages.find((x) => x.code === l.code)?.count ?? 0 : 0;
483
+ checks.push({
484
+ tone: 'warn',
485
+ text: phones ? `${l.name} is missing. ${plural(phones, `${l.name} phone`)} would get the ${main.name}.` : `${l.name} is missing. ${l.name} readers see the ${main.name}.`,
486
+ fix: { label: `Add ${l.name}`, run: () => { review.current?.close(); switchLang(l.code); requestAnimationFrame(() => titleInput.current?.focus()); } },
487
+ });
488
+ }
489
+ if (form.linkRef === CUSTOM && !link) checks.push({ tone: 'bad', text: 'Add the address the button opens, or choose No button.' });
490
+ else if (badLink) checks.push({ tone: 'bad', text: 'The button’s address has to start with / or https://.' });
491
+ else checks.push({ tone: 'ok', text: link ? `“${form.words.buttonLabel.trim() || announcementStrings(main.code).more}” opens ${state.links.find((l) => l.ref === form.linkRef)?.label ?? link}` : 'No button' });
492
+ if (!input.push) checks.push({ tone: 'ok', text: 'No push' });
493
+ else if (!input.audience.length) checks.push({ tone: 'bad', text: 'Choose who gets the push.' });
494
+ else {
495
+ const who = input.audience.includes('*') ? 'everyone with notifications on' : `people following ${input.audience.map(topicName).join(', ')}`;
496
+ const c = count;
497
+ const langs = c && c !== 'error' && extra.length ? c.languages.filter((l) => l.count > 0).map((l) => `${l.count.toLocaleString()} ${l.name}`).join(', ') : '';
498
+ checks.push(c === null ? { tone: 'ok', text: `Push to ${who}: counting phones…` }
499
+ : c === 'error' ? { tone: 'ok', text: `Push to ${who}` }
500
+ : c.total === 0 ? { tone: 'warn', text: `Push to ${who}: no phones match yet` }
501
+ : { tone: 'ok', text: `Push to ${who}: about ${plural(c.total, 'phone')}${langs ? ` (${langs})` : ''}` });
502
+ }
503
+ if (form.when === 'at' && input.publishAt === null) checks.push({ tone: 'bad', text: 'Pick when it goes up.' });
504
+ else checks.push({ tone: 'ok', text: start ? `Goes up ${day(start, now)} at ${clock(start)}, ${fromNow(start, now)}` : form.when === 'at' ? 'Goes up now (the time you picked has passed)' : 'Goes up now' });
505
+ if (form.until === 'at' && input.showUntil === null) checks.push({ tone: 'bad', text: 'Pick when it comes down, or choose No end.' });
506
+ else if (input.showUntil !== null && input.showUntil <= Math.max(now, input.publishAt ?? 0)) checks.push({ tone: 'bad', text: 'The end has to be after the start.' });
507
+ else if (input.pinned) checks.push({ tone: 'ok', text: input.showUntil ? `Banner until ${at(input.showUntil, now)}` : 'Banner until you archive it' });
508
+ else if (input.showUntil) checks.push({ tone: 'ok', text: `Comes down ${at(input.showUntil, now)}` });
509
+ if (urgent) checks.push({ tone: 'warn', text: URGENT_NOTE });
510
+ }
511
+ const blocked = checks.some((c) => c.tone === 'bad');
512
+
513
+ const statusLine = !original ? <p className="an-status">Not saved yet</p> : (
514
+ <p className={`an-status status ${archived ? 'archived' : STATUS_CLASS[tabOf(original, now)]}`}>
515
+ {timeText(original, now)}{archived ? '. Put it back from the list to show it again.' : ''}
516
+ </p>
517
+ );
518
+ const pushNote = wentUp
519
+ ? (original.push ? (original.notificationId ? 'Pushed when it went up. A push only goes out once.' : 'No push went out when it went up.') : 'A push only goes out when it goes up.')
520
+ : !state.push.enabled ? 'Push notifications are off on this site.'
521
+ : state.push.devices === 0 ? 'No phone has turned notifications on yet.' : undefined;
522
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
523
+ const showPush = wentUp ? original.push : state.push.enabled && form.push;
524
+ const words = lang === main.code ? form.words : form.translations[lang] ?? BLANK;
525
+
526
+ return (
527
+ <div className="an">
528
+ <header className="an-head">
529
+ <button type="button" className="an-back" onClick={leave}>← All announcements</button>
530
+ <div className="an-head-row">
531
+ <div>
532
+ <h2 ref={heading} tabIndex={-1}>{id ? 'Edit announcement' : 'New announcement'}</h2>
533
+ {statusLine}
534
+ </div>
535
+ <div className="page-actions">
536
+ {wentUp || archived ? (
537
+ <button type="button" className="btn primary" disabled={!dirty || busy !== ''} onClick={() => save('draft')}>{busy ? 'Saving…' : 'Save changes'}</button>
538
+ ) : (
539
+ <>
540
+ <button type="button" className="btn" disabled={busy !== '' || (!dirty && original?.status !== 'scheduled')} onClick={() => save('draft')}>
541
+ {busy === 'draft' ? 'Saving…' : original?.status === 'scheduled' ? 'Save as draft' : 'Save draft'}</button>
542
+ <button type="button" className="btn primary" disabled={busy !== ''} onClick={openReview}>Review and schedule…</button>
543
+ </>
544
+ )}
545
+ </div>
546
+ </div>
547
+ </header>
548
+ <FlashLine flash={msg} />
549
+
550
+ <div className="card card-pad an-compose">
551
+ <div className="an-form">
552
+ <div className="an-sec">
553
+ <fieldset className="an-fs">
554
+ <legend className="an-legend">Kind</legend>
555
+ <p className="hint an-sec-hint">Sets the icon and colour.</p>
556
+ <div className="an-kinds">
557
+ {state.kinds.map((k) => (
558
+ <label key={k.id} className={`an-kind-opt${form.kind === k.id ? ' on' : ''}`} data-kind={k.id}>
559
+ <input type="radio" name={`${uid}-kind`} value={k.id} checked={form.kind === k.id} onChange={() => pickKind(k.id)}
560
+ disabled={wentUp && (original.kind === 'urgent' ? k.id !== 'urgent' : k.id === 'urgent')} />
561
+ <i><Icon d={KIND_ICON[k.id]} /></i>
562
+ <span>{k.label}</span>
563
+ </label>
564
+ ))}
565
+ </div>
566
+ {wentUp && <p className="hint an-after">{original.kind === 'urgent' ? 'It went up as urgent and stays urgent.' : 'It already went up, so it can’t become urgent.'}</p>}
567
+ </fieldset>
568
+ </div>
569
+
570
+ <div className="an-sec">
571
+ {extra.length > 0 && (
572
+ <div className="an-langs">
573
+ <Tabs label="Language" idPrefix={`${uid}-lang`} controls={`${uid}-words`} value={lang} onChange={switchLang}
574
+ items={[{ id: main.code, label: main.name }, ...extra.map((l) => ({ id: l.code, label: form.translations[l.code]?.title.trim() ? l.name : `${l.name} · missing` }))]} />
575
+ </div>
576
+ )}
577
+ <div id={`${uid}-words`} role={extra.length ? 'tabpanel' : undefined} aria-labelledby={extra.length ? `${uid}-lang-${lang}` : undefined}>
578
+ {lang !== main.code && <p className="hint an-sec-hint">Blank fields show the {main.name}.</p>}
579
+ <WordFields words={words} source={lang === main.code ? null : form.words} sourceName={main.name} limits={state.limits}
580
+ buttonDefault={announcementStrings(lang).more} hasLink={link !== ''} titleRef={titleInput} onChange={setWords} />
581
+ </div>
582
+ <label className="field">
583
+ <span>Button opens</span>
584
+ <select value={form.linkRef} onChange={(e) => set({ linkRef: e.target.value })}>
585
+ <option value="">No button</option>
586
+ {groups.map((g) => (
587
+ <optgroup key={g} label={g}>
588
+ {state.links.filter((l) => l.group === g).map((l) => <option key={l.ref} value={l.ref}>{l.label}</option>)}
589
+ </optgroup>
590
+ ))}
591
+ <option value={CUSTOM}>Custom address…</option>
592
+ </select>
593
+ </label>
594
+ {form.linkRef === CUSTOM && (
595
+ <label className="field">
596
+ <span>Address</span>
597
+ <input className="mono" type="url" value={form.customUrl} placeholder="/visit or https://example.com" aria-invalid={badLink || undefined} onChange={(e) => set({ customUrl: e.target.value })} />
598
+ </label>
599
+ )}
600
+ {badLink ? <p className="hint an-bad">Links start with / or https://.</p> : link && <p className="hint an-linkhint">When tapped, opens: <code>{link}</code></p>}
601
+ </div>
602
+
603
+ <div className="an-sec">
604
+ <fieldset className="an-fs">
605
+ <legend className="an-legend">Where it shows</legend>
606
+ <Toggle label="Announcements list (app and website)" checked={form.listed} onChange={(v) => set({ listed: v })} />
607
+ <Toggle label="Push to phones" checked={wentUp ? original.push : form.push && state.push.enabled} disabled={wentUp || !state.push.enabled}
608
+ note={pushNote} onChange={(v) => set({ push: v })} />
609
+ <Toggle label="Pin as a banner until it ends" checked={form.pinned} note="Closing it hides this announcement only; the next one still shows." onChange={(v) => set({ pinned: v })} />
610
+ </fieldset>
611
+ </div>
612
+
613
+ {showPush && (
614
+ <div className="an-sec">
615
+ <fieldset className="an-fs">
616
+ <legend className="an-legend">Who gets the push</legend>
617
+ {wentUp ? (
618
+ <p className="an-ro">{original.kind === 'urgent' || original.audience.includes('*') ? 'Everyone with notifications on' : `People following ${original.audience.map(topicName).join(', ')}`}</p>
619
+ ) : (
620
+ <>
621
+ <Tabs label="Who gets the push" disabled={urgent} value={urgent || form.everyone ? 'all' : 'topics'}
622
+ onChange={(v) => { setTouched(true); set({ everyone: v === 'all' }); }}
623
+ items={[{ id: 'all', label: 'Everyone with notifications on' }, { id: 'topics', label: 'People following…' }]} />
624
+ {urgent ? <p className="hint an-after">{URGENT_NOTE}</p> : form.everyone ? (
625
+ <p className="hint an-after">{plural(state.push.devices, 'phone')} with notifications on.</p>
626
+ ) : (
627
+ <div className="an-topics">
628
+ {state.push.topics.map((t) => (
629
+ <label key={t.id} className={`an-opt${form.topics.includes(t.id) ? ' on' : ''}`}>
630
+ <input type="checkbox" checked={form.topics.includes(t.id)} onChange={() => {
631
+ setTouched(true);
632
+ set({ topics: form.topics.includes(t.id) ? form.topics.filter((x) => x !== t.id) : [...form.topics, t.id] });
633
+ }} />
634
+ <span>{t.label}</span>
635
+ <em>{(state.push.topicCounts[t.id] ?? 0).toLocaleString()}</em>
636
+ </label>
637
+ ))}
638
+ </div>
639
+ )}
640
+ <p className="hint an-pace" data-tone={state.push.sentThisWeek >= 2 ? 'warn' : undefined}>
641
+ Sent this week: {plural(state.push.sentThisWeek, 'push', 'pushes')}. Keep it to about two a week so people leave notifications on.</p>
642
+ </>
643
+ )}
644
+ </fieldset>
645
+ </div>
646
+ )}
647
+
648
+ <div className="an-sec">
649
+ <div className="an-when">
650
+ <fieldset className="an-fs">
651
+ <legend className="an-legend">When</legend>
652
+ {wentUp ? <p className="an-ro">Went up {at(original.publishedAt!, now)}</p> : (
653
+ <>
654
+ <Tabs label="When it goes up" value={form.when} items={[{ id: 'now', label: 'Now' }, { id: 'at', label: 'Pick a time' }]}
655
+ onChange={(v) => set({ when: v, publishAt: v === 'at' && !form.publishAt ? toLocal(nextHour(now)) : form.publishAt })} />
656
+ {form.when === 'at' && <input type="datetime-local" aria-label="Goes up at" value={form.publishAt} min={toLocal(now)} onChange={(e) => set({ publishAt: e.target.value })} />}
657
+ </>
658
+ )}
659
+ </fieldset>
660
+ <fieldset className="an-fs">
661
+ <legend className="an-legend">Show until <span className="an-opt-l">(optional)</span></legend>
662
+ <Tabs label="Show until" value={form.until} items={[{ id: 'none', label: 'No end' }, { id: 'at', label: 'Pick a time' }]}
663
+ onChange={(v) => set({ until: v, showUntil: v === 'at' && !form.showUntil ? toLocal(weekLater(fromLocal(form.publishAt) ?? now)) : form.showUntil })} />
664
+ {form.until === 'at' && <input type="datetime-local" aria-label="Comes down at" value={form.showUntil} min={form.when === 'at' && form.publishAt ? form.publishAt : toLocal(now)} onChange={(e) => set({ showUntil: e.target.value })} />}
665
+ </fieldset>
666
+ </div>
667
+ <p className="hint an-after">It goes up at the start time and comes down at the end. With no end, it stays until you archive it.</p>
668
+ {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>}
669
+ </div>
670
+ </div>
671
+ <Preview state={state} form={form} lang={previewLang} setLang={setPreviewLang} />
672
+ </div>
673
+
674
+ <dialog className="modal an-review" ref={review} aria-labelledby={`${uid}-review-h`}>
675
+ <div className="modal-head"><span id={`${uid}-review-h`}>Before you schedule</span>
676
+ <button type="button" className="modal-x" onClick={() => review.current?.close()} aria-label="Close">✕</button></div>
677
+ <div className="modal-body">
678
+ <ul className="an-checks">
679
+ {checks.map((c) => (
680
+ <li key={c.text} data-tone={c.tone}>
681
+ <span className="an-mark" aria-hidden="true">{c.tone === 'ok' ? '✓' : c.tone === 'warn' ? '!' : '✕'}</span>
682
+ <span><span className="sr-only">{c.tone === 'ok' ? 'Ready: ' : c.tone === 'warn' ? 'Check: ' : 'Fix first: '}</span>{c.text}</span>
683
+ {c.fix && <button type="button" className="btn sm" onClick={c.fix.run}>{c.fix.label}</button>}
684
+ </li>
685
+ ))}
686
+ </ul>
687
+ <FlashLine flash={{ err: reviewErr }} />
688
+ <div className="modal-foot">
689
+ <button type="button" className="btn" onClick={() => review.current?.close()}>Keep editing</button>
690
+ <button type="button" className="btn primary" disabled={blocked || busy !== ''} onClick={() => save('schedule')}>
691
+ {busy === 'schedule' ? 'Saving…' : start ? 'Schedule' : 'Put it up now'}</button>
692
+ </div>
693
+ </div>
694
+ </dialog>
695
+ </div>
696
+ );
697
+ }
698
+
699
+ function WordFields({ words, source, sourceName, limits, buttonDefault, hasLink, titleRef, onChange }: {
700
+ words: Words; source: Words | null; sourceName: string; limits: State['limits']; buttonDefault: string;
701
+ hasLink: boolean; titleRef: Ref<HTMLInputElement>; onChange: (patch: Partial<Words>) => void;
702
+ }) {
703
+ const field = (key: keyof Words) => ({ value: words[key], source: source?.[key], sourceName, onChange: (v: string) => onChange({ [key]: v }) });
704
+ return (
705
+ <>
706
+ <TextField label="Title" limit={limits.title} inputRef={titleRef} placeholder={source?.title || 'Volunteers needed for Saturday’s food drive'} {...field('title')} />
707
+ <TextField label="Short message (the push)" limit={limits.message} rows={3} placeholder={source?.message || 'We need 10 more people from 9 to noon. Sign up by Thursday.'} {...field('message')} />
708
+ <TextField label="More details (shown in the app and on the website)" limit={limits.details} rows={5} placeholder={source?.details}
709
+ hint="Optional. A blank line starts a new paragraph." {...field('details')} />
710
+ <TextField label="Button label" limit={limits.buttonLabel} placeholder={source?.buttonLabel || buttonDefault}
711
+ hint={hasLink ? undefined : 'Shows only when the button opens something (below).'} {...field('buttonLabel')} />
712
+ </>
713
+ );
714
+ }
715
+
716
+ // A text field with a character counter; on another language's tab the main
717
+ // language's words sit above it, read-only.
718
+ function TextField({ label, value, limit, rows, source, sourceName, placeholder, hint, inputRef, onChange }: {
719
+ label: string; value: string; limit: number; rows?: number; source?: string; sourceName: string;
720
+ placeholder?: string; hint?: string; inputRef?: Ref<HTMLInputElement>; onChange: (v: string) => void;
721
+ }) {
722
+ const id = useId();
723
+ const describedBy = [source && `${id}-src`, hint && `${id}-hint`, `${id}-n`].filter(Boolean).join(' ');
724
+ const common = { id, value, maxLength: limit, placeholder, 'aria-describedby': describedBy };
725
+ return (
726
+ <div className="an-field">
727
+ <div className="an-field-top">
728
+ <label htmlFor={id}>{label}</label>
729
+ <em className="an-count" id={`${id}-n`} data-near={value.length > limit * 0.9 || undefined}>{value.length}/{limit}</em>
730
+ </div>
731
+ {source && <p className="an-src" id={`${id}-src`}><span>{sourceName}</span>{source}</p>}
732
+ {rows ? <textarea rows={rows} {...common} onChange={(e) => onChange(e.target.value)} />
733
+ : <input type="text" ref={inputRef} {...common} onChange={(e) => onChange(e.target.value)} />}
734
+ {hint && <small className="hint" id={`${id}-hint`}>{hint}</small>}
735
+ </div>
736
+ );
737
+ }
738
+
739
+ function Toggle({ label, note, checked, disabled, onChange }: { label: string; note?: string; checked: boolean; disabled?: boolean; onChange: (v: boolean) => void }) {
740
+ const id = useId();
741
+ return (
742
+ <div className="an-toggle">
743
+ <input id={id} type="checkbox" checked={checked} disabled={disabled} aria-describedby={note ? `${id}-note` : undefined} onChange={(e) => onChange(e.target.checked)} />
744
+ <label htmlFor={id}>{label}</label>
745
+ {note && <small className="hint" id={`${id}-note`}>{note}</small>}
746
+ </div>
747
+ );
748
+ }
749
+
750
+ // ---- preview ----------------------------------------------------------------------
751
+
752
+ // A phone lock screen with the push, and the website banner when pinned.
753
+ // Device and site previews: fixed neutral colours, not admin tokens.
754
+ function Preview({ state, form, lang, setLang }: { state: State; form: Form; lang: string; setLang: (l: string) => void }) {
755
+ const [open, setOpen] = useState(false);
756
+ const [time, setTime] = useState(() => new Date());
757
+ useEffect(() => { const t = setInterval(() => setTime(new Date()), 30_000); return () => clearInterval(t); }, []);
758
+ const { main, extra } = state.languages;
759
+ const w = localize(form, lang, main.code);
760
+ const link = linkOf(form, state);
761
+ const push = form.push && state.push.enabled;
762
+ return (
763
+ <aside className="an-preview" aria-label="Preview">
764
+ <div className="an-preview-tools">
765
+ <Tabs label="Notification size" value={open ? 'open' : 'closed'} onChange={(v) => setOpen(v === 'open')}
766
+ items={[{ id: 'closed', label: 'Collapsed' }, { id: 'open', label: 'Expanded' }]} />
767
+ {extra.length > 0 && (
768
+ <select className="an-preview-lang" aria-label="Preview language" value={lang} onChange={(e) => setLang(e.target.value)}>
769
+ {[main, ...extra].map((l) => <option key={l.code} value={l.code}>{l.name}</option>)}
770
+ </select>
771
+ )}
772
+ </div>
773
+ <div className={`an-phone${push ? '' : ' is-off'}`}>
774
+ <div className="an-phone-screen">
775
+ <div className="an-clock">
776
+ <small>{time.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</small>
777
+ <b>{time.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?[AP]M$/i, '')}</b>
778
+ </div>
779
+ <div className={`an-note${open ? ' is-open' : ''}`}>
780
+ <div className="an-note-top">
781
+ <span className="an-appicon" aria-hidden="true">
782
+ {state.site.icon ? <img src={state.site.icon} alt="" /> : <Icon d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0" />}
783
+ </span>
784
+ <span className="an-appname">{state.site.name || 'Your app'}</span>
785
+ <span className="an-now">now</span>
786
+ </div>
787
+ <b className="an-note-title">{w.title.trim() || 'Your title'}</b>
788
+ <p className="an-note-body">{w.message.trim() || 'Your short message appears here.'}</p>
789
+ </div>
790
+ </div>
791
+ </div>
792
+ <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>
793
+ {form.pinned && (
794
+ <div className="an-site">
795
+ <small className="an-site-label">Website banner</small>
796
+ <div className="an-site-bar" data-kind={form.kind}>
797
+ <i aria-hidden="true"><Icon d={KIND_ICON[form.kind]} /></i>
798
+ <b>{w.title.trim() || 'Your title'}</b>
799
+ {link && <span className="an-site-btn">{w.buttonLabel.trim() || announcementStrings(lang).more} →</span>}
800
+ <span className="an-site-x" aria-hidden="true">✕</span>
801
+ </div>
802
+ </div>
803
+ )}
804
+ </aside>
805
+ );
806
+ }