@acms-subir/core 0.3.30 → 0.3.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/cli/migrations.generated.mjs +9 -1
- package/docs/mobile.md +15 -8
- package/integration.mjs +11 -5
- package/package.json +1 -1
- package/src/components/LegalPage.astro +18 -10
- package/src/components/LegalSettings.tsx +152 -0
- package/src/lib/booking/service.ts +16 -1
- package/src/lib/calendar/schedule.ts +5 -1
- package/src/lib/legal/admin.ts +60 -0
- package/src/lib/legal/app-privacy.ts +49 -0
- package/src/lib/legal/config.ts +4 -0
- package/src/lib/legal/facts.ts +91 -0
- package/src/lib/legal/index.ts +34 -23
- package/src/lib/legal/text-en.ts +128 -0
- package/src/lib/legal/text-es.ts +129 -0
- package/src/lib/legal/text.ts +69 -0
- package/src/lib/legal/types.ts +28 -0
- package/src/lib/mobile/config.mjs +18 -2
- package/src/lib/mobile/locale.ts +2 -0
- package/src/lib/mobile/openapi.ts +3 -2
- package/src/lib/mobile/schemas.ts +3 -2
- package/src/lib/mobile/site.ts +8 -1
- package/src/lib/push/api.ts +3 -2
- package/src/lib/push/config.ts +3 -1
- package/src/lib/push/service.ts +34 -12
- package/src/lib/push/types.ts +1 -1
- package/src/lib/repo/migrations.ts +9 -1
- package/src/lib/repo/sql.ts +15 -2
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/schema.ts +4 -0
- package/src/pages/admin/settings.astro +13 -0
- package/src/pages/api/legal.ts +23 -0
- package/src/styles/legal-settings.css +23 -0
- package/src/virtual.d.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.32
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f1c7635: Sites can add their own push topics, and topic names come in the app's language.
|
|
8
|
+
|
|
9
|
+
- **Site topics.** `acms({ mobile: { push: { topics: [{ id: 'help', label: { en: 'Help needed', es: 'Se necesita ayuda' }, description?: … }] } } })`. A single string is used for every language. They're listed after the built-in topics with `kind: 'site'` (and their `description`), devices may choose them, and staff send to them from Admin → Mobile app. Ids are lowercase letters, numbers, `-` and `_`. `news`, `events` and `live` are reserved, and there can be up to 20. Like every topic, they never reach devices that didn't choose them.
|
|
10
|
+
- **Names in the app's language.** `GET /api/v1/push/topics?locale=es` names the built-in topics in that language (News/Noticias, Events/Eventos, Live video/Video en vivo; also French, German and Portuguese) and uses the site's words for its own. Missing words fall back to the main language, and the response says which `locale` it used.
|
|
11
|
+
- **Legal links in the app's language.** `GET /api/v1/site?locale=es` returns `legal.privacy` / `legal.terms` for the Spanish pages (`/es/privacy`), so apps no longer add `/es` themselves.
|
|
12
|
+
|
|
13
|
+
Unknown locales return 400 on both routes, as on other localized routes.
|
|
14
|
+
|
|
15
|
+
## 0.3.31
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 3eaa9a3: Cancelling one date of a repeating event now also cancels that date's RSVPs, the same way cancelling a whole event cancels all of them. Each person gets the "cancelled" email, and no 24-hour reminder goes out for that date. Before, those RSVPs stayed confirmed, nobody was told, and the reminder still went out. RSVPs for the event's other dates aren't touched. Restoring the date doesn't bring the RSVPs back, so people sign up again. RSVPs for a date cancelled before this release get cancelled, with the email, when their reminder would have been due.
|
|
20
|
+
|
|
21
|
+
The "Starting soon" push no longer goes out for a cancelled date of a repeating event. Only events cancelled as a whole were skipped before.
|
|
22
|
+
|
|
23
|
+
New repository method: `cancelOccurrenceRsvps(eventId, start)`.
|
|
24
|
+
|
|
25
|
+
- 5d98d6a: The standard privacy notice and terms (`/privacy`, `/terms`) now describe what each site actually does, in English and Spanish, and the owner can complete them in a new **Settings → Legal** card. That's what Apple's 5.1.1, Google Play's User Data policy and Mexico's LFPDPPP expect, and the app stores need it before submission.
|
|
26
|
+
|
|
27
|
+
- **Built from the features that are on.** Always covered: site visits and rate limiting, sign-in (built-in or Clerk), forms (missing before), embedded content and Google Fonts, connected tools and backups, cookies with their lifetimes, service providers, retention, rights, children, Do Not Track, and how changes are announced. Covered when turned on: bookings and RSVPs (shown whenever the calendar is on, so RSVP-only sites are covered now), email sign-in in the app, the app and push notifications (token, platform, language and member-chosen topics, Expo → APNs/FCM, removal after the push retention period, how to turn them off), the email and maps providers by name, live video, Umami, and Meta/TikTok pixels. With a pixel on, the "no advertising" sentences are dropped and the Do Not Track answer changes.
|
|
28
|
+
- **Spanish.** `/es/privacy` and `/es/terms` are in Spanish (the site's language setting decides on single-language sites). Other languages show English, marked `lang="en"`.
|
|
29
|
+
- **Settings → Legal** (General settings permission):
|
|
30
|
+
- the legal name, contact email, address, country and who answers privacy requests. Country `MX` adds the ARCO wording, the 20-business-day answer and the data protection authority; any country makes the terms name its law;
|
|
31
|
+
- the owner's own sections for either page, up to 20, translatable into each site language, for retention, other services, sensitive data and consent;
|
|
32
|
+
- a summary of what the standard text covers, warnings for missing details, and **App store answers**: how to fill in App Store Connect → App Privacy and Google Play → Data safety for the enabled features, including the in-app deletion expected for email sign-in.
|
|
33
|
+
- `acms({ legal })` values (now with `address` and `country`) are the defaults for empty fields.
|
|
34
|
+
- **"Last updated" is automatic.** The date changes only when the wording changes; `legal.updatedAt` is the earliest date shown.
|
|
35
|
+
- A published `privacy` or `terms` page still replaces the standard text, and now uses its translations on `/es/…`.
|
|
36
|
+
- `/api/v1/site` adds `legal: { privacy, terms }` (absolute URLs, or null) so apps can link to them.
|
|
37
|
+
|
|
38
|
+
Migration 57 adds `settings.legal` (it clears the page cache when saved) and `settings.legal_stamps`. The text describes the framework's behavior; it isn't legal advice, and the pages stay marked as a standard notice, and noindex, until a name and contact email are set.
|
|
39
|
+
|
|
3
40
|
## 0.3.30
|
|
4
41
|
|
|
5
42
|
### Patch Changes
|
|
@@ -1065,11 +1065,19 @@ var MIGRATIONS = [
|
|
|
1065
1065
|
{ version: 56, statements: [
|
|
1066
1066
|
`ALTER TABLE events ADD COLUMN host_ids TEXT NOT NULL DEFAULT '[]'`,
|
|
1067
1067
|
`ALTER TABLE events ADD COLUMN request TEXT`
|
|
1068
|
+
] },
|
|
1069
|
+
// Settings → Legal (Settings.legal): shown on /privacy and /terms, so it
|
|
1070
|
+
// bumps the content version; the automatic "last updated" stamps don't
|
|
1071
|
+
{ version: 57, statements: [
|
|
1072
|
+
`ALTER TABLE settings ADD COLUMN legal TEXT NOT NULL DEFAULT '{}'`,
|
|
1073
|
+
`ALTER TABLE settings ADD COLUMN legal_stamps TEXT NOT NULL DEFAULT '{}'`,
|
|
1074
|
+
`DROP TRIGGER content_version_settings_update`,
|
|
1075
|
+
...contentVersionTriggers("settings", [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"])
|
|
1068
1076
|
] }
|
|
1069
1077
|
];
|
|
1070
1078
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1071
1079
|
...CONTENT_VERSION_COLUMNS,
|
|
1072
|
-
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale"],
|
|
1080
|
+
settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
|
|
1073
1081
|
events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"]
|
|
1074
1082
|
};
|
|
1075
1083
|
var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
package/docs/mobile.md
CHANGED
|
@@ -125,7 +125,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
|
|
|
125
125
|
| `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
|
|
126
126
|
| `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
|
|
127
127
|
| `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
|
|
128
|
-
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications) |
|
|
128
|
+
| `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
|
|
129
129
|
| `useAcms()` | the typed client (e.g. `calendars()`) for your own `useQuery` |
|
|
130
130
|
|
|
131
131
|
Components: `<Sections>`, `<RichText html>`, `<EventDate start end allDay timezone>`,
|
|
@@ -301,16 +301,22 @@ a pre-ticked choice doesn't count. So:
|
|
|
301
301
|
- A new topic (for example `live`) never reaches existing devices by itself.
|
|
302
302
|
To offer it to people who installed the app before it existed, show a
|
|
303
303
|
one-time in-app prompt ("Get notified when we're live?") and call
|
|
304
|
-
`setTopics([...topics, 'live'])` only on yes.
|
|
305
|
-
|
|
304
|
+
`setTopics([...topics, 'live'])` only on yes. `useNewPushTopics({ baseline })`
|
|
305
|
+
lists the topics added since the person last saw the list (pass `baseline`:
|
|
306
|
+
the topic ids your older app version offered) and `markSeen()` remembers
|
|
307
|
+
the answer on the device, so you ask once.
|
|
306
308
|
- Starting topics for new installs (`usePushRegistration({ topics })`) are
|
|
307
309
|
fine once the person has allowed notifications from your explained prompt.
|
|
308
310
|
List the topics they'll get on that screen.
|
|
309
311
|
- Keep a Notifications screen with a switch per topic (`usePushTopics()`) and
|
|
310
|
-
an "Off" (`unregister()`).
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
312
|
+
an "Off" (`unregister()`). Off stays off across restarts (`turnedOff`); don't
|
|
313
|
+
invite people who turned it off. The app must work fully with notifications off.
|
|
314
|
+
- The site's privacy notice (`/privacy`, `/es/privacy`) already describes push,
|
|
315
|
+
forms, bookings and email sign-in for the features that are on. The owner
|
|
316
|
+
completes it in **Settings → Legal**, which also lists the App Privacy and
|
|
317
|
+
Data safety answers. Link to it inside the app: `useSite().data.legal.privacy`
|
|
318
|
+
(and `.terms`). The site stores only the Expo token, platform, language and
|
|
319
|
+
topics, with no name or email.
|
|
314
320
|
|
|
315
321
|
## Editor apps (staff sign-in)
|
|
316
322
|
|
|
@@ -551,7 +557,8 @@ npx eas-cli submit --platform ios # and android
|
|
|
551
557
|
Store checklist: real `ios.bundleIdentifier` / `android.package` (can't change
|
|
552
558
|
after release; doctor warns on `com.example.*`); app name, `icon` (1024×1024)
|
|
553
559
|
and splash in `app.json`; `version` and build numbers bumped; `.env` points at
|
|
554
|
-
the **https** production site; privacy policy URL
|
|
560
|
+
the **https** production site; privacy policy URL (`site.legal.privacy`, also
|
|
561
|
+
linked inside the app) and the store privacy answers from Settings → Legal; screenshots for phone and
|
|
555
562
|
tablet; deep-link config deployed on the site; a production build tested on
|
|
556
563
|
real devices; `acms mobile doctor` clean.
|
|
557
564
|
|
package/integration.mjs
CHANGED
|
@@ -73,6 +73,7 @@ const ROUTES = [
|
|
|
73
73
|
['/api/ops/status', 'api/ops/status.ts'],
|
|
74
74
|
['/api/health', 'api/health.ts'],
|
|
75
75
|
['/api/monitoring', 'api/monitoring.ts'],
|
|
76
|
+
['/api/legal', 'api/legal.ts'],
|
|
76
77
|
['/admin/ai', 'admin/ai.astro'],
|
|
77
78
|
['/admin/import', 'admin/import.astro'],
|
|
78
79
|
['/admin/tags', 'admin/tags.astro'],
|
|
@@ -594,10 +595,13 @@ function acmsVitePlugin(userConfig) {
|
|
|
594
595
|
* Home page content per language is authored in /admin as a normal page
|
|
595
596
|
* marked "home" with that language selected. Omit for monolingual sites —
|
|
596
597
|
* nothing changes.
|
|
597
|
-
* @param {{ios?: {teamId: string, bundleId: string, paths?: string[]}, android?: {package: string, sha256CertFingerprints: string[]}, push?: boolean | {enabled?: boolean, retentionDays?: number}, editor?: {redirectUris: string[], name?: string, accessTokenMinutes?: number, refreshTokenDays?: number}}} [options.mobile]
|
|
598
|
+
* @param {{ios?: {teamId: string, bundleId: string, paths?: string[]}, android?: {package: string, sha256CertFingerprints: string[]}, push?: boolean | {enabled?: boolean, retentionDays?: number, topics?: Array<{id: string, label: string | Record<string, string>, description?: string | Record<string, string>}>}, editor?: {redirectUris: string[], name?: string, accessTokenMinutes?: number, refreshTokenDays?: number}}} [options.mobile]
|
|
598
599
|
* Optional iOS Universal Links and Android App Links associations, and push
|
|
599
600
|
* notifications for the site's own Expo apps (`push: true`; off by default):
|
|
600
|
-
* /api/v1/devices, /api/v1/push/topics and Admin → Mobile app. `
|
|
601
|
+
* /api/v1/devices, /api/v1/push/topics and Admin → Mobile app. `topics` adds
|
|
602
|
+
* the site's own topics (e.g. `{ id: 'help', label: { en: 'Help needed', es:
|
|
603
|
+
* 'Se necesita ayuda' } }`): apps offer them, staff send to them; they never
|
|
604
|
+
* reach devices that didn't choose them. `editor:
|
|
601
605
|
* { redirectUris, name? }` turns on editor apps: staff sign in through the
|
|
602
606
|
* browser (/admin/app-auth, OAuth 2.0 + PKCE; only the listed redirect URIs
|
|
603
607
|
* receive codes) and edit through the bearer-token /api/v1/admin/* API.
|
|
@@ -623,7 +627,7 @@ function acmsVitePlugin(userConfig) {
|
|
|
623
627
|
* the environment. Without a provider, MAPS_URL picks photon and
|
|
624
628
|
* MAPS_API_KEY picks geoapify; with neither, maps are off — saved places and
|
|
625
629
|
* pasted map links still work.
|
|
626
|
-
* @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
|
|
630
|
+
* @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, address?: string, country?: string, updatedAt?: string}} [options.legal]
|
|
627
631
|
* Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
|
|
628
632
|
* @param {{provider?: 'builtin' | 'clerk'}} [options.auth] Staff authentication provider. Defaults to builtin.
|
|
629
633
|
* @param {false | {maxAge?: number, staleWhileRevalidate?: number, memory?: boolean}} [options.cache]
|
|
@@ -676,8 +680,10 @@ export default function acms(options = {}) {
|
|
|
676
680
|
const roles = resolveRoles(options.roles, members);
|
|
677
681
|
const permissions = resolvePermissions(options.permissions, roles);
|
|
678
682
|
const maps = resolveMaps(options.maps);
|
|
679
|
-
const legal = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
|
|
680
|
-
if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
|
|
683
|
+
const legal = { enabled: true, operatorName: '', contactEmail: '', address: '', country: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
|
|
684
|
+
if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','address','country','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
|
|
685
|
+
legal.country = legal.country.trim().toUpperCase();
|
|
686
|
+
if (legal.country && !/^[A-Z]{2}$/.test(legal.country)) throw new Error('acms: legal.country must be a two-letter country code such as MX or US');
|
|
681
687
|
if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
|
|
682
688
|
if (legal.updatedAt && (!/^\d{4}-\d{2}-\d{2}$/.test(legal.updatedAt) || !Number.isFinite(Date.parse(legal.updatedAt)) || new Date(legal.updatedAt).toISOString().slice(0,10) !== legal.updatedAt)) throw new Error('acms: legal.updatedAt must be a valid YYYY-MM-DD date');
|
|
683
689
|
const auth = { provider: options.auth?.provider === undefined ? 'builtin' : options.auth.provider };
|
package/package.json
CHANGED
|
@@ -2,26 +2,34 @@
|
|
|
2
2
|
import Site from '../layouts/Site.astro';
|
|
3
3
|
import WidgetRenderer from './WidgetRenderer.astro';
|
|
4
4
|
import { getRepo } from '../lib/repo';
|
|
5
|
-
import {
|
|
5
|
+
import { LEGAL, LEGAL_LANGUAGES, legalPolicy, policyLocale } from '../lib/legal';
|
|
6
6
|
import { expandDynamicBlocks } from '../lib/dynamicBlocks';
|
|
7
|
+
import { requestLocale } from '../lib/i18n';
|
|
8
|
+
import { localizeDoc } from '../lib/translations';
|
|
7
9
|
interface Props { policy: 'privacy' | 'terms' }
|
|
8
10
|
const { policy } = Astro.props;
|
|
9
11
|
const repo = await getRepo(Astro.locals);
|
|
10
12
|
const [settings, page] = await Promise.all([repo.getSettings(), repo.getPageBySlug(policy)]);
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
+
const locale = policyLocale(requestLocale(Astro.url, Astro.locals), settings);
|
|
14
|
+
// a published page with this slug replaces the standard text, in each language it's translated into
|
|
15
|
+
const custom = page?.status === 'published' ? localizeDoc(page, requestLocale(Astro.url, Astro.locals)) : null;
|
|
16
|
+
const standard = custom ? null : await legalPolicy(repo, policy, { locale, locals: Astro.locals });
|
|
13
17
|
const html = custom?.bodyHtml ? await expandDynamicBlocks(custom.bodyHtml, repo) : '';
|
|
18
|
+
const t = standard ? LEGAL_LANGUAGES[standard.lang] : null;
|
|
19
|
+
const updated = standard?.updated ? new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(`${standard.updated}T12:00:00Z`)) : '';
|
|
20
|
+
const contactEmail = settings.legal.contactEmail || LEGAL.contactEmail;
|
|
21
|
+
const mailto = contactEmail ? `mailto:${contactEmail}` : '';
|
|
14
22
|
---
|
|
15
|
-
<Site title={custom?.title || standard
|
|
23
|
+
<Site title={custom?.title || standard!.title} noindex={!custom && !standard!.complete}>
|
|
16
24
|
{custom ? (
|
|
17
25
|
html ? <article class="page-body" set:html={html} /> : <WidgetRenderer pageId={custom.id} blocks={custom.blocks} />
|
|
18
26
|
) : (
|
|
19
|
-
<article class="page-body">
|
|
20
|
-
<h1>{standard
|
|
21
|
-
{
|
|
22
|
-
{!standard
|
|
23
|
-
{standard
|
|
24
|
-
{
|
|
27
|
+
<article class="page-body" lang={standard!.lang}>
|
|
28
|
+
<h1>{standard!.title}</h1>
|
|
29
|
+
{updated && <p>{t!.updated}: <time datetime={standard!.updated}>{updated}</time></p>}
|
|
30
|
+
{!standard!.complete && <p><strong>{t!.incomplete}</strong></p>}
|
|
31
|
+
{standard!.sections.map(section => <section><h2>{section.title}</h2>{section.text.map(p => <p>{p}</p>)}</section>)}
|
|
32
|
+
{mailto && <p><a href={mailto}>{t!.contact}</a></p>}
|
|
25
33
|
</article>
|
|
26
34
|
)}
|
|
27
35
|
</Site>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { LegalAdminState } from '../lib/legal/admin';
|
|
3
|
+
import type { LegalSection, LegalSettings as Legal } from '../lib/legal/types';
|
|
4
|
+
import '../styles/legal-settings.css';
|
|
5
|
+
|
|
6
|
+
// Settings → Legal: who operates the site and the owner's own sections of the
|
|
7
|
+
// privacy notice and terms (translatable), what the standard text already
|
|
8
|
+
// covers, and the answers for the App Store and Google Play privacy forms.
|
|
9
|
+
|
|
10
|
+
const ENDPOINT = '/api/legal';
|
|
11
|
+
async function api(body?: Legal): Promise<LegalAdminState> {
|
|
12
|
+
const res = await fetch(ENDPOINT, {
|
|
13
|
+
method: body ? 'POST' : 'GET', credentials: 'same-origin',
|
|
14
|
+
headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
|
|
15
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
16
|
+
});
|
|
17
|
+
const data = await res.json().catch(() => null);
|
|
18
|
+
if (!res.ok) throw new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
|
|
19
|
+
return data as LegalAdminState;
|
|
20
|
+
}
|
|
21
|
+
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
22
|
+
const newId = () => Math.random().toString(36).slice(2, 10);
|
|
23
|
+
const longDate = (d: string) => new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, { dateStyle: 'long', timeZone: 'UTC' });
|
|
24
|
+
|
|
25
|
+
function covered(c: LegalAdminState['covers']): string[] {
|
|
26
|
+
return [
|
|
27
|
+
'visits and rate limiting', c.auth === 'clerk' ? 'sign-in with Clerk' : 'staff sign-in', 'forms',
|
|
28
|
+
...(c.calendar ? [c.bookings ? 'bookings and RSVPs' : 'event RSVPs'] : []),
|
|
29
|
+
...(c.guestSignIn ? ['email-code sign-in in the app'] : []),
|
|
30
|
+
...(c.push ? ['the app and push notifications'] : []),
|
|
31
|
+
...(c.email ? [`email (${c.email})`] : []), ...(c.maps ? [`maps (${c.maps})`] : []),
|
|
32
|
+
...(c.live ? ['live video'] : []),
|
|
33
|
+
...(c.analytics.umami ? ['Umami statistics'] : []), ...(c.analytics.meta ? ['Meta Pixel'] : []), ...(c.analytics.tiktok ? ['TikTok Pixel'] : []),
|
|
34
|
+
...(c.ai ? ['AI writing tools'] : []),
|
|
35
|
+
'embedded content and Google Fonts', 'cookies', 'service providers', 'retention', 'rights (including ARCO)', 'children',
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function LegalSettings() {
|
|
40
|
+
const [state, setState] = useState<LegalAdminState | null>(null);
|
|
41
|
+
const [form, setForm] = useState<Legal | null>(null);
|
|
42
|
+
const [error, setError] = useState('');
|
|
43
|
+
const [note, setNote] = useState('');
|
|
44
|
+
const [busy, setBusy] = useState(false);
|
|
45
|
+
useEffect(() => { api().then((s) => { setState(s); setForm(s.legal); }, (e) => setError(errText(e))); }, []);
|
|
46
|
+
if (!state || !form) return error ? <p className="flash error">{error}</p> : <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /></div>;
|
|
47
|
+
|
|
48
|
+
const dirty = JSON.stringify(form) !== JSON.stringify(state.legal);
|
|
49
|
+
const set = (patch: Partial<Legal>) => setForm({ ...form, ...patch });
|
|
50
|
+
const setSection = (i: number, patch: Partial<LegalSection>) => set({ sections: form.sections.map((s, j) => (j === i ? { ...s, ...patch } : s)) });
|
|
51
|
+
const setTranslation = (i: number, code: string, patch: { title?: string; body?: string }) => {
|
|
52
|
+
const s = form.sections[i];
|
|
53
|
+
const t = { title: s.translations[code]?.title ?? '', body: s.translations[code]?.body ?? '', ...patch };
|
|
54
|
+
setSection(i, { translations: { ...s.translations, [code]: t } });
|
|
55
|
+
};
|
|
56
|
+
const move = (i: number, by: number) => {
|
|
57
|
+
const next = [...form.sections];
|
|
58
|
+
const [s] = next.splice(i, 1);
|
|
59
|
+
next.splice(i + by, 0, s);
|
|
60
|
+
set({ sections: next });
|
|
61
|
+
};
|
|
62
|
+
const save = async () => {
|
|
63
|
+
setBusy(true); setError(''); setNote('');
|
|
64
|
+
try { const s = await api(form); setState(s); setForm(s.legal); setNote('Saved. The pages show it now.'); }
|
|
65
|
+
catch (e) { setError(errText(e)); } finally { setBusy(false); }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="lg">
|
|
70
|
+
{!state.enabled && <p className="flash">The standard pages are off (<code>legal: false</code>). Your site serves its own /privacy and /terms.</p>}
|
|
71
|
+
{state.warnings.length > 0 && <ul className="lg-warn">{state.warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
|
|
72
|
+
|
|
73
|
+
<div className="lg-block">
|
|
74
|
+
<h3>Who operates this site</h3>
|
|
75
|
+
<div className="lg-grid">
|
|
76
|
+
<label className="field"><span>Legal name</span>
|
|
77
|
+
<input value={form.operatorName} maxLength={200} placeholder={state.defaults.operatorName} onChange={(e) => set({ operatorName: e.target.value })} />
|
|
78
|
+
<small className="hint">As it appears in your app’s store listing.</small></label>
|
|
79
|
+
<label className="field"><span>Contact email for privacy</span>
|
|
80
|
+
<input type="email" value={form.contactEmail} maxLength={200} placeholder={state.defaults.contactEmail || 'privacy@example.org'} onChange={(e) => set({ contactEmail: e.target.value })} /></label>
|
|
81
|
+
<label className="field lg-wide"><span>Address</span>
|
|
82
|
+
<textarea rows={2} value={form.address} maxLength={500} placeholder={state.defaults.address || 'Street, city, state, postal code, country'} onChange={(e) => set({ address: e.target.value })} /></label>
|
|
83
|
+
<label className="field"><span>Country</span>
|
|
84
|
+
<input value={form.country} maxLength={2} placeholder={state.defaults.country || 'MX, US…'} list="lg-countries" onChange={(e) => set({ country: e.target.value.toUpperCase() })} />
|
|
85
|
+
<datalist id="lg-countries">{['MX', 'US', 'CA', 'ES', 'CO', 'GT', 'SV', 'HN', 'PR', 'DO', 'AR', 'CL', 'PE'].map((c) => <option key={c} value={c} />)}</datalist>
|
|
86
|
+
<small className="hint">Two letters. MX adds the ARCO and authority wording.</small></label>
|
|
87
|
+
<label className="field"><span>Who answers privacy requests</span>
|
|
88
|
+
<input value={form.privacyContact} maxLength={200} placeholder="e.g. The church office" onChange={(e) => set({ privacyContact: e.target.value })} /></label>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className="lg-block">
|
|
93
|
+
<h3>Already covered</h3>
|
|
94
|
+
<p className="hint">The standard text describes what this site does, in English and Spanish, and updates itself when you turn features on or off: {covered(state.covers).join(', ')}.</p>
|
|
95
|
+
<p className="lg-links">
|
|
96
|
+
{state.links.privacy && <a href={state.links.privacy} target="_blank" rel="noopener">Privacy notice ↗</a>}
|
|
97
|
+
{state.links.terms && <a href={state.links.terms} target="_blank" rel="noopener">Terms ↗</a>}
|
|
98
|
+
{state.updated && <span className="hint">Last updated {longDate(state.updated.privacy)} (privacy) · {longDate(state.updated.terms)} (terms)</span>}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div className="lg-block">
|
|
103
|
+
<h3>Your own sections</h3>
|
|
104
|
+
<p className="hint">For what the site can’t know: how long you keep records, how to reach you for privacy requests, other services you use, sensitive information you collect and how you ask for consent. Separate paragraphs with a blank line.</p>
|
|
105
|
+
{form.sections.map((s, i) => (
|
|
106
|
+
<fieldset key={s.id} className="lg-section">
|
|
107
|
+
<div className="lg-row">
|
|
108
|
+
<select value={s.policy} aria-label="Page" onChange={(e) => setSection(i, { policy: e.target.value as LegalSection['policy'] })}>
|
|
109
|
+
<option value="privacy">Privacy notice</option><option value="terms">Terms</option>
|
|
110
|
+
</select>
|
|
111
|
+
<input className="lg-title" value={s.title} maxLength={120} placeholder="Section title" aria-label="Section title" onChange={(e) => setSection(i, { title: e.target.value })} />
|
|
112
|
+
<button type="button" className="btn ghost" disabled={i === 0} onClick={() => move(i, -1)} aria-label="Move up">↑</button>
|
|
113
|
+
<button type="button" className="btn ghost" disabled={i === form.sections.length - 1} onClick={() => move(i, 1)} aria-label="Move down">↓</button>
|
|
114
|
+
<button type="button" className="btn ghost" onClick={() => set({ sections: form.sections.filter((_, j) => j !== i) })}>Remove</button>
|
|
115
|
+
</div>
|
|
116
|
+
<textarea rows={4} value={s.body} maxLength={6000} aria-label="Section text" onChange={(e) => setSection(i, { body: e.target.value })} />
|
|
117
|
+
{state.languages.extra.map((l) => (
|
|
118
|
+
<details key={l.code} className="lg-tr" open={!!(s.translations[l.code]?.title || s.translations[l.code]?.body)}>
|
|
119
|
+
<summary>{l.name}{!s.translations[l.code]?.body && s.body ? ' (missing: shows the main text)' : ''}</summary>
|
|
120
|
+
<input value={s.translations[l.code]?.title ?? ''} maxLength={120} placeholder={s.title} aria-label={`Title in ${l.name}`} onChange={(e) => setTranslation(i, l.code, { title: e.target.value })} />
|
|
121
|
+
<textarea rows={4} value={s.translations[l.code]?.body ?? ''} maxLength={6000} aria-label={`Text in ${l.name}`} onChange={(e) => setTranslation(i, l.code, { body: e.target.value })} />
|
|
122
|
+
</details>
|
|
123
|
+
))}
|
|
124
|
+
</fieldset>
|
|
125
|
+
))}
|
|
126
|
+
<button type="button" className="btn" disabled={form.sections.length >= 20} onClick={() => set({ sections: [...form.sections, { id: newId(), policy: 'privacy', title: '', body: '', translations: {} }] })}>Add a section</button>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{error && <p className="flash error" role="alert">{error}</p>}
|
|
130
|
+
<div className="form-actions">
|
|
131
|
+
<button type="button" className="btn primary" disabled={!dirty || busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
|
|
132
|
+
{dirty ? <button type="button" className="btn ghost" onClick={() => { setForm(state.legal); setError(''); }}>Discard</button> : note && <span className="hint" role="status">{note}</span>}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div className="lg-block">
|
|
136
|
+
<h3>App store answers</h3>
|
|
137
|
+
<p className="hint">What an app built on this site collects, for App Store Connect → App Privacy and Google Play → Data safety. Review them, and add anything your app’s own code collects.</p>
|
|
138
|
+
<div className="lg-table" role="table" aria-label="App store answers">
|
|
139
|
+
<div role="row" className="lg-th"><span role="columnheader">Data</span><span role="columnheader">Apple</span><span role="columnheader">Google Play</span></div>
|
|
140
|
+
{state.answers.rows.map((r) => (
|
|
141
|
+
<div role="row" key={r.data}>
|
|
142
|
+
<span role="cell"><b>{r.data}</b><small className="hint">{r.when}</small></span>
|
|
143
|
+
<span role="cell">{r.apple}</span><span role="cell">{r.google}</span>
|
|
144
|
+
</div>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
<p><b>Tracking:</b> {state.answers.tracking}</p>
|
|
148
|
+
<ul className="lg-notes">{state.answers.notes.map((n) => <li key={n}>{n}</li>)}</ul>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -335,12 +335,27 @@ export async function rescheduleBooking(repo: ContentRepository, b: Booking, sta
|
|
|
335
335
|
export async function sendDueReminders(repo: ContentRepository, opts: FlowOptions): Promise<number> {
|
|
336
336
|
const now = opts.now ?? Date.now();
|
|
337
337
|
const due = await repo.listDueReminders(now + 60 * 60_000, now + 24 * 3600_000);
|
|
338
|
+
const events = new Map<string, CalEvent | null>();
|
|
339
|
+
let settings: Settings | undefined;
|
|
340
|
+
let queued = 0;
|
|
338
341
|
for (const b of due) {
|
|
342
|
+
// an RSVP for a date cancelled before its RSVPs were cancelled with it
|
|
343
|
+
// (older versions): cancel it now (the person gets that email) instead of reminding
|
|
344
|
+
if (b.kind === 'rsvp' && b.eventId) {
|
|
345
|
+
if (!events.has(b.eventId)) events.set(b.eventId, await repo.getEvent(b.eventId));
|
|
346
|
+
const e = events.get(b.eventId);
|
|
347
|
+
settings ??= await repo.getSettings();
|
|
348
|
+
if (e?.cancelledDates?.length && e.cancelledDates.includes(occurrenceKey(e, b.start, settings))) {
|
|
349
|
+
await repo.cancelOccurrenceRsvps(e.id, b.start);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
queued++;
|
|
339
354
|
await repo.email.enqueue({dedupe:`${b.id}:${b.notificationVersion}:reminder:${b.email}`,kind:'booking',recipient:b.email,
|
|
340
355
|
payload:JSON.stringify({bookingId:b.id,version:b.notificationVersion,kind:'reminder'}),configVersion:(await repo.email.config())?.version||0,expiresAt:b.start});
|
|
341
356
|
}
|
|
342
357
|
await kickEmail(repo,opts.locals,opts.waitUntil);
|
|
343
|
-
return
|
|
358
|
+
return queued;
|
|
344
359
|
}
|
|
345
360
|
|
|
346
361
|
// cron: pending bookings nobody answered within the link's deadline get the
|
|
@@ -15,6 +15,7 @@ import { addWallDays, findOccurrence, formatWall, parseWall } from './events';
|
|
|
15
15
|
import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
|
|
16
16
|
import { eventDeleted } from './hooks';
|
|
17
17
|
import { waitUntilFrom } from '../webhooks';
|
|
18
|
+
import { kickEmail } from '../email/service';
|
|
18
19
|
|
|
19
20
|
export async function uniqueSlug(repo: ContentRepository, title: string) {
|
|
20
21
|
const base = slugify(title) || 'event';
|
|
@@ -167,7 +168,9 @@ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: S
|
|
|
167
168
|
|
|
168
169
|
// ---- cancel one date ----------------------------------------------------------
|
|
169
170
|
// The date stays on the calendar struck through ("Cancelled"), its room holds
|
|
170
|
-
// are released,
|
|
171
|
+
// are released, feeds mark it STATUS:CANCELLED, and its RSVPs are cancelled
|
|
172
|
+
// (each person gets the "cancelled" email; no reminder). Restoring the date
|
|
173
|
+
// leaves those RSVPs cancelled: people sign up again. These do no permission
|
|
171
174
|
// checks: callers (admin actions, site code through @acms-subir/core/server)
|
|
172
175
|
// decide who may do this.
|
|
173
176
|
|
|
@@ -182,6 +185,7 @@ async function setOccurrenceCancelled(repo: ContentRepository, id: string, key:
|
|
|
182
185
|
const has = e.cancelledDates.includes(key);
|
|
183
186
|
if (has === cancel) return e; // already so: nothing to change
|
|
184
187
|
await repo.setEventCancelledDates(id, cancel ? [...e.cancelledDates, key].sort() : e.cancelledDates.filter((x) => x !== key));
|
|
188
|
+
if (cancel && await repo.cancelOccurrenceRsvps(id, o.start)) await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals));
|
|
185
189
|
const after = (await repo.getEvent(id))!;
|
|
186
190
|
await eventSideEffects(repo, hooks, e, after);
|
|
187
191
|
return after;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ContentRepository } from '../repo/types';
|
|
2
|
+
import { I18N, localeName } from '../i18n';
|
|
3
|
+
import { siteOrigin } from '../seo';
|
|
4
|
+
import { LEGAL } from './config';
|
|
5
|
+
import { legalFacts } from './facts';
|
|
6
|
+
import { LEGAL_LANGUAGES, legalLanguage } from './text';
|
|
7
|
+
import { policyUpdated } from './index';
|
|
8
|
+
import { appPrivacyAnswers } from './app-privacy';
|
|
9
|
+
import { LegalSettings } from './types';
|
|
10
|
+
|
|
11
|
+
// Settings → Legal: the owner's details and sections for /privacy and /terms,
|
|
12
|
+
// what the standard text covers, and the app store answers.
|
|
13
|
+
|
|
14
|
+
export async function legalAdminState(repo: ContentRepository, url: URL, locals?: unknown) {
|
|
15
|
+
const [settings, pages] = await Promise.all([repo.getSettings(), repo.listPages()]);
|
|
16
|
+
const facts = await legalFacts(repo, settings, locals);
|
|
17
|
+
const origin = siteOrigin(settings, url);
|
|
18
|
+
const custom = { privacy: pages.some((p) => p.status === 'published' && p.slug === 'privacy'), terms: pages.some((p) => p.status === 'published' && p.slug === 'terms') };
|
|
19
|
+
const extra = I18N.enabled ? I18N.locales.filter((l) => l !== I18N.defaultLocale) : [];
|
|
20
|
+
const siteLanguages = I18N.enabled ? I18N.locales : [settings.locale || 'en'];
|
|
21
|
+
const pixels = facts.analytics.meta || facts.analytics.tiktok;
|
|
22
|
+
const warnings = [
|
|
23
|
+
...(!facts.complete ? ['Add your legal name and a contact email. Until then the pages say they’re a standard notice and ask search engines not to list them.'] : []),
|
|
24
|
+
...(!facts.operator.address ? ['Add your address. Mexico’s data protection law asks for the operator’s domicile, and app stores show the operator’s name.'] : []),
|
|
25
|
+
...(facts.operator.country === 'MX' && !facts.operator.contact ? ['Name the person or department that answers privacy (ARCO) requests.'] : []),
|
|
26
|
+
...(pixels ? ['Meta or TikTok ad pixels are on (Settings → Analytics). The notice says so, but you may also need visitors’ consent before they load, depending on where your visitors are.'] : []),
|
|
27
|
+
...(siteLanguages.filter((l) => !LEGAL_LANGUAGES[legalLanguage(l)] || legalLanguage(l) !== l.split('-')[0].toLowerCase()).map((l) => `The standard text has no ${localeName(l)} version yet; ${localeName(l)} pages show it in English.`)),
|
|
28
|
+
...(custom.privacy ? ['A published page at /privacy replaces the standard privacy notice. Unpublish it to use the text below.'] : []),
|
|
29
|
+
...(custom.terms ? ['A published page at /terms replaces the standard terms. Unpublish it to use the text below.'] : []),
|
|
30
|
+
];
|
|
31
|
+
const link = (slug: 'privacy' | 'terms') => (LEGAL.enabled || custom[slug] ? `${origin}/${slug}` : null);
|
|
32
|
+
return {
|
|
33
|
+
enabled: LEGAL.enabled,
|
|
34
|
+
legal: settings.legal,
|
|
35
|
+
// what each empty field falls back to (acms({ legal }) config, then the site title)
|
|
36
|
+
defaults: { operatorName: LEGAL.operatorName || settings.siteTitle, contactEmail: LEGAL.contactEmail, address: LEGAL.address, country: LEGAL.country },
|
|
37
|
+
languages: { main: I18N.enabled ? I18N.defaultLocale : (settings.locale || 'en'), extra: extra.map((code) => ({ code, name: localeName(code) })) },
|
|
38
|
+
links: { privacy: link('privacy'), terms: link('terms') },
|
|
39
|
+
custom,
|
|
40
|
+
updated: LEGAL.enabled ? { privacy: await policyUpdated(repo, 'privacy', facts, settings), terms: await policyUpdated(repo, 'terms', facts, settings) } : null,
|
|
41
|
+
covers: {
|
|
42
|
+
calendar: facts.calendar, bookings: facts.bookings, guestSignIn: facts.guestSignIn, push: facts.push.enabled, auth: facts.auth,
|
|
43
|
+
email: facts.email, maps: facts.maps, live: facts.live, analytics: facts.analytics, ai: facts.ai,
|
|
44
|
+
},
|
|
45
|
+
warnings,
|
|
46
|
+
answers: appPrivacyAnswers(facts, link('privacy') ?? `${origin}/privacy`),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export type LegalAdminState = Awaited<ReturnType<typeof legalAdminState>>;
|
|
50
|
+
|
|
51
|
+
/** Saves Settings → Legal. Section ids must be unique; translations only for the site's other languages. */
|
|
52
|
+
export async function saveLegal(repo: ContentRepository, raw: unknown) {
|
|
53
|
+
const input = LegalSettings.parse(raw);
|
|
54
|
+
const extra = new Set(I18N.enabled ? I18N.locales.filter((l) => l !== I18N.defaultLocale) : []);
|
|
55
|
+
const seen = new Set<string>();
|
|
56
|
+
const sections = input.sections.filter((s) => !seen.has(s.id) && !!seen.add(s.id)).map((s) => ({
|
|
57
|
+
...s, translations: Object.fromEntries(Object.entries(s.translations).filter(([code, t]) => extra.has(code) && (t.title || t.body))),
|
|
58
|
+
}));
|
|
59
|
+
await repo.updateSettings({ legal: { ...input, sections } });
|
|
60
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { LegalFacts } from './facts';
|
|
2
|
+
|
|
3
|
+
// Settings → Legal → App store answers: what an app built on this site's
|
|
4
|
+
// public API collects, in the words of Apple's App Privacy form (App Store
|
|
5
|
+
// Connect) and Google Play's Data safety form. Only the site's features; the
|
|
6
|
+
// app's own SDKs (crash reporting, analytics) must be added by its developer.
|
|
7
|
+
|
|
8
|
+
export interface StoreRow { data: string; when: string; apple: string; google: string }
|
|
9
|
+
export interface AppPrivacyAnswers { rows: StoreRow[]; tracking: string; notes: string[] }
|
|
10
|
+
|
|
11
|
+
export function appPrivacyAnswers(f: LegalFacts, privacyUrl: string): AppPrivacyAnswers {
|
|
12
|
+
const rows: StoreRow[] = [];
|
|
13
|
+
const contact = [...(f.bookings ? ['bookings'] : []), ...(f.appForms ? ['forms'] : [])];
|
|
14
|
+
if (contact.length) {
|
|
15
|
+
rows.push({
|
|
16
|
+
data: 'Name, email address, phone number', when: `When someone sends ${contact.join(' or ')} from the app`,
|
|
17
|
+
apple: 'Contact Info → Name, Email Address, Phone Number. Purpose: App Functionality. Linked to the user. Not used for tracking.',
|
|
18
|
+
google: 'Personal info → Name, Email address, Phone number. Collected, not shared. Purpose: App functionality. Optional (people choose to send it).',
|
|
19
|
+
});
|
|
20
|
+
rows.push({
|
|
21
|
+
data: 'Answers typed into a form or booking question', when: `When someone sends ${contact.join(' or ')} from the app`,
|
|
22
|
+
apple: 'User Content → Other User Content. Purpose: App Functionality. Linked to the user. Not used for tracking.',
|
|
23
|
+
google: 'App activity → Other user-generated content. Collected, not shared. Purpose: App functionality. Optional.',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (f.guestSignIn) rows.push({
|
|
27
|
+
data: 'Email address (sign-in to see bookings)', when: 'When someone signs in with an emailed code',
|
|
28
|
+
apple: 'Contact Info → Email Address. Purpose: App Functionality. Linked to the user. Not used for tracking.',
|
|
29
|
+
google: 'Personal info → Email address. Collected, not shared. Purpose: App functionality, Account management. Optional.',
|
|
30
|
+
});
|
|
31
|
+
if (f.push.enabled) rows.push({
|
|
32
|
+
data: 'Push token, platform, language and chosen topics', when: 'When someone turns on notifications',
|
|
33
|
+
apple: 'Identifiers → Device ID. Purpose: App Functionality. Linked to the user. Not used for tracking.',
|
|
34
|
+
google: 'Device or other IDs. Collected, not shared (Expo, Apple and Google deliver on your behalf). Purpose: App functionality, Developer communications. Optional.',
|
|
35
|
+
});
|
|
36
|
+
rows.push({
|
|
37
|
+
data: 'IP address', when: 'Every request (rate limiting only)',
|
|
38
|
+
apple: 'Usually not declared: only a scrambled copy is kept, in counters that expire after about a minute. To be cautious, declare Other Data → Other Data Types, App Functionality, not linked.',
|
|
39
|
+
google: 'Usually not declared for the same reason. To be cautious: Device or other IDs, processed ephemerally, App functionality and Fraud prevention, security.',
|
|
40
|
+
});
|
|
41
|
+
const notes = [
|
|
42
|
+
'The site encrypts data in transit (HTTPS): answer “Yes” to “Is all of the user data collected by your app encrypted in transit?” in Google Play, once the site runs on https.',
|
|
43
|
+
`People can ask for their data to be deleted by writing to ${f.operator.email || 'your contact address (set it above)'}${f.push.enabled ? '; turning notifications off removes their device at once' : ''}. Use ${privacyUrl} as the privacy policy URL in both stores, and link to it inside the app too.`,
|
|
44
|
+
'Not used for tracking: the app doesn’t load the website’s analytics or ad pixels. If you add an SDK that tracks (ads, attribution), these answers change.',
|
|
45
|
+
'Add whatever the app itself collects: crash reporting, analytics or update SDKs (for example Sentry or Expo Updates) are not included here.',
|
|
46
|
+
...(f.guestSignIn ? ['Signing in with an emailed code creates an account in the stores’ eyes. Apple (guideline 5.1.1(v)) and Google Play expect a way to delete it inside the app, plus a web link for Google. Offer “Sign out and delete my sign-in” (useGuest().signOut()) and say how to ask for bookings to be deleted.'] : []),
|
|
47
|
+
];
|
|
48
|
+
return { rows, tracking: 'No: neither the site’s app API nor the packaged app components track people across other companies’ apps or websites.', notes };
|
|
49
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { userConfig } from 'virtual:acms-config';
|
|
2
|
+
|
|
3
|
+
// acms({ legal }) config: defaults for Settings → Legal. `legal: false` removes /privacy and /terms.
|
|
4
|
+
export const LEGAL = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', address: '', country: '', ...userConfig.legal };
|