@acms-subir/core 0.3.34 → 0.3.36

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 (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +7 -1
  3. package/docs/mobile.md +9 -6
  4. package/docs/theme-authoring.md +5 -4
  5. package/integration.mjs +3 -1
  6. package/mobile-template/client/AGENTS.md +2 -1
  7. package/mobile-template/client/README.md +1 -1
  8. package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
  9. package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
  10. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +270 -0
  11. package/mobile-template/recipes/events/recipe.json +6 -3
  12. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +270 -0
  13. package/mobile-template/recipes/rsvp/recipe.json +15 -0
  14. package/package.json +1 -1
  15. package/src/components/Announcements.tsx +14 -1
  16. package/src/components/LiveCard.astro +23 -10
  17. package/src/components/MobileApp.tsx +54 -4
  18. package/src/layouts/Site.astro +45 -38
  19. package/src/lib/announcements/api.ts +8 -4
  20. package/src/lib/announcements/store.ts +4 -0
  21. package/src/lib/cache/policy.ts +2 -2
  22. package/src/lib/calendar/hooks.ts +5 -1
  23. package/src/lib/legal/app-privacy.ts +5 -3
  24. package/src/lib/legal/text-en.ts +1 -0
  25. package/src/lib/legal/text-es.ts +1 -0
  26. package/src/lib/live/view.ts +28 -24
  27. package/src/lib/live/words.ts +94 -0
  28. package/src/lib/mobile/bookings.ts +17 -11
  29. package/src/lib/mobile/forms.ts +21 -11
  30. package/src/lib/mobile/manage.ts +2 -0
  31. package/src/lib/mobile/openapi.ts +8 -3
  32. package/src/lib/mobile/rsvp.ts +65 -0
  33. package/src/lib/mobile/schemas.ts +9 -1
  34. package/src/lib/mobile/site.ts +3 -1
  35. package/src/lib/push/api.ts +13 -1
  36. package/src/lib/push/service.ts +211 -24
  37. package/src/lib/push/store.ts +35 -8
  38. package/src/lib/push/types.ts +23 -2
  39. package/src/lib/repo/migrations.ts +6 -0
  40. package/src/lib/schema.ts +2 -1
  41. package/src/lib/search-i18n.ts +41 -4
  42. package/src/lib/site-chrome.ts +8 -0
  43. package/src/lib/site-words.ts +62 -0
  44. package/src/pages/admin/calendar/bookings.astro +3 -1
  45. package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
  46. package/src/styles/mobile-app.css +7 -0
  47. package/src/theme-sdk/index.ts +2 -0
@@ -12,6 +12,8 @@ import { announcementPath, localizeAnnouncement } from '../lib/announcements/ser
12
12
  import { announcementStrings } from '../lib/announcements/words';
13
13
  import { SEARCH } from '../lib/search-config';
14
14
  import { searchStrings } from '../lib/search-i18n';
15
+ import { siteStrings } from '../lib/site-words';
16
+ import { liveStrings } from '../lib/live/words';
15
17
  import { effectiveSettings, effectiveNav, resolvePreview } from '../lib/designPreview';
16
18
  import { diffDesign } from '../lib/designDiff';
17
19
  import { getTheme } from '../lib/theme';
@@ -71,10 +73,19 @@ const compareUrl = (live: boolean) => {
71
73
  const daysLeft = preview.expiresAt ? Math.max(1, Math.ceil((preview.expiresAt - Date.now()) / 86_400_000)) : 0;
72
74
  const versionDate = preview.versionAt ? new Date(preview.versionAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
73
75
  const design = resolveDesign(settings);
76
+ // the path the visitor sees (/es/… is rewritten to the shared route by the middleware)
77
+ const current = (Astro.locals as { acmsPath?: string }).acmsPath ?? Astro.url.pathname;
78
+ // with i18n configured, the request path decides the document language; the
79
+ // locale setting (a date-formatting concern) stays the fallback
80
+ const reqLocale = I18N.enabled ? requestLocale(new URL(current, Astro.url), Astro.locals) : '';
81
+ const htmlLang = reqLocale || siteLang(settings);
82
+ // built-in words (skip link, menus, footer columns, legal links) follow it too
83
+ const words = siteStrings(htmlLang);
84
+ const liveWords = liveStrings(htmlLang);
74
85
  const navPages = pages.filter((p) => p.status === 'published' && !p.isHome);
75
86
  const headerNav = navItems.filter((n) => n.area === 'header');
76
87
  const footerNav = navItems.filter((n) => n.area === 'footer').map(n => ({ label: n.label, url: n.url }));
77
- if (LEGAL.enabled) for (const [url, label] of [['/privacy', 'Privacy'], ['/terms', 'Terms']]) {
88
+ if (LEGAL.enabled) for (const [url, label] of [['/privacy', words.privacy], ['/terms', words.terms]]) {
78
89
  if (!footerNav.some(n => n.url.replace(/\/$/, '') === url)) footerNav.push({ url, label });
79
90
  }
80
91
  const socialNav = navItems.filter((n) => n.area === 'social').map((n) => {
@@ -90,17 +101,15 @@ const isCurrent = (url: string) =>
90
101
  const headerLinks: { label: string; url: string }[] = headerNav.length > 0
91
102
  ? headerNav.map((n) => ({ label: n.label, url: n.url }))
92
103
  : [
93
- { label: 'Home', url: '/' },
104
+ { label: words.home, url: '/' },
94
105
  ...navPages.map((p) => ({ label: p.title, url: `/${p.slug}` })),
95
106
  ...Object.values(CONTENT.collections).map((c) => ({ label: c.plural, url: c.base })),
96
- { label: 'Links', url: '/links' },
107
+ { label: words.links, url: '/links' },
97
108
  ];
98
109
  const tokens = design.tokens;
99
110
  // the visitor scheme toggle only exists when the theme follows the OS
100
111
  const schemeToggle = tokens.scheme === 'auto';
101
112
 
102
- // the path the visitor sees (/es/… is rewritten to the shared route by the middleware)
103
- const current = (Astro.locals as { acmsPath?: string }).acmsPath ?? Astro.url.pathname;
104
113
  const fullTitle = exactTitle && title ? title : title ? `${title} · ${settings.siteTitle}` : settings.siteTitle;
105
114
  // the columns footer already shows the tagline beside the brand, so its
106
115
  // default bottom line is just the copyright
@@ -122,14 +131,10 @@ const annKey = showAnnouncement
122
131
  : '';
123
132
  const canonical = origin + canonicalPath(current);
124
133
  const metaDescription = description || settings.tagline || undefined;
125
- // with i18n configured, the request path decides the document language; the
126
- // locale setting (a date-formatting concern) stays the fallback
127
- const reqLocale = I18N.enabled ? requestLocale(new URL(current, Astro.url), Astro.locals) : '';
128
134
  // same page, other language; internal links stay in the visitor's language
129
135
  const barePath = stripLocale(current);
130
136
  const inLang = reqLocale && reqLocale !== I18N.defaultLocale ? reqLocale : '';
131
137
  const lnk = (url: string) => (inLang && /^\/(?!\/|media\/|rss\.xml|sitemap|admin|api|_)/.test(url) ? localePath(url, inLang) : url);
132
- const htmlLang = reqLocale || siteLang(settings);
133
138
  // pinned announcements (Admin → Announcements) take the bar's place while
134
139
  // they're up, urgent first; each one is closed on its own (remembered per id)
135
140
  const annWords = announcementStrings(htmlLang);
@@ -152,7 +157,7 @@ const searchAction = reqLocale && reqLocale !== I18N.defaultLocale ? `/${reqLoca
152
157
  // theme asks for it (footer: simple).
153
158
  const readLinks = [
154
159
  ...Object.values(CONTENT.collections).map((c) => ({ label: c.plural, url: c.base })),
155
- ...(SEARCH.enabled ? [{ label: 'Search', url: searchAction }] : []),
160
+ ...(SEARCH.enabled ? [{ label: searchT.label, url: searchAction }] : []),
156
161
  { label: 'RSS', url: '/rss.xml' },
157
162
  ];
158
163
  // a theme's templates/header.astro / footer.astro get this instead of core markup
@@ -174,6 +179,7 @@ const chrome: SiteChrome = {
174
179
  schemeToggle,
175
180
  liveHeader: Boolean(liveSignals?.header),
176
181
  headerStyle: String(tokens.header), footerStyle: String(tokens.footer),
182
+ lang: htmlLang, words,
177
183
  };
178
184
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
185
  const HeaderTpl = design.theme.HeaderTemplate as any;
@@ -259,10 +265,10 @@ const homeJsonLd =
259
265
  <body>
260
266
  {liveSignals?.bar && (
261
267
  <div class="pc-live-bar" data-live-bar hidden role="status">
262
- <span class="pc-live-mark">Live now</span>
268
+ <span class="pc-live-mark">{liveWords.bar}</span>
263
269
  <span class="pc-live-bar-title" data-live-bar-title></span>
264
- <a data-live-bar-watch href="#">Watch →</a>
265
- <button type="button" class="pc-live-x" aria-label="Dismiss live notice">✕</button>
270
+ <a data-live-bar-watch href="#">{liveWords.watch}</a>
271
+ <button type="button" class="pc-live-x" aria-label={liveWords.dismissBar}>✕</button>
266
272
  </div>
267
273
  )}
268
274
  {pinnedBars.map((b) => (
@@ -297,7 +303,7 @@ const homeJsonLd =
297
303
  <div class="announcement-bar" data-ann-key={ann.dismissible ? annKey : undefined} hidden={ann.dismissible || undefined}>
298
304
  {annLink ? <a href={annLink}>{ann.text}</a> : <span>{ann.text}</span>}
299
305
  {ann.dismissible && (
300
- <button type="button" class="announcement-x" aria-label="Dismiss announcement">✕</button>
306
+ <button type="button" class="announcement-x" aria-label={annWords.dismiss}>✕</button>
301
307
  )}
302
308
  </div>
303
309
  )}
@@ -317,7 +323,7 @@ const homeJsonLd =
317
323
  })();
318
324
  </script>
319
325
  )}
320
- {!bare && <a class="skip-link" href="#main">Skip to content</a>}
326
+ {!bare && <a class="skip-link" href="#main">{words.skip}</a>}
321
327
  {!bare && HeaderTpl && <HeaderTpl site={chrome} />}
322
328
  {!bare && !HeaderTpl && (
323
329
  <header class="site-header" data-style={tokens.header}>
@@ -325,7 +331,7 @@ const homeJsonLd =
325
331
  <a class="brand" href={reqLocale ? localeHomeUrl(reqLocale) : '/'}>
326
332
  <SiteLogo title={settings.siteTitle} logo={settings.siteLogo} logoDark={settings.siteLogoDark} />
327
333
  </a>
328
- <nav class="site-nav" aria-label="Main">
334
+ <nav class="site-nav" aria-label={words.main}>
329
335
  {headerLinks.map((n) => (
330
336
  <a
331
337
  href={lnk(n.url)}
@@ -336,7 +342,7 @@ const homeJsonLd =
336
342
  ))}
337
343
  </nav>
338
344
  <div class="site-tools">
339
- {liveSignals?.header && <a class="site-live" data-live-header href="#" hidden>Live</a>}
345
+ {liveSignals?.header && <a class="site-live" data-live-header href="#" hidden>{liveWords.marker}</a>}
340
346
  {showSearch && (
341
347
  <a class="pc-search-trigger" href={searchAction} data-search-open aria-label={searchT.label}>
342
348
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6" /></svg>
@@ -345,7 +351,7 @@ const homeJsonLd =
345
351
  </a>
346
352
  )}
347
353
  {I18N.enabled && (
348
- <span class="lang-switch" role="group" aria-label="Language">
354
+ <span class="lang-switch" role="group" aria-label={words.language}>
349
355
  {I18N.locales.map((l) => (
350
356
  <a
351
357
  href={localePath(barePath, l)}
@@ -357,12 +363,12 @@ const homeJsonLd =
357
363
  </span>
358
364
  )}
359
365
  {schemeToggle && (
360
- <button type="button" class="icon-btn scheme-toggle" data-scheme-toggle aria-label="Toggle dark mode">
366
+ <button type="button" class="icon-btn scheme-toggle" data-scheme-toggle aria-label={words.scheme}>
361
367
  <svg class="i-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" /></svg>
362
368
  <svg class="i-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4" /></svg>
363
369
  </button>
364
370
  )}
365
- <button type="button" class="icon-btn menu-btn" data-menu-open aria-label="Open menu" aria-haspopup="dialog">
371
+ <button type="button" class="icon-btn menu-btn" data-menu-open aria-label={words.openMenu} aria-haspopup="dialog">
366
372
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 8h16M4 16h16" /></svg>
367
373
  </button>
368
374
  </div>
@@ -370,9 +376,9 @@ const homeJsonLd =
370
376
  </header>
371
377
  )}
372
378
  {!bare && (
373
- <dialog class="site-menu" aria-label="Menu">
379
+ <dialog class="site-menu" aria-label={words.menu}>
374
380
  <div class="site-menu-grab" aria-hidden="true"></div>
375
- <nav aria-label="Main">
381
+ <nav aria-label={words.main}>
376
382
  {headerLinks.map((n) => (
377
383
  <a
378
384
  href={lnk(n.url)}
@@ -384,7 +390,7 @@ const homeJsonLd =
384
390
  </nav>
385
391
  <div class="site-menu-foot">
386
392
  {I18N.enabled ? (
387
- <span class="lang-switch" role="group" aria-label="Language">
393
+ <span class="lang-switch" role="group" aria-label={words.language}>
388
394
  {I18N.locales.map((l) => (
389
395
  <a href={localePath(barePath, l)} lang={l} class:list={[{ on: l === (reqLocale || I18N.defaultLocale) }]}>{l.toUpperCase()}</a>
390
396
  ))}
@@ -392,12 +398,12 @@ const homeJsonLd =
392
398
  ) : <span />}
393
399
  <span class="site-menu-actions">
394
400
  {schemeToggle && (
395
- <button type="button" class="icon-btn scheme-toggle" data-scheme-toggle aria-label="Toggle dark mode">
401
+ <button type="button" class="icon-btn scheme-toggle" data-scheme-toggle aria-label={words.scheme}>
396
402
  <svg class="i-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" /></svg>
397
403
  <svg class="i-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4" /></svg>
398
404
  </button>
399
405
  )}
400
- <button type="button" class="icon-btn" data-menu-close aria-label="Close menu">
406
+ <button type="button" class="icon-btn" data-menu-close aria-label={words.closeMenu}>
401
407
  <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
402
408
  </button>
403
409
  </span>
@@ -405,7 +411,7 @@ const homeJsonLd =
405
411
  </dialog>
406
412
  )}
407
413
  {!bare && (
408
- <script is:inline>
414
+ <script is:inline define:vars={{ copyLabel: words.copy, copiedLabel: words.copied, closeLabel: words.close }}>
409
415
  (() => {
410
416
  // phone menu: a bottom sheet on <dialog> (focus trap + Esc for free)
411
417
  const menu = document.querySelector('dialog.site-menu');
@@ -428,13 +434,13 @@ const homeJsonLd =
428
434
  const b = document.createElement('button');
429
435
  b.type = 'button';
430
436
  b.className = 'code-copy';
431
- b.textContent = 'Copy';
437
+ b.textContent = copyLabel;
432
438
  b.addEventListener('click', async () => {
433
439
  try {
434
440
  await navigator.clipboard.writeText((pre.querySelector('code') || pre).innerText);
435
- b.textContent = 'Copied';
441
+ b.textContent = copiedLabel;
436
442
  b.classList.add('is-done');
437
- setTimeout(() => { b.textContent = 'Copy'; b.classList.remove('is-done'); }, 1400);
443
+ setTimeout(() => { b.textContent = copyLabel; b.classList.remove('is-done'); }, 1400);
438
444
  } catch {}
439
445
  });
440
446
  pre.append(b);
@@ -448,7 +454,8 @@ const homeJsonLd =
448
454
  if (!box) {
449
455
  box = document.createElement('dialog');
450
456
  box.className = 'pc-lightbox';
451
- box.innerHTML = '<img alt=""><button type="button" aria-label="Close">\u2715</button>';
457
+ box.innerHTML = '<img alt=""><button type="button">\u2715</button>';
458
+ box.querySelector('button').setAttribute('aria-label', closeLabel);
452
459
  box.addEventListener('click', () => box.close());
453
460
  document.body.append(box);
454
461
  }
@@ -727,7 +734,7 @@ const homeJsonLd =
727
734
  <a class="brand" href="/"><SiteLogo title={settings.siteTitle} logo={settings.siteLogo} logoDark={settings.siteLogoDark} /></a>
728
735
  {settings.tagline && <p class="footer-blurb">{settings.tagline}</p>}
729
736
  {socialNav.length > 0 && (
730
- <nav class="social-nav" aria-label="Social">
737
+ <nav class="social-nav" aria-label={words.social}>
731
738
  {socialNav.map((n) => (
732
739
  <a href={lnk(n.url)} class:list={[{ 'has-icon': n.icon }]} aria-label={n.icon ? n.label : undefined} title={n.icon ? n.label : undefined} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener me' : undefined}>
733
740
  {n.icon ? <Fragment set:html={n.icon} /> : n.label}
@@ -736,13 +743,13 @@ const homeJsonLd =
736
743
  </nav>
737
744
  )}
738
745
  </div>
739
- <nav class="footer-col" aria-label="Read">
740
- <h2>Read</h2>
746
+ <nav class="footer-col" aria-label={words.read}>
747
+ <h2>{words.read}</h2>
741
748
  {readLinks.map((n) => <a href={lnk(n.url)}>{n.label}</a>)}
742
749
  </nav>
743
750
  {footerNav.length > 0 && (
744
- <nav class="footer-col" aria-label="Site">
745
- <h2>Site</h2>
751
+ <nav class="footer-col" aria-label={words.site}>
752
+ <h2>{words.site}</h2>
746
753
  {footerNav.map((n) => (
747
754
  <a href={lnk(n.url)} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener' : undefined}>{n.label}</a>
748
755
  ))}
@@ -759,7 +766,7 @@ const homeJsonLd =
759
766
  </nav>
760
767
  )}
761
768
  {socialNav.length > 0 && (
762
- <nav class="social-nav" aria-label="Social">
769
+ <nav class="social-nav" aria-label={words.social}>
763
770
  {socialNav.map((n) => (
764
771
  <a href={lnk(n.url)} class:list={[{ 'has-icon': n.icon }]} aria-label={n.icon ? n.label : undefined} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener me' : undefined}>
765
772
  {n.icon ? <Fragment set:html={n.icon} /> : n.label}
@@ -777,9 +784,9 @@ const homeJsonLd =
777
784
  <div class="pc-live-dock" data-live-dock hidden>
778
785
  <a href="#">
779
786
  <span class="pc-live-dock-thumb"><img alt="" decoding="async" hidden /></span>
780
- <span><span class="pc-live-mark">Live</span><b data-live-dock-title></b></span>
787
+ <span><span class="pc-live-mark">{liveWords.marker}</span><b data-live-dock-title></b></span>
781
788
  </a>
782
- <button type="button" class="pc-live-x" aria-label="Dismiss live player">✕</button>
789
+ <button type="button" class="pc-live-x" aria-label={liveWords.dismissDock}>✕</button>
783
790
  </div>
784
791
  )}
785
792
  <script>
@@ -8,14 +8,14 @@ import { siteOrigin } from '../seo';
8
8
  import { pageQuery, queryParams } from '../mobile/http';
9
9
  import { localeOnlyQuery, localeQuery, mobileLocale } from '../mobile/locale';
10
10
  import { PUSH } from '../push/config';
11
- import { deviceLocale, knownTopics, pushTopics, siteOriginOf } from '../push/service';
11
+ import { deviceLocale, knownTopics, pushTopics, siteOriginOf, sitePath } from '../push/service';
12
12
  import { linkTargets } from '../push/api';
13
13
  import { ANNOUNCEMENTS } from './config';
14
14
  import { announcementJson, isLive, publishAnnouncement, type PublishOptions } from './service';
15
15
  import { announcementStrings } from './words';
16
16
  import { ANNOUNCEMENT_KINDS, AnnouncementInput, AnnouncementKind, LIMITS, type Announcement, type AnnouncementWords } from './types';
17
17
 
18
- const DAY = 86_400_000;
18
+ const HOUR = 3_600_000, DAY = 24 * HOUR;
19
19
  export function requireAnnouncements() {
20
20
  if (!ANNOUNCEMENTS.enabled) throw new ApiError(404, 'Announcements are off on this site.');
21
21
  }
@@ -70,18 +70,20 @@ export function defaultAudience(known: Set<string>): Record<AnnouncementKind, st
70
70
  export async function adminAnnouncementsState(repo: ContentRepository, url: URL) {
71
71
  const now = Date.now();
72
72
  const settings = await repo.getSettings();
73
- const [announcements, topics, known, stats, links, sentThisWeek] = await Promise.all([
73
+ const [announcements, topics, known, stats, links, sentThisWeek, changes] = await Promise.all([
74
74
  repo.announcements.list(),
75
75
  PUSH.enabled ? pushTopics(repo) : Promise.resolve([]),
76
76
  PUSH.enabled ? knownTopics(repo) : Promise.resolve(new Set<string>()),
77
77
  PUSH.enabled ? repo.push.stats(now) : Promise.resolve(null),
78
78
  linkTargets(repo),
79
79
  PUSH.enabled ? repo.push.countSent(['manual', 'announcement'], now - 7 * DAY) : Promise.resolve(0),
80
+ PUSH.enabled ? repo.push.sentSince('change', now - 2 * HOUR) : Promise.resolve([]),
80
81
  ]);
81
82
  const main = I18N.enabled ? I18N.defaultLocale : (settings.locale || 'en');
83
+ const origin = siteOriginOf(settings, url.origin);
82
84
  return {
83
85
  now,
84
- site: { name: settings.siteTitle, icon: settings.siteFavicon, origin: siteOriginOf(settings, url.origin), timezone: settings.timezone || 'UTC' },
86
+ site: { name: settings.siteTitle, icon: settings.siteFavicon, origin, timezone: settings.timezone || 'UTC' },
85
87
  languages: { main: { code: main, name: localeName(main) }, extra: extraLanguages().map((code) => ({ code, name: localeName(code) })) },
86
88
  kinds: ANNOUNCEMENT_KINDS.map((id) => ({ id, label: announcementStrings('en').kinds[id] })),
87
89
  limits: LIMITS,
@@ -98,6 +100,8 @@ export async function adminAnnouncementsState(repo: ContentRepository, url: URL)
98
100
  // the push audience each kind starts with in the composer (['*'] = everyone)
99
101
  defaultAudience: defaultAudience(known),
100
102
  links,
103
+ // automatic event-change notices of the last 2 hours: a push whose button opens the same event repeats one
104
+ recentChanges: changes.map((n) => ({ path: sitePath(n.url, origin), at: n.createdAt, title: n.title })).filter((c) => c.path),
101
105
  announcements,
102
106
  };
103
107
  }
@@ -68,6 +68,10 @@ export class AnnouncementStore {
68
68
  async due(now: number, limit = 10): Promise<Announcement[]> {
69
69
  return (await this.all(`SELECT * FROM announcements WHERE status='scheduled' AND COALESCE(publish_at,0)<=? ORDER BY publish_at LIMIT ?`, [now, limit])).map(announcement);
70
70
  }
71
+ /** Ones that went up with a push since `since` (an event-change notice about the same event is skipped). */
72
+ async pushedSince(since: number): Promise<Announcement[]> {
73
+ return (await this.all(`SELECT * FROM announcements WHERE notification_id IS NOT NULL AND published_at>=? ORDER BY published_at DESC LIMIT 50`, [since])).map(announcement);
74
+ }
71
75
  /** Published ones past their end: they come down. */
72
76
  async endExpired(now: number): Promise<number> {
73
77
  return (await this.all(`UPDATE announcements SET status='ended', updated_at=? WHERE status='published' AND show_until IS NOT NULL AND show_until<=? RETURNING id`, [now, now])).length;
@@ -31,8 +31,8 @@ function privatePrefixes() {
31
31
  ...(CALENDAR.enabled ? [CALENDAR.bookBase] : []),
32
32
  ];
33
33
  }
34
- // Public app API routes that are personal, transactional or write-only.
35
- const PRIVATE_API = /^\/api\/v1\/(?:admin|auth|guest|bookings|availability|devices|push|forms)(?:\/|$)/;
34
+ // Public app API routes that are personal, transactional, live or write-only.
35
+ const PRIVATE_API = /^\/api\/v1\/(?:admin|auth|guest|bookings|availability|devices|push|forms|events\/[^/]+\/rsvp)(?:\/|$)/;
36
36
 
37
37
  const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}.`);
38
38
 
@@ -1,10 +1,12 @@
1
1
  // Side effects when an event changes live: a 301 when a published event's
2
- // slug moves, and the matching webhook (event.published / event.updated /
2
+ // slug moves, a queued "event changed" app notice when its schedule or place
3
+ // moves, and the matching webhook (event.published / event.updated /
3
4
  // event.unpublished). Shared by the editor actions, the list's bulk actions
4
5
  // and the agent tools so every path behaves the same.
5
6
  import type { ContentRepository } from '../repo/types';
6
7
  import type { CalEvent } from '../schema';
7
8
  import { emitWebhook } from '../webhooks';
9
+ import { queueEventChange } from '../push/service';
8
10
  import { eventToJson } from './api';
9
11
  import { eventUrl } from './config';
10
12
 
@@ -18,6 +20,8 @@ export async function eventChanged(
18
20
  await repo.createRedirect({ fromPath: eventUrl(before.slug), toPath: eventUrl(after.slug) }).catch(() => {});
19
21
  opts.onRedirect?.();
20
22
  }
23
+ // the scheduler sends it once the edits settle, if the rule still applies
24
+ await queueEventChange(repo, before, after).catch((e) => console.error('acms push: event change not queued', e));
21
25
  let type: 'event.published' | 'event.updated' | 'event.unpublished' | null = null;
22
26
  if (before.status !== 'published' && after.status === 'published') type = 'event.published';
23
27
  else if (before.status === 'published' && after.status !== 'published') type = 'event.unpublished';
@@ -10,15 +10,17 @@ export interface AppPrivacyAnswers { rows: StoreRow[]; tracking: string; notes:
10
10
 
11
11
  export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacyAnswers {
12
12
  const rows: StoreRow[] = [];
13
- const contact = [...(f.bookings ? ['bookings'] : []), ...(f.appForms ? ['forms'] : [])];
13
+ // apps can RSVP wherever the calendar is on; room bookings need bookings too
14
+ const contact = [...(f.bookings ? ['bookings'] : []), ...(f.calendar ? ['RSVPs'] : []), ...(f.appForms ? ['forms'] : [])];
15
+ const sends = contact.length > 1 ? `${contact.slice(0, -1).join(', ')} or ${contact.at(-1)}` : contact[0];
14
16
  if (contact.length) {
15
17
  rows.push({
16
- data: 'Name, email address, phone number', when: `When someone sends ${contact.join(' or ')} from the app`,
18
+ data: 'Name, email address, phone number', when: `When someone sends ${sends} from the app`,
17
19
  apple: 'Contact Info → Name, Email Address, Phone Number. Purpose: App Functionality. Linked to the user. Not used for tracking.',
18
20
  google: 'Personal info → Name, Email address, Phone number. Collected, not shared. Purpose: App functionality. Optional (people choose to send it).',
19
21
  });
20
22
  rows.push({
21
- data: 'Answers typed into a form or booking question', when: `When someone sends ${contact.join(' or ')} from the app`,
23
+ data: 'Answers typed into a form or booking question', when: `When someone sends ${sends} from the app`,
22
24
  apple: 'User Content → Other User Content. Purpose: App Functionality. Linked to the user. Not used for tracking.',
23
25
  google: 'App activity → Other user-generated content. Collected, not shared. Purpose: App functionality. Optional.',
24
26
  });
@@ -82,6 +82,7 @@ export function privacyEn(f: LegalFacts, country: string): PolicySection[] {
82
82
  ...(app ? [f.announcements
83
83
  ? 'Our app may keep the content it last loaded on your phone so it works offline, and remembers there your notification choice and which announcements you’ve read or closed. That stays on your phone and is never sent to us.'
84
84
  : 'Our app may keep the content it last loaded on your phone so it works offline, and remembers your notification choice there.'] : []),
85
+ ...(app && f.calendar ? ['When you RSVP or book in the app, it keeps the private key that lets you see or cancel it in your phone’s secure storage, like the private link we email you.'] : []),
85
86
  ] });
86
87
  s.push({ id: 'sharing', title: 'Who receives your information', text: [
87
88
  `We don’t sell your personal information${pixels ? '' : ' or share it for advertising'}. These service providers process it on our behalf and only for the purposes above: ${list(processors(f))}. They must protect it at least as this notice describes. Some of them may store it outside your country, including in the United States.`,
@@ -83,6 +83,7 @@ export function privacyEs(f: LegalFacts, country: string): PolicySection[] {
83
83
  ...(app ? [f.announcements
84
84
  ? 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones y qué anuncios leyó o cerró. Eso se queda en su teléfono y nunca se nos envía.'
85
85
  : 'Nuestra app puede guardar en su teléfono el contenido que cargó por última vez para funcionar sin conexión, y recuerda ahí su preferencia de notificaciones.'] : []),
86
+ ...(app && f.calendar ? ['Cuando confirma su asistencia o reserva en la app, esta guarda en el almacenamiento seguro de su teléfono la clave privada que le permite verla o cancelarla, igual que el enlace privado que le enviamos por correo.'] : []),
86
87
  ] });
87
88
  s.push({ id: 'sharing', title: 'Quién recibe sus datos', text: [
88
89
  `No vendemos sus datos personales${pixels ? '' : ' ni los compartimos con fines publicitarios'}. Estos proveedores los tratan por cuenta nuestra y solo para los fines descritos: ${list(processors(f))}. Deben protegerlos al menos como lo describe este aviso. Algunos pueden guardarlos fuera de su país, incluido Estados Unidos.`,
@@ -1,8 +1,9 @@
1
1
  import type { PublicLiveSnapshot } from './types';
2
+ import { fill, liveStrings, type LiveStrings } from './words';
2
3
 
3
4
  // Display text for a live snapshot. Pure and DOM-free: LiveCard renders it on
4
5
  // the server and the browser re-applies it on every status change, so both
5
- // always agree.
6
+ // always agree. Both pass the page's words and language (English by default).
6
7
 
7
8
  export const PROVIDER_NAMES: Record<string, string> = { youtube: 'YouTube', twitch: 'Twitch', facebook: 'Facebook', other: '' };
8
9
 
@@ -23,6 +24,7 @@ export interface LiveView {
23
24
  }
24
25
 
25
26
  const MIN = 60_000, HOUR = 60 * MIN, DAY = 24 * HOUR;
27
+ const EN = liveStrings('en');
26
28
 
27
29
  export function compactCount(n: number): string {
28
30
  if (n < 1000) return String(n);
@@ -30,49 +32,51 @@ export function compactCount(n: number): string {
30
32
  return `${v < 10 ? Math.floor(v * 10) / 10 : Math.floor(v)}${unit}`;
31
33
  }
32
34
 
33
- export function ago(ms: number): string {
34
- if (ms < MIN) return 'just now';
35
- if (ms < HOUR) return `${Math.floor(ms / MIN)} min ago`;
35
+ export function ago(ms: number, w: LiveStrings = EN): string {
36
+ if (ms < MIN) return w.justNow;
37
+ if (ms < HOUR) return fill(w.ago, { time: `${Math.floor(ms / MIN)} min` });
36
38
  const h = Math.floor(ms / HOUR), m = Math.floor((ms % HOUR) / MIN);
37
- return m ? `${h} h ${m} min ago` : `${h} h ago`;
39
+ return fill(w.ago, { time: m ? `${h} h ${m} min` : `${h} h` });
38
40
  }
39
41
 
40
- export function until(ms: number): string {
41
- if (ms < MIN) return 'starting now';
42
- if (ms < HOUR) return `in ${Math.ceil(ms / MIN)} min`;
43
- if (ms < DAY) return `in ${Math.round(ms / HOUR)} h`;
42
+ export function until(ms: number, w: LiveStrings = EN): string {
43
+ if (ms < MIN) return w.startingNow;
44
+ if (ms < HOUR) return fill(w.in, { time: `${Math.ceil(ms / MIN)} min` });
45
+ if (ms < DAY) return fill(w.in, { time: `${Math.round(ms / HOUR)} h` });
44
46
  const d = Math.round(ms / DAY);
45
- return `in ${d} day${d === 1 ? '' : 's'}`;
47
+ return fill(w.in, { time: fill(d === 1 ? w.day : w.days, { n: String(d) }) });
46
48
  }
47
49
 
48
- export function when(ts: number, now: number): string {
49
- const d = new Date(ts);
50
- const time = d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
51
- if (ts - now < 6 * DAY) return `${d.toLocaleDateString(undefined, { weekday: 'short' })} ${time}`;
52
- return `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`;
50
+ // a bad language tag formats like the runtime default rather than failing the page
51
+ export function when(ts: number, now: number, locale?: string): string {
52
+ const format = (o: Intl.DateTimeFormatOptions) => {
53
+ try { return new Intl.DateTimeFormat(locale, o).format(ts); } catch { return new Intl.DateTimeFormat(undefined, o).format(ts); }
54
+ };
55
+ const time = format({ hour: 'numeric', minute: '2-digit' });
56
+ return `${format(ts - now < 6 * DAY ? { weekday: 'short' } : { month: 'short', day: 'numeric' })} ${time}`;
53
57
  }
54
58
 
55
- export function liveView(s: PublicLiveSnapshot | null, now: number): LiveView | null {
59
+ export function liveView(s: PublicLiveSnapshot | null, now: number, w: LiveStrings = EN, locale?: string): LiveView | null {
56
60
  if (!s || (s.status !== 'live' && s.status !== 'upcoming' && s.status !== 'offline')) return null;
57
61
  const name = PROVIDER_NAMES[s.provider] ?? '';
58
62
  const channel = s.channel?.trim() ?? '';
59
63
  const base = { status: s.status, provider: s.provider, title: s.title, watchUrl: s.watchUrl, thumbUrl: s.thumbUrl };
60
64
  if (s.status === 'offline') {
61
- return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel: 'Watch our channel ↗' };
65
+ return { ...base, tag: '', kicker: '', meta: channel, viewers: '', thumbUrl: '', watchLabel: w.watchChannel };
62
66
  }
63
- const watchLabel = name ? `Watch on ${name} ↗` : 'Watch now ↗';
67
+ const watchLabel = name ? fill(w.watchOn, { provider: name }) : w.watchNow;
64
68
  if (s.status === 'upcoming') {
65
- const start = s.startedAt > 0 ? `${when(s.startedAt, now)} · ${until(s.startedAt - now)}` : '';
69
+ const start = s.startedAt > 0 ? `${when(s.startedAt, now, locale)} · ${until(s.startedAt - now, w)}` : '';
66
70
  return {
67
- ...base, tag: start || 'UPCOMING', kicker: name ? `UPCOMING ON ${name.toUpperCase()}` : 'UPCOMING',
68
- meta: [channel, name && 'scheduled'].filter(Boolean).join(' · '), viewers: '', watchLabel: name ? `Open on ${name} ↗` : 'Open ↗',
71
+ ...base, tag: start || w.upcoming, kicker: name ? fill(w.upcomingOn, { provider: name.toUpperCase() }) : w.upcoming,
72
+ meta: [channel, name && w.scheduled].filter(Boolean).join(' · '), viewers: '', watchLabel: name ? fill(w.openOn, { provider: name }) : w.open,
69
73
  };
70
74
  }
71
- const started = s.startedAt > 0 && s.startedAt <= now ? `started ${ago(now - s.startedAt)}` : '';
75
+ const started = s.startedAt > 0 && s.startedAt <= now ? fill(w.started, { time: ago(now - s.startedAt, w) }) : '';
72
76
  return {
73
- ...base, tag: 'LIVE', kicker: name ? `LIVE ON ${name.toUpperCase()}` : 'LIVE NOW',
77
+ ...base, tag: w.tag, kicker: name ? fill(w.liveOn, { provider: name.toUpperCase() }) : w.liveNow,
74
78
  meta: [channel, started].filter(Boolean).join(' · '),
75
- viewers: typeof s.viewers === 'number' ? `${compactCount(s.viewers)} watching` : '',
79
+ viewers: typeof s.viewers === 'number' ? fill(w.watching, { count: compactCount(s.viewers) }) : '',
76
80
  watchLabel,
77
81
  };
78
82
  }
@@ -0,0 +1,94 @@
1
+ // Words the website's live surfaces show: the live card (view.ts, LiveCard)
2
+ // and the site-wide bar, header marker and dock (Site.astro). Plain strings
3
+ // with {provider}, {time}, {count}, {n} and {title} slots, so LiveCard can
4
+ // hand them to its browser script as JSON. Spanish matches the app's words
5
+ // (packages/native strings.ts) where they overlap. Other languages fall back
6
+ // to English.
7
+ export interface LiveStrings {
8
+ /** poster tag while live */
9
+ tag: string;
10
+ /** above the title: without a provider name, then with it (upper-cased) */
11
+ liveNow: string;
12
+ liveOn: string;
13
+ upcoming: string;
14
+ upcomingOn: string;
15
+ watchOn: string;
16
+ watchNow: string;
17
+ openOn: string;
18
+ open: string;
19
+ watchChannel: string;
20
+ /** after the channel name of an upcoming broadcast */
21
+ scheduled: string;
22
+ /** after the channel name while live: "started {time}" with {time} from `ago` */
23
+ started: string;
24
+ justNow: string;
25
+ ago: string;
26
+ startingNow: string;
27
+ in: string;
28
+ day: string;
29
+ days: string;
30
+ watching: string;
31
+ notLive: string;
32
+ /** the card's label and the player's title when the stream has none */
33
+ video: string;
34
+ playHere: string;
35
+ play: string;
36
+ /** under the in-page player button; {provider} falls back to `platform` */
37
+ privacy: string;
38
+ platform: string;
39
+ /** site-wide signals */
40
+ bar: string;
41
+ watch: string;
42
+ dismissBar: string;
43
+ marker: string;
44
+ dismissDock: string;
45
+ }
46
+
47
+ const STRINGS: Record<string, LiveStrings> = {
48
+ en: {
49
+ tag: 'LIVE', liveNow: 'LIVE NOW', liveOn: 'LIVE ON {provider}', upcoming: 'UPCOMING', upcomingOn: 'UPCOMING ON {provider}',
50
+ watchOn: 'Watch on {provider} ↗', watchNow: 'Watch now ↗', openOn: 'Open on {provider} ↗', open: 'Open ↗', watchChannel: 'Watch our channel ↗',
51
+ scheduled: 'scheduled', started: 'started {time}', justNow: 'just now', ago: '{time} ago', startingNow: 'starting now', in: 'in {time}', day: '{n} day', days: '{n} days',
52
+ watching: '{count} watching', notLive: 'Not live right now', video: 'Live video', playHere: 'Play here', play: 'Play {title} here',
53
+ privacy: 'Playing loads video from {provider}.', platform: 'the platform',
54
+ bar: 'Live now', watch: 'Watch →', dismissBar: 'Dismiss live notice', marker: 'Live', dismissDock: 'Dismiss live player',
55
+ },
56
+ es: {
57
+ tag: 'EN VIVO', liveNow: 'EN VIVO AHORA', liveOn: 'EN VIVO EN {provider}', upcoming: 'PRÓXIMAMENTE', upcomingOn: 'PRÓXIMAMENTE EN {provider}',
58
+ watchOn: 'Ver en {provider} ↗', watchNow: 'Ver ahora ↗', openOn: 'Abrir en {provider} ↗', open: 'Abrir ↗', watchChannel: 'Ver nuestro canal ↗',
59
+ scheduled: 'programado', started: 'empezó {time}', justNow: 'hace un momento', ago: 'hace {time}', startingNow: 'empieza ahora', in: 'en {time}', day: '{n} día', days: '{n} días',
60
+ watching: '{count} viendo', notLive: 'No estamos en vivo ahora', video: 'Video en vivo', playHere: 'Reproducir aquí', play: 'Reproducir {title} aquí',
61
+ privacy: 'Al reproducir, el video se carga desde {provider}.', platform: 'la plataforma',
62
+ bar: 'En vivo', watch: 'Ver →', dismissBar: 'Cerrar aviso', marker: 'En vivo', dismissDock: 'Cerrar reproductor',
63
+ },
64
+ fr: {
65
+ tag: 'EN DIRECT', liveNow: 'EN DIRECT', liveOn: 'EN DIRECT SUR {provider}', upcoming: 'À VENIR', upcomingOn: 'BIENTÔT SUR {provider}',
66
+ watchOn: 'Regarder sur {provider} ↗', watchNow: 'Regarder ↗', openOn: 'Ouvrir sur {provider} ↗', open: 'Ouvrir ↗', watchChannel: 'Voir notre chaîne ↗',
67
+ scheduled: 'programmé', started: 'a commencé {time}', justNow: 'à l’instant', ago: 'il y a {time}', startingNow: 'commence maintenant', in: 'dans {time}', day: '{n} jour', days: '{n} jours',
68
+ watching: '{count} spectateurs', notLive: 'Pas en direct pour le moment', video: 'Vidéo en direct', playHere: 'Lire ici', play: 'Lire {title} ici',
69
+ privacy: 'La lecture charge la vidéo depuis {provider}.', platform: 'la plateforme',
70
+ bar: 'En direct', watch: 'Regarder →', dismissBar: 'Fermer l’avis', marker: 'En direct', dismissDock: 'Fermer le lecteur',
71
+ },
72
+ de: {
73
+ tag: 'LIVE', liveNow: 'JETZT LIVE', liveOn: 'LIVE AUF {provider}', upcoming: 'DEMNÄCHST', upcomingOn: 'DEMNÄCHST AUF {provider}',
74
+ watchOn: 'Auf {provider} ansehen ↗', watchNow: 'Jetzt ansehen ↗', openOn: 'Auf {provider} öffnen ↗', open: 'Öffnen ↗', watchChannel: 'Zu unserem Kanal ↗',
75
+ scheduled: 'geplant', started: 'begann {time}', justNow: 'gerade eben', ago: 'vor {time}', startingNow: 'beginnt jetzt', in: 'in {time}', day: '{n} Tag', days: '{n} Tagen',
76
+ watching: '{count} Zuschauer', notLive: 'Gerade nicht live', video: 'Livevideo', playHere: 'Hier abspielen', play: '{title} hier abspielen',
77
+ privacy: 'Beim Abspielen wird das Video von {provider} geladen.', platform: 'der Plattform',
78
+ bar: 'Jetzt live', watch: 'Ansehen →', dismissBar: 'Hinweis schließen', marker: 'Live', dismissDock: 'Player schließen',
79
+ },
80
+ pt: {
81
+ tag: 'AO VIVO', liveNow: 'AO VIVO AGORA', liveOn: 'AO VIVO NO {provider}', upcoming: 'EM BREVE', upcomingOn: 'EM BREVE NO {provider}',
82
+ watchOn: 'Assistir no {provider} ↗', watchNow: 'Assistir agora ↗', openOn: 'Abrir no {provider} ↗', open: 'Abrir ↗', watchChannel: 'Ver nosso canal ↗',
83
+ scheduled: 'agendado', started: 'começou {time}', justNow: 'agora mesmo', ago: 'há {time}', startingNow: 'começando agora', in: 'em {time}', day: '{n} dia', days: '{n} dias',
84
+ watching: '{count} assistindo', notLive: 'Não estamos ao vivo agora', video: 'Vídeo ao vivo', playHere: 'Reproduzir aqui', play: 'Reproduzir {title} aqui',
85
+ privacy: 'Ao reproduzir, o vídeo é carregado de {provider}.', platform: 'uma plataforma externa',
86
+ bar: 'Ao vivo', watch: 'Assistir →', dismissBar: 'Fechar aviso', marker: 'Ao vivo', dismissDock: 'Fechar player',
87
+ },
88
+ };
89
+
90
+ export const liveStrings = (locale: string | null | undefined): LiveStrings =>
91
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;
92
+
93
+ /** "Watch on {provider}" → "Watch on YouTube"; unknown slots stay as written */
94
+ export const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (m, k: string) => values[k] ?? m);