@acms-subir/core 0.3.29 → 0.3.31
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 +33 -0
- package/cli/migrations.generated.mjs +9 -1
- package/docs/live-video.md +1 -1
- package/docs/mobile.md +10 -5
- package/integration.mjs +6 -3
- package/package.json +1 -1
- package/src/components/LegalPage.astro +18 -10
- package/src/components/LegalSettings.tsx +152 -0
- package/src/components/LiveManager.tsx +15 -3
- 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/live/service.ts +22 -5
- package/src/lib/live/store.ts +13 -3
- package/src/lib/mobile/schemas.ts +2 -1
- package/src/lib/mobile/site.ts +5 -0
- package/src/lib/push/service.ts +2 -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/pages/api/live/oauth/youtube/callback.ts +8 -11
- package/src/pages/api/live/settings.ts +4 -1
- package/src/styles/legal-settings.css +23 -0
- package/src/virtual.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.31
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
New repository method: `cancelOccurrenceRsvps(eventId, start)`.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
- **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.
|
|
16
|
+
- **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"`.
|
|
17
|
+
- **Settings → Legal** (General settings permission):
|
|
18
|
+
- 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;
|
|
19
|
+
- the owner's own sections for either page, up to 20, translatable into each site language, for retention, other services, sensitive data and consent;
|
|
20
|
+
- 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.
|
|
21
|
+
- `acms({ legal })` values (now with `address` and `country`) are the defaults for empty fields.
|
|
22
|
+
- **"Last updated" is automatic.** The date changes only when the wording changes; `legal.updatedAt` is the earliest date shown.
|
|
23
|
+
- A published `privacy` or `terms` page still replaces the standard text, and now uses its translations on `/es/…`.
|
|
24
|
+
- `/api/v1/site` adds `legal: { privacy, terms }` (absolute URLs, or null) so apps can link to them.
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
## 0.3.30
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- aeaed2c: YouTube **Authorize** (Integrations → Live video) now finishes on sites that use a Clerk development instance. Before, Google's redirect back to `/api/live/oauth/youtube/callback` was always signed out there: development instances set `__client_uat` as `SameSite=Strict`, so the browser drops it from the cross-site return, Clerk answers with a handshake, and the page showed "YouTube authorization didn't finish". The same thing could happen on production when the owner spent more than about a minute on Google's screens and the session token lapsed.
|
|
33
|
+
|
|
34
|
+
The callback no longer checks the sign-in. It attaches Google's code, encrypted, to its one-use pending state for up to two minutes. Only the first return counts. It then sends the owner to `/admin/live?live=finish`, where a lapsed sign-in renews like on any admin page. That page finishes with a same-origin request (the new `finishAuthorize` action on `/api/live/settings`, gated like every other owner action), and only the session that started authorization can finish it. The code never appears in a URL again, and nothing changes in Google's setup: the redirect URI is the same. No migration.
|
|
35
|
+
|
|
3
36
|
## 0.3.29
|
|
4
37
|
|
|
5
38
|
### 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/live-video.md
CHANGED
|
@@ -28,7 +28,7 @@ ACMS obtains an app token with the client-credentials flow and checks Get Stream
|
|
|
28
28
|
4. Save the Client ID and Client Secret in ACMS, then choose **Authorize YouTube**. Approve YouTube read-only access using the correct Google/Brand channel identity.
|
|
29
29
|
5. Confirm the available channel, test, and turn the connection on. Selecting another channel requires a new test. A typed channel ID cannot override the authorized identity.
|
|
30
30
|
|
|
31
|
-
The authorization-code flow uses session-bound, one-use state and PKCE. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
|
|
31
|
+
The authorization-code flow uses session-bound, one-use state and PKCE. Google's redirect back is a cross-site request that may arrive without a usable sign-in (a Clerk development instance withholds its `SameSite=Strict` cookie, and a session token can lapse while the owner is on Google's screens). It doesn't need one: it only attaches the code, encrypted, to its pending state for up to two minutes, then sends the owner to `/admin/live?live=finish`. That page finishes with a same-origin request from the signed-in session that began authorization; no other session can finish it, and the code never appears in a URL again. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
|
|
32
32
|
|
|
33
33
|
External OAuth apps left in **Testing** receive refresh tokens for YouTube scopes that expire after seven days. The UI's Testing/Production choice is owner-reported; ACMS cannot verify the app's publishing status. Follow Google's applicable publishing and verification requirements before relying on a long-running connection. A revoked or expired grant requires reauthorization. See [Google token expiration](https://developers.google.com/identity/protocols/oauth2#expiration).
|
|
34
34
|
|
package/docs/mobile.md
CHANGED
|
@@ -307,10 +307,14 @@ a pre-ticked choice doesn't count. So:
|
|
|
307
307
|
fine once the person has allowed notifications from your explained prompt.
|
|
308
308
|
List the topics they'll get on that screen.
|
|
309
309
|
- Keep a Notifications screen with a switch per topic (`usePushTopics()`) and
|
|
310
|
-
an "Off" (`unregister()`).
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
310
|
+
an "Off" (`unregister()`). Off stays off across restarts (`turnedOff`); don't
|
|
311
|
+
invite people who turned it off. The app must work fully with notifications off.
|
|
312
|
+
- The site's privacy notice (`/privacy`, `/es/privacy`) already describes push,
|
|
313
|
+
forms, bookings and email sign-in for the features that are on. The owner
|
|
314
|
+
completes it in **Settings → Legal**, which also lists the App Privacy and
|
|
315
|
+
Data safety answers. Link to it inside the app: `useSite().data.legal.privacy`
|
|
316
|
+
(and `.terms`). The site stores only the Expo token, platform, language and
|
|
317
|
+
topics, with no name or email.
|
|
314
318
|
|
|
315
319
|
## Editor apps (staff sign-in)
|
|
316
320
|
|
|
@@ -551,7 +555,8 @@ npx eas-cli submit --platform ios # and android
|
|
|
551
555
|
Store checklist: real `ios.bundleIdentifier` / `android.package` (can't change
|
|
552
556
|
after release; doctor warns on `com.example.*`); app name, `icon` (1024×1024)
|
|
553
557
|
and splash in `app.json`; `version` and build numbers bumped; `.env` points at
|
|
554
|
-
the **https** production site; privacy policy URL
|
|
558
|
+
the **https** production site; privacy policy URL (`site.legal.privacy`, also
|
|
559
|
+
linked inside the app) and the store privacy answers from Settings → Legal; screenshots for phone and
|
|
555
560
|
tablet; deep-link config deployed on the site; a production build tested on
|
|
556
561
|
real devices; `acms mobile doctor` clean.
|
|
557
562
|
|
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'],
|
|
@@ -623,7 +624,7 @@ function acmsVitePlugin(userConfig) {
|
|
|
623
624
|
* the environment. Without a provider, MAPS_URL picks photon and
|
|
624
625
|
* MAPS_API_KEY picks geoapify; with neither, maps are off — saved places and
|
|
625
626
|
* pasted map links still work.
|
|
626
|
-
* @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
|
|
627
|
+
* @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, address?: string, country?: string, updatedAt?: string}} [options.legal]
|
|
627
628
|
* Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
|
|
628
629
|
* @param {{provider?: 'builtin' | 'clerk'}} [options.auth] Staff authentication provider. Defaults to builtin.
|
|
629
630
|
* @param {false | {maxAge?: number, staleWhileRevalidate?: number, memory?: boolean}} [options.cache]
|
|
@@ -676,8 +677,10 @@ export default function acms(options = {}) {
|
|
|
676
677
|
const roles = resolveRoles(options.roles, members);
|
|
677
678
|
const permissions = resolvePermissions(options.permissions, roles);
|
|
678
679
|
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');
|
|
680
|
+
const legal = { enabled: true, operatorName: '', contactEmail: '', address: '', country: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
|
|
681
|
+
if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','address','country','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
|
|
682
|
+
legal.country = legal.country.trim().toUpperCase();
|
|
683
|
+
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
684
|
if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
|
|
682
685
|
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
686
|
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
|
+
}
|
|
@@ -167,11 +167,23 @@ export default function LiveManager() {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// Google sent the owner back (?live=finish): the callback parked the code; finish it with this
|
|
171
|
+
// signed-in session (a same-origin POST, so a lapsed sign-in renews on this page first)
|
|
172
|
+
async function finishAuthorize() {
|
|
173
|
+
try {
|
|
174
|
+
adopt(await api<LiveAdminState>({ action: 'finishAuthorize', provider: 'youtube' }));
|
|
175
|
+
setPageMsg({ ok: 'YouTube is authorized. Confirm your channel below, then test the connection.', err: '' });
|
|
176
|
+
} catch (e) {
|
|
177
|
+
setPageMsg({ ok: '', err: `YouTube authorization didn’t finish: ${errText(e)} Try Authorize again.` });
|
|
178
|
+
await load();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
170
182
|
useEffect(() => {
|
|
171
183
|
const q = new URLSearchParams(location.search);
|
|
172
184
|
const r = q.get('live');
|
|
173
|
-
if (r === '
|
|
174
|
-
setPageMsg({ ok: 'YouTube
|
|
185
|
+
if (r === 'finish') {
|
|
186
|
+
setPageMsg({ ok: 'Finishing YouTube authorization…', err: '' });
|
|
175
187
|
} else if (r === 'error') {
|
|
176
188
|
setPageMsg({ ok: '', err: 'YouTube authorization didn’t finish. Check that the redirect URI in Google matches exactly, then try Authorize again.' });
|
|
177
189
|
}
|
|
@@ -182,7 +194,7 @@ export default function LiveManager() {
|
|
|
182
194
|
const qs = q.toString();
|
|
183
195
|
history.replaceState(history.state, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
|
|
184
196
|
}
|
|
185
|
-
void load();
|
|
197
|
+
void (r === 'finish' ? finishAuthorize() : load());
|
|
186
198
|
const tick = setInterval(() => setNow(Date.now()), 30_000);
|
|
187
199
|
return () => clearInterval(tick);
|
|
188
200
|
}, []);
|
|
@@ -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 };
|