diff --git a/app/Support/Banner.php b/app/Support/Banner.php index 219b44c3..2ccd3f2e 100644 --- a/app/Support/Banner.php +++ b/app/Support/Banner.php @@ -43,8 +43,10 @@ public static function hiddenOnIosDevices(Request $request): bool /** * The shared `banner` prop, or null where the banner is off or not on this * page. Null rather than a flag, so the component renders nothing at all. + * Only the link: the words are the `banner` catalog's, and the banner + * cannot be closed, so there is no version to close it at. * - * @return array{href: string, version: string}|null + * @return array{href: string}|null */ public static function forRequest(Request $request): ?array { @@ -54,7 +56,6 @@ public static function forRequest(Request $request): ?array return [ 'href' => (string) config('banner.href'), - 'version' => (string) config('banner.version'), ]; } diff --git a/config/banner.php b/config/banner.php index 2ae8bffb..89de6339 100644 --- a/config/banner.php +++ b/config/banner.php @@ -12,19 +12,19 @@ | on every public page except Pricing, which is where it leads | (App\Support\Banner). It scrolls away with the page rather than sticking. | - | Config holds the switch, the link and the dismissal version, and nothing - | else. The words live in the `banner` UI catalog, once per language + | Config holds the switch and the link, and nothing else. The words live in + | the `banner` UI catalog, once per language | (resources/js/i18n/messages/{locale}/banner.ts), so the banner reads in | the page's language and its length limits are checked in each | (TopBannerTest). | | It states a fact and never pleads: no "we need your help", no countdown, - | no orange fill. A reader who closes it does not see it for 30 days, and a - | reader who says they have a license, or buys one, not for a year - | (resources/js/lib/banner.ts). + | no orange fill, only a tint of the brand hue (app.css). It cannot be + | closed: there is no close button, no stored record and no version, so + | every reader sees it on every page it belongs on. | | `BANNER_ENABLED=false` removes it from the DOM entirely: no hidden - | element, no reserved height, no dismissal script. + | element, no reserved height. | */ @@ -37,17 +37,4 @@ */ 'href' => env('BANNER_HREF', '/pricing'), - /** - * Bump to show the banner again to readers who closed an earlier one. - * - * A closed banner is stored as `tablepro:banner-dismissed` in - * `localStorage`: this version and the time it stays hidden until. There - * is no session on this domain and the server sets no cookie, so the - * browser is the only place a dismissal can live. Change it whenever the - * catalog wording changes: a reader who closed the old message has not - * read the new one. A license holder's year-long dismissal covers every - * version. - */ - 'version' => env('BANNER_VERSION', '3'), - ]; diff --git a/docs/architecture.md b/docs/architecture.md index 5b1a6aba..1043df2b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -370,12 +370,11 @@ nothing else. An event before the answer is dropped, not queued. Events keep the names the Plausible goals had: `download_click` (`location`, `platform`), `checkout_started` (`tier`, `cycle`) and `newsletter_signup_clicked` (`source`). The license banner adds -`license_banner_view` and `license_banner_click` (`version`) and -`license_banner_dismiss` (`version`, `reason`: `closed` or `licensed`), so a -banner's clicks can be compared with its views, and with `checkout_started`, -per message. GA4 stores those parameters from the -first hit but only shows them in reports once each is registered as an -event-scoped custom dimension under Admin → Custom definitions. Page changes +`license_banner_view` and `license_banner_click`, without parameters, so a +banner's clicks can be compared with its views and with `checkout_started`. +It cannot be closed, so there is no dismiss event. GA4 stores the parameters +from the first hit but only shows them in reports once each is registered as +an event-scoped custom dimension under Admin → Custom definitions. Page changes between Inertia visits are counted by enhanced measurement's "page changes based on browser history events", which must stay on in the web stream. diff --git a/docs/rebuild/design/architecture.md b/docs/rebuild/design/architecture.md index 80e57b3f..1b933ab9 100644 --- a/docs/rebuild/design/architecture.md +++ b/docs/rebuild/design/architecture.md @@ -136,7 +136,7 @@ does not call `/api/newsletter/stats` at all (§1.14). A Pest guard scans `resou | `localization` | closure → `{ switcher: [{locale, native, hreflang, href, current, fallback}] }` | `href` is the alternate for that locale when one exists. Otherwise it is that locale's section index (for example `/vi/blog`) with `fallback: true`, and the switcher shows a short note ("Trang này chưa có bản tiếng Việt") | | `seo` | closure → `{ robots: 'index, follow' \| 'noindex, follow', canonical: string \| null, alternates: [{hreflang, href}], xDefault, ogLocale, ogLocaleAlternates[], ogImage: {url, width, height, type} \| null }` | Built by `App\Support\Seo\SeoContext::forRequest()` from the `PageRegistry` (§1.6). For a page that renders but is not indexed in this locale (`/vi/blog`), and for an unmatched route or error page, the value is `robots: 'noindex, follow'`, `canonical: null` and no alternates | | `lcpAsset` | `{light, dark} \| null` (page prop, not shared) | Set by a controller only for a **supplied** asset with `priority: true`, through `AssetManifest::lcpDescriptor()` (§1.9). `app.blade.php` reads it to emit one preload for the active theme | -| `banner` | `{href, version} \| null` | The **copy** moves to the `banner` catalog namespace. Config keeps only `enabled`, `href` and `version` (`config/banner.php` is trimmed) | +| `banner` | `{href} \| null` | The **copy** moves to the `banner` catalog namespace. Config keeps only `enabled` and `href` (`config/banner.php` is trimmed); the banner cannot be closed, so there is no version (2026-10-22) | | `crispWebsiteId` | `config('services.crisp.website_id')` | Public value, used only by the click-to-load helper (§1.12) | | `assetPlaceholders` | `! App::isProduction()` | False in production, where an asset slot with no image renders nothing (§1.9) | @@ -1080,7 +1080,7 @@ Windows or Linux buttons appear. | `POST /newsletter/subscribe {email}` | Adds `locale`. Fallback strings in `use-email-form.ts:40-56` move to the `forms` namespace. 429 always uses the catalog, because the platform's 429 text is shared with the license API and stays English | | `GET /api/newsletter/stats` | **The public site no longer calls it.** The new footer shows no subscriber count (sitemap §B.3, design-system §11.1, item 6), so `footer.tsx:122`'s lazy fetch goes. The endpoint stays as a platform contract. `Contracts/PublicContractsTest` asserts that no file in `resources/js` fetches it | | GA events `download_click{location, platform}`, `checkout_started{tier, cycle}`, `newsletter_signup_clicked{source}` | Names and parameters unchanged. The new layout may add new `location` values; `platform` stays `mac\|ios` | -| Storage keys `tablepro:analytics-consent`, `theme`, `tablepro:attribution`, `tablepro:banner-dismissed` | Unchanged. `theme` now has writers (§1.10) | +| Storage keys `tablepro:analytics-consent`, `theme`, `tablepro:attribution` | Unchanged. `theme` now has writers (§1.10). `tablepro:banner-dismissed` is retired: the banner cannot be closed (2026-10-22) | | Consent head order | Unchanged (pinned) | | Inbound URLs `/?ref=…#pricing`, `/download`, `/account`, `/privacy` | Unchanged (§1.1) | | Assets the platform pulls from this app: `/logo.png`, `/images/logo.png`, `/site.webmanifest`, `/privacy#cookies` | **Do not move.** `#cookies` stays an explicit heading id in both locales' privacy markdown | @@ -1167,7 +1167,7 @@ cases call live `api.github.com` today. | `Landing/LicenseFeaturesTest.php` | **Rewrite** | Run it against `paid-features.json` (10, 8/2, order, names) and the `en`/`vi` detail files; each feature named once in the plan table (SSR). Drop the reading-budget pin | | `Landing/ProductHuntBadgeTest.php` | **Delete** | The hotlink is removed. If the design keeps a local badge, assert it is a local file | | `Landing/PurchaseAttributionTest.php` | **Keep, update paths** | Add: `locale` is present in the checkout body, and attribution is still attached before `fetch('/checkout')` | -| `Landing/TopBannerTest.php` | **Keep semantics, rewrite** | Keep the pre-paint dismissal script, "disabled leaves no trace" and the href. The length limits move to `banner` catalog values in both locales | +| `Landing/TopBannerTest.php` | **Keep semantics, rewrite** | Keep "disabled leaves no trace" and the href; the banner cannot be closed, so there is no dismissal script to keep (2026-10-22). The length limits move to `banner` catalog values in both locales | | `Seo/SeoSmokeTest.php` | **Rewrite** | Iterate the registry for both locales: the component name and a 200 | | `Seo/StaleClaimsTest.php` | **Delete → `Content/BannedClaimsTest`** | It keeps the slugs-equal-routes rule and the no-ratings rule, and adds the missing guards (MCP tool count, AI provider count, macOS floor) | | `tests/js/consent.test.ts`, `tests/js/attribution.test.ts` | **Keep** | Paths unchanged (the shared `consent.ts` keeps its location) | diff --git a/docs/rebuild/design/design-system.md b/docs/rebuild/design/design-system.md index dee1680a..bb37a09e 100644 --- a/docs/rebuild/design/design-system.md +++ b/docs/rebuild/design/design-system.md @@ -577,7 +577,7 @@ Repo key: | `ThemeControl` | Shared | **New** | — (none rendered on the old site) | `components/shared/theme-control.tsx`: `variant="menu"` (header `menuitemradio` menu), `variant="segmented"` (mobile menu) and `variant="icons"` (footer bar); labels as props | | `SiteHeader` | P | **Replace** | `landing/header.tsx` (both) | Nav per sitemap §B.1 (Features menu, Databases, Pricing, Docs, Blog) plus language, theme, Account and Download. **No Download dropdown**: Download goes to `/download`. The license repo's copy is deleted; it gets its own slim headers (§5.4) | | `MobileNav` | P | **Refactor** | `landing/mobile-nav.tsx` (both) | Full-height sheet dialog. Toggle gets `aria-expanded`, which the old one lacked. The license repo's copy is deleted | -| `SupportBanner` | P | **Refactor** | `landing/support-banner.tsx` | Neutral `--surface` band, not an orange fill. Key `tablepro:banner-dismissed` unchanged | +| `SupportBanner` | P | **Refactor** | `landing/support-banner.tsx` | A tint of the brand hue over the page colour, not an orange fill. It cannot be closed: no dismiss, no stored key (2026-10-22) | | `SiteFooter` | P | **Replace** | `landing/footer.tsx` (both) | Groups and links per sitemap §B.3 with positioning §10.2's labels, newsletter form and cookie settings, as cells, closed by the shared FooterBar (language menu, theme). No giant wordmark. The license repo's copy is replaced by its own slim footer (§5.4), which ends on the same FooterBar | | `ConsentBar` | Shared | **Refactor** | `landing/consent-bar.tsx` (both) | Moves to `components/shared/consent-bar.tsx` with `lib/consent.ts`. Compact. Equal-weight Allow and Decline. Storage key and head order unchanged | | `Field`, `Input`, `Select`, `Checkbox`, `Stepper` | Shared\* | **Keep/refactor** | A `ui/field.tsx`, `select.tsx`, `stepper.tsx` | Shared form styling. Native controls with `accent-color` | @@ -857,9 +857,9 @@ One shared file, `components/shared/theme-control.tsx` (architecture §1.10 and **SupportBanner:** - The license banner (decided 2026-10-05): on by default on every public page except Pricing, above the sticky header, scrolling away with the page. Off on `/ios` for a reader on an iPhone or iPad, whose app has no license. -- 40px `--surface` band with a 1px `--rule` bottom border; text `small` `--foreground`; link `standalone`. From 1024px the sentence and "Get a license"; below, the short question ("Use TablePro daily?") and the same link, within 248px so it fits a 320px screen; from 1280px also "Have a license? Hide this". -- Dismiss is a 32px `quiet` icon button labelled "Dismiss" / "Ẩn thông báo", in a 44px-wide target. Closing it hides the bar for 30 days at that version; "Have a license?" and a purchase hide it for a year at every version (`lib/banner.ts`). -- Mechanics: `--banner-h`, `has-banner` stamped server-side on the pages that show it, and the dismissal record settled before first paint. +- 40px band on a wash of the brand hue, left to right: `linear-gradient(90deg, color-mix(in oklab, --accent 14%, --background), color-mix(in oklab, --accent 6%, --background))` (oklab, since the neutrals write hue 0 and a polar mix would swing towards red), under a 1px rule of `--accent` 30% in `--rule` (app.css). A tint, not a fill: text `small` `--foreground` (17.5:1 light, 14.5:1 dark on the strongest stop); link `standalone` `--accent-text` (5.1:1 light, 8.2:1 dark). Forced colours drop the gradient. From 1024px the sentence and "See plans"; below, the short question ("Optional paid plans") and the same link, within 248px so it fits a 320px screen. +- It cannot be closed (decided 2026-10-22): no dismiss button, no "Have a license?" link, no stored record, no version. Every reader sees it on every page it belongs on. +- Mechanics: `--banner-h`, `has-banner` stamped server-side on the pages that show it; the one browser-side removal is the `/ios` device rule, before first paint. **SiteFooter:** diff --git a/docs/rebuild/design/sitemap.md b/docs/rebuild/design/sitemap.md index 16d9a40d..29cabfa5 100644 --- a/docs/rebuild/design/sitemap.md +++ b/docs/rebuild/design/sitemap.md @@ -352,7 +352,7 @@ Two lists drive this. **`alternates`** (for hreflang) holds real translations on **Theme across both apps.** Both use the same localStorage `theme` key on the same origin. Both head scripts default to `system` when nothing is stored, guard storage access with try/catch, and set the class before first paint. Screenshot variants follow the site's theme class, not `prefers-color-scheme`. -**License banner.** `config/banner.php` is on by default (owner decision, 2026-10-05), on every public page except /pricing in every language, and links to /pricing or to a release post. On /ios a reader on an iPhone or iPad does not get it: the license is for the Mac app. It asks a regular user to buy a license and says what a license adds and pays for; it never pleads. Its dismiss key `tablepro:banner-dismissed` holds the closed version and an end date: 30 days after closing, a year for a license holder or a buyer. The copy "The whole app is free" stays deleted. +**License banner.** `config/banner.php` is on by default (owner decision, 2026-10-05), on every public page except /pricing in every language, and links to /pricing or to a release post. On /ios a reader on an iPhone or iPad does not get it: the license is for the Mac app. It asks a regular user to buy a license and says what a license adds and pays for; it never pleads. It cannot be closed (decided 2026-10-22): no dismiss button and no stored record, so the `tablepro:banner-dismissed` key is retired. The copy "The whole app is free" stays deleted. --- diff --git a/resources/css/app.css b/resources/css/app.css index c71c1e8e..fffae070 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -137,12 +137,12 @@ html { * (landing-layout.tsx), so an anchor only has to clear the header: 64px plus * 16px of air. * - * Zero by default, so a disabled or dismissed banner leaves nothing behind. - * `html.has-banner` is stamped server-side in app.blade.php on every page that - * shows it, and removed there before first paint while the reader's dismissal - * lasts (lib/banner.ts), so a dismissed banner never flashes and a disabled one - * reserves nothing. Visibility lives here rather than in React state for the - * same reason: state would resolve one paint too late. + * Zero by default, so a disabled banner leaves nothing behind. `html.has-banner` + * is stamped server-side in app.blade.php on every page that shows it. The + * banner cannot be closed, so the class stays for the whole visit; the one + * place it comes off in the browser is the iPhone and iPad page, for a reader + * on one of those devices, before first paint. Visibility lives here rather + * than in React state so that the first frame is already right. * * The slot holds one bar at a time (TopBanner). The language bar, which offers * this page in the reader's language, takes the same height and hides the @@ -181,6 +181,33 @@ html.has-language-bar .support-banner { display: none; } +/* + * The license banner's ground: a wash of the brand hue over the page colour, + * strongest at the left and thinning to the right, under a rule in the same + * hue. A tint, not a fill: the text stays `--foreground` and the link + * `--accent-text`, which measure 17.5:1 and 5.1:1 on the strongest stop in + * light and 14.5:1 and 8.2:1 in dark, within the design system's text pairs + * (its lowest is 5.15:1). Mixed in oklab, not oklch: the neutral tokens + * write their hue as 0, and a polar mix would swing the tint from the brand's + * 55 towards red. `bg-surface` on the element is the ground where + * `color-mix()` is unknown, and in forced colours the band is Canvas like + * every other band. + */ +.support-banner { + background-image: linear-gradient( + 90deg, + color-mix(in oklab, var(--accent) 14%, var(--background)), + color-mix(in oklab, var(--accent) 6%, var(--background)) + ); + border-color: color-mix(in oklab, var(--accent) 30%, var(--rule)); +} + +@media (forced-colors: active) { + .support-banner { + background-image: none; + } +} + html { scroll-padding-top: 5rem; } diff --git a/resources/data/legal/de/privacy.md b/resources/data/legal/de/privacy.md index cec33fa4..5b81ba87 100644 --- a/resources/data/legal/de/privacy.md +++ b/resources/data/legal/de/privacy.md @@ -1,7 +1,7 @@ --- title: Datenschutzerklärung description: Welche Daten die TablePro-Apps, die Website und das Kontoportal erfassen, wohin sie gehen, wie lange sie bleiben und wie du sie änderst oder löschst. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- Diese Erklärung gilt für TablePro für Mac, TablePro für iPhone und iPad, die Website tablepro.app, die Dokumentation unter docs.tablepro.app und das Kontoportal tablepro.app/account. Sie beschreibt, was diese heute tatsächlich senden und speichern. Beide Apps sind quelloffen unter der AGPLv3. Den Code, der die unten genannten Daten sendet, kannst du im [TablePro-Repository]({github}) lesen. @@ -151,7 +151,7 @@ Das Lesen der öffentlichen Website und ihre Anfragen zu Newsletter, Checkout un - **`_ga` und `_ga_`** (Google-Analytics-Cookies, bis zu 2 Jahre, nur mit deiner Erlaubnis): eine zufällige Browserkennung und der Status deines aktuellen Besuchs. Ablehnen oder späteres Ändern deiner Antwort löscht sie. Rechtsgrundlage: Einwilligung. - **`tablepro:analytics-consent`** (lokaler Speicher, bis du ihn löschst): deine Antwort auf die Analysefrage, damit sie nicht auf jeder Seite erscheint. Website und Kontoportal teilen diesen Eintrag. Rechtsgrundlage: unbedingt erforderlich, um deine Wahl zu beachten. - **`tablepro:attribution`** (lokaler Speicher, 90 Tage): der unter [Website](#website) beschriebene Erstbesuchsdatensatz. Er enthält keine persönliche Kennung und wird nur mit einer Checkout-Anfrage gesendet, bei der unser Server ihn verwirft. Rechtsgrundlage: berechtigtes Interesse. -- **`theme`** und **`tablepro:banner-dismissed`** (lokaler Speicher, bis du ihn löschst): deine Wahl zwischen heller, dunkler oder Systemdarstellung sowie ausgeblendetes Banner und Ausblendungsdauer: 30 Tage oder ein Jahr, wenn du angibst, eine Lizenz zu haben, oder eine kaufst. Rechtsgrundlage: berechtigtes Interesse. +- **`theme`** (lokaler Speicher, bis du ihn löschst): deine Wahl zwischen heller, dunkler oder Systemdarstellung. Rechtsgrundlage: berechtigtes Interesse. - **`tablepro:language`** (lokaler Speicher, bis du ihn löschst): die Sprache, die du im Sprachmenü oder in der Sprachleiste gewählt hast, und jede Sprache, deren Vorschlag du geschlossen hast, damit sie nicht erneut angeboten wird. Rechtsgrundlage: berechtigtes Interesse. - **`mintlify_anonymous_id`** (lokaler Speicher auf docs.tablepro.app, von Mintlify gesetzt, nur wenn du dort Google Analytics zulässt): die unter [Website](#website) beschriebene Besucher-ID. Beim Ablehnen wird sie gelöscht. Die Dokumentation speichert ihre eigene Antwort `tablepro:analytics-consent`. Rechtsgrundlage: Einwilligung. - **Cookies beginnend mit `crisp-client/`** (Crisp, etwa `crisp-client/session/…`; 6 Monate, bei Rückkehr erneuert; auf jeder Seite nach dem Laden des Chats gesetzt): halten Chat und Gespräch über Seiten und Besuche hinweg verfügbar. Rechtsgrundlage: berechtigtes Interesse, um auf jeder Seite Support anzubieten. diff --git a/resources/data/legal/en/privacy.md b/resources/data/legal/en/privacy.md index e6519725..d39dc685 100644 --- a/resources/data/legal/en/privacy.md +++ b/resources/data/legal/en/privacy.md @@ -1,7 +1,7 @@ --- title: Privacy policy description: What TablePro's apps, website and account portal collect, where it goes, how long it is kept, and how to change or delete it. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- This policy covers TablePro for Mac, TablePro for iPhone and iPad, the website at tablepro.app, the documentation at docs.tablepro.app, and the account portal at tablepro.app/account. It describes what each of them actually sends and stores today. Both apps are open source under the AGPLv3, so you can read the code that sends any of the data below in the [TablePro repository]({github}). @@ -151,7 +151,7 @@ Reading the public website and its newsletter, checkout and discount-code reques - **`_ga` and `_ga_`** (Google Analytics cookies, up to 2 years, only if you allow analytics): a random identifier for your browser and the state of your current visit. Declining, or changing your answer later, deletes them. Lawful basis: consent. - **`tablepro:analytics-consent`** (local storage, until you clear it): your answer to the analytics question, so you are not asked on every page. The website and the account portal share it. Lawful basis: strictly necessary to honor your choice. - **`tablepro:attribution`** (local storage, 90 days): the first-visit record described under [Website](#website). It holds no identifier of you and is sent only with a checkout request, where our server discards it. Lawful basis: legitimate interest. -- **`theme`** and **`tablepro:banner-dismissed`** (local storage, until you clear it): whether you chose a light, dark or system appearance, and which banner you closed and until when: 30 days, or a year if you say you have a license or buy one. Lawful basis: legitimate interest. +- **`theme`** (local storage, until you clear it): whether you chose a light, dark or system appearance. Lawful basis: legitimate interest. - **`tablepro:language`** (local storage, until you clear it): the language you picked in the language menu or the language bar, and each language whose suggestion you closed, so it is not offered again. Lawful basis: legitimate interest. - **`mintlify_anonymous_id`** (local storage on docs.tablepro.app, set by Mintlify, only if you allow analytics there): the visitor ID described under [Website](#website). Declining removes it. The documentation keeps its own `tablepro:analytics-consent` answer. Lawful basis: consent. - **Cookies starting with `crisp-client/`** (Crisp, for example `crisp-client/session/…`; 6 months, renewed when you return; set on every page once the chat has loaded): keep the chat and your conversation across pages and visits. Lawful basis: legitimate interest, to offer support on every page. diff --git a/resources/data/legal/es/privacy.md b/resources/data/legal/es/privacy.md index 97272165..29504593 100644 --- a/resources/data/legal/es/privacy.md +++ b/resources/data/legal/es/privacy.md @@ -1,7 +1,7 @@ --- title: Política de privacidad description: Qué recopilan las apps, el sitio web y el portal de cuentas de TablePro, adónde se envía, cuánto tiempo se conserva y cómo modificarlo o eliminarlo. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- Esta política cubre TablePro para Mac, TablePro para iPhone y iPad, el sitio web tablepro.app, la documentación en docs.tablepro.app y el portal de cuentas tablepro.app/account. Describe qué envía y almacena realmente cada uno de ellos en la actualidad. Ambas apps son de código abierto bajo AGPLv3, por lo que puedes consultar el código que envía cualquiera de los datos siguientes en el [repositorio de TablePro]({github}). @@ -151,7 +151,7 @@ Leer el sitio público y sus solicitudes de boletín, compra y códigos de descu - **`_ga` y `_ga_`** (cookies de Google Analytics, hasta 2 años, solo si permites el análisis): un identificador aleatorio del navegador y el estado de tu visita actual. Rechazarlas, o cambiar tu respuesta después, las elimina. Base jurídica: consentimiento. - **`tablepro:analytics-consent`** (almacenamiento local, hasta que lo borres): tu respuesta a la pregunta sobre análisis, para no preguntarte en todas las páginas. El sitio web y el portal de cuentas lo comparten. Base jurídica: estrictamente necesario para respetar tu elección. - **`tablepro:attribution`** (almacenamiento local, 90 días): el registro de primera visita descrito en [Sitio web](#website). No contiene un identificador tuyo y solo se envía con una solicitud de compra, donde nuestro servidor lo descarta. Base jurídica: interés legítimo. -- **`theme`** y **`tablepro:banner-dismissed`** (almacenamiento local, hasta que lo borres): si elegiste apariencia clara, oscura o del sistema, y qué aviso cerraste y hasta cuándo: 30 días, o un año si indicas que tienes licencia o compras una. Base jurídica: interés legítimo. +- **`theme`** (almacenamiento local, hasta que lo borres): si elegiste apariencia clara, oscura o del sistema. Base jurídica: interés legítimo. - **`tablepro:language`** (almacenamiento local, hasta que lo borres): el idioma que elegiste en el menú de idiomas o en la barra de idioma, y cada idioma cuya sugerencia cerraste, para no volver a ofrecerlo. Base jurídica: interés legítimo. - **`mintlify_anonymous_id`** (almacenamiento local en docs.tablepro.app, lo guarda Mintlify, solo si permites Google Analytics allí): el ID de visitante descrito en [Sitio web](#website). Al rechazar se elimina. La documentación guarda su propia respuesta `tablepro:analytics-consent`. Base jurídica: consentimiento. - **Cookies que comienzan por `crisp-client/`** (Crisp, por ejemplo `crisp-client/session/…`; 6 meses, renovados cuando vuelves; establecidas en todas las páginas al cargar el chat): mantienen el chat y tu conversación entre páginas y visitas. Base jurídica: interés legítimo, para ofrecer soporte en todas las páginas. diff --git a/resources/data/legal/fr/privacy.md b/resources/data/legal/fr/privacy.md index 4860afae..f78b25dc 100644 --- a/resources/data/legal/fr/privacy.md +++ b/resources/data/legal/fr/privacy.md @@ -1,7 +1,7 @@ --- title: Politique de confidentialité description: Ce que collectent les apps, le site et le portail de comptes TablePro, où vont ces données, leur durée de conservation, comment les modifier ou supprimer. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- Cette politique couvre TablePro pour Mac, TablePro pour iPhone et iPad, le site web tablepro.app, la documentation sur docs.tablepro.app et le portail de comptes tablepro.app/account. Elle décrit ce que chacun envoie et stocke réellement aujourd’hui. Les deux applications sont open source sous AGPLv3 ; vous pouvez donc lire le code qui envoie les données ci-dessous dans le [dépôt TablePro]({github}). @@ -151,7 +151,7 @@ Consulter le site public et ses demandes de newsletter, de règlement et de code - **`_ga` et `_ga_`** (cookies Google Analytics, jusqu’à 2 ans, uniquement si vous autorisez l’analyse) : identifiant aléatoire de votre navigateur et état de votre visite actuelle. Refuser, ou modifier votre réponse plus tard, les supprime. Base légale : consentement. - **`tablepro:analytics-consent`** (stockage local, jusqu’à suppression) : votre réponse à la question sur l’analyse, pour éviter de la reposer sur chaque page. Le site et le portail de comptes la partagent. Base légale : strictement nécessaire pour respecter votre choix. - **`tablepro:attribution`** (stockage local, 90 jours) : enregistrement de première visite décrit dans [Site web](#website). Il ne contient aucun identifiant personnel et n’accompagne qu’une demande de règlement, où notre serveur l’écarte. Base légale : intérêt légitime. -- **`theme`** et **`tablepro:banner-dismissed`** (stockage local, jusqu’à suppression) : votre choix d’apparence claire, sombre ou système, et le bandeau fermé et sa durée de masquage : 30 jours, ou un an si vous indiquez posséder une licence ou en achetez une. Base légale : intérêt légitime. +- **`theme`** (stockage local, jusqu’à suppression) : votre choix d’apparence claire, sombre ou système. Base légale : intérêt légitime. - **`tablepro:language`** (stockage local, jusqu’à suppression) : la langue que vous avez choisie dans le menu des langues ou dans la barre de langue, et chaque langue dont vous avez fermé la suggestion, pour qu’elle ne soit plus proposée. Base légale : intérêt légitime. - **`mintlify_anonymous_id`** (stockage local sur docs.tablepro.app, déposé par Mintlify, uniquement si vous y autorisez Google Analytics) : l’identifiant de visiteur décrit dans [Site web](#website). Un refus le supprime. La documentation conserve sa propre réponse `tablepro:analytics-consent`. Base légale : consentement. - **Cookies commençant par `crisp-client/`** (Crisp, par exemple `crisp-client/session/…` ; 6 mois, renouvelés à votre retour ; déposés sur chaque page après le chargement du chat) : maintiennent le chat et votre conversation entre pages et visites. Base légale : intérêt légitime, pour proposer une assistance sur chaque page. diff --git a/resources/data/legal/id/privacy.md b/resources/data/legal/id/privacy.md index 5a7d5ebd..cd234208 100644 --- a/resources/data/legal/id/privacy.md +++ b/resources/data/legal/id/privacy.md @@ -1,7 +1,7 @@ --- title: Kebijakan privasi description: Data yang dikumpulkan aplikasi, situs web, dan portal akun TablePro, tujuan pengirimannya, lama penyimpanannya, serta cara mengubah atau menghapusnya. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- Kebijakan ini mencakup TablePro untuk Mac, TablePro untuk iPhone dan iPad, situs web di tablepro.app, dokumentasi di docs.tablepro.app, dan portal akun di tablepro.app/account. Kebijakan ini menjelaskan data yang benar-benar dikirim dan disimpan oleh masing-masing layanan saat ini. Kedua aplikasi bersifat sumber terbuka dengan lisensi AGPLv3, sehingga Anda dapat membaca kode yang mengirim data di bawah ini dalam [repositori TablePro]({github}). @@ -151,7 +151,7 @@ Membaca situs publik serta permintaan newsletter, checkout, dan kode diskonnya t - **`_ga` dan `_ga_`** (cookie Google Analytics, hingga 2 tahun, hanya jika Anda mengizinkan analitik): pengenal acak untuk peramban dan status kunjungan saat ini. Menolak atau mengubah jawaban kemudian akan menghapusnya. Dasar hukum: persetujuan. - **`tablepro:analytics-consent`** (penyimpanan lokal, sampai Anda menghapusnya): jawaban Anda atas pertanyaan analitik, agar tidak ditanyakan pada setiap halaman. Situs web dan portal akun berbagi data ini. Dasar hukum: benar-benar diperlukan untuk menghormati pilihan Anda. - **`tablepro:attribution`** (penyimpanan lokal, 90 hari): catatan kunjungan pertama yang dijelaskan di [Situs web](#website). Catatan ini tidak berisi pengenal Anda dan hanya dikirim bersama permintaan pembayaran, lalu dibuang oleh server kami. Dasar hukum: kepentingan yang sah. -- **`theme`** dan **`tablepro:banner-dismissed`** (penyimpanan lokal, sampai Anda menghapusnya): pilihan tampilan terang, gelap, atau sistem, serta banner yang Anda tutup dan batas waktunya: 30 hari, atau satu tahun jika Anda menyatakan sudah memiliki lisensi atau membelinya. Dasar hukum: kepentingan yang sah. +- **`theme`** (penyimpanan lokal, sampai Anda menghapusnya): pilihan tampilan terang, gelap, atau sistem. Dasar hukum: kepentingan yang sah. - **`tablepro:language`** (penyimpanan lokal, sampai Anda menghapusnya): bahasa yang Anda pilih di menu bahasa atau bilah bahasa, dan setiap bahasa yang sarannya Anda tutup, agar tidak ditawarkan lagi. Dasar hukum: kepentingan yang sah. - **`mintlify_anonymous_id`** (penyimpanan lokal di docs.tablepro.app, dipasang oleh Mintlify, hanya jika Anda mengizinkan Google Analytics di sana): ID pengunjung yang dijelaskan di [Situs web](#website). Menolak akan menghapusnya. Dokumentasi menyimpan jawaban `tablepro:analytics-consent` miliknya sendiri. Dasar hukum: persetujuan. - **Cookie yang diawali `crisp-client/`** (Crisp, misalnya `crisp-client/session/…`; 6 bulan, diperbarui saat Anda kembali; dipasang pada setiap halaman setelah chat dimuat): mempertahankan chat dan percakapan lintas halaman dan kunjungan. Dasar hukum: kepentingan yang sah, untuk menawarkan dukungan di setiap halaman. diff --git a/resources/data/legal/it/privacy.md b/resources/data/legal/it/privacy.md index 8d703c49..89528e49 100644 --- a/resources/data/legal/it/privacy.md +++ b/resources/data/legal/it/privacy.md @@ -1,7 +1,7 @@ --- title: Informativa sulla privacy description: Quali dati raccolgono le app, il sito web e il portale account di TablePro, dove vanno, per quanto tempo restano e come modificarli o eliminarli. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- Questa informativa riguarda TablePro per Mac, TablePro per iPhone e iPad, il sito web tablepro.app, la documentazione su docs.tablepro.app e il portale account tablepro.app/account. Descrive quali dati ciascuno di essi invia e conserva effettivamente oggi. Entrambe le app sono open source con licenza AGPLv3: puoi quindi leggere il codice che invia i dati descritti di seguito nel [repository di TablePro]({github}). @@ -151,7 +151,7 @@ La lettura del sito pubblico e le sue richieste per newsletter, checkout e codic - **`_ga` e `_ga_`** (cookie di Google Analytics, fino a 2 anni, solo se autorizzi le analisi): un identificatore casuale per il browser e lo stato della visita corrente. Rifiutarli o modificare successivamente la risposta li elimina. Base giuridica: consenso. - **`tablepro:analytics-consent`** (archiviazione locale, finché non la cancelli): la risposta alla richiesta sulle analisi, per non riproporla su ogni pagina. Il sito web e il portale account la condividono. Base giuridica: strettamente necessaria per rispettare la tua scelta. - **`tablepro:attribution`** (archiviazione locale, 90 giorni): il record della prima visita descritto in [Sito web](#website). Non contiene un tuo identificatore e viene inviato solo con una richiesta di pagamento, dove il nostro server lo scarta. Base giuridica: legittimo interesse. -- **`theme`** e **`tablepro:banner-dismissed`** (archiviazione locale, finché non la cancelli): la scelta di un aspetto chiaro, scuro o di sistema e il banner che hai chiuso e fino a quando: 30 giorni oppure un anno se dichiari di avere una licenza o ne acquisti una. Base giuridica: legittimo interesse. +- **`theme`** (archiviazione locale, finché non la cancelli): la scelta di un aspetto chiaro, scuro o di sistema. Base giuridica: legittimo interesse. - **`tablepro:language`** (archiviazione locale, finché non la cancelli): la lingua che hai scelto nel menu delle lingue o nella barra della lingua, e ogni lingua di cui hai chiuso il suggerimento, perché non venga più proposta. Base giuridica: legittimo interesse. - **`mintlify_anonymous_id`** (archiviazione locale su docs.tablepro.app, impostato da Mintlify, solo se lì autorizzi Google Analytics): l'ID visitatore descritto in [Sito web](#website). Il rifiuto lo rimuove. La documentazione conserva la propria risposta `tablepro:analytics-consent`. Base giuridica: consenso. - **Cookie che iniziano con `crisp-client/`** (Crisp, per esempio `crisp-client/session/…`; 6 mesi, rinnovati quando ritorni; impostati su ogni pagina dopo il caricamento della chat): mantengono la chat e la conversazione tra pagine e visite. Base giuridica: legittimo interesse, per offrire supporto su ogni pagina. diff --git a/resources/data/legal/ja/privacy.md b/resources/data/legal/ja/privacy.md index 9d9650e2..f5c9a2c4 100644 --- a/resources/data/legal/ja/privacy.md +++ b/resources/data/legal/ja/privacy.md @@ -1,7 +1,7 @@ --- title: プライバシーポリシー description: TablePro のアプリ、ウェブサイト、アカウントポータルが収集する情報、送信先、保存期間、変更・削除方法を説明します。 -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- このポリシーは、Mac 版 TablePro、iPhone・iPad 版 TablePro、tablepro.app のウェブサイト、docs.tablepro.app のドキュメント、tablepro.app/account のアカウントポータルに適用されます。それぞれが現在実際に送信・保存している情報を説明します。両アプリは AGPLv3 のオープンソースで、以下のデータを送信するコードは [TablePro のリポジトリ]({github})で確認できます。 @@ -151,7 +151,7 @@ tablepro.app/account の[アカウントポータル](/account?locale=ja)は、 - **`_ga` と `_ga_`**(Google Analytics の Cookie、最長 2 年、解析を許可した場合のみ):ブラウザーのランダムな識別子と現在の訪問状態です。拒否した場合や後から回答を変更した場合は削除します。法的根拠:同意。 - **`tablepro:analytics-consent`**(ローカルストレージ、消去するまで):解析の質問への回答を保存し、ページごとに質問しないようにします。ウェブサイトとアカウントポータルで共有します。法的根拠:お客様の選択を尊重するために厳密に必要。 - **`tablepro:attribution`**(ローカルストレージ、90 日):[ウェブサイト](#website)で説明した初回訪問の記録です。お客様の識別子は含まず、チェックアウトリクエストとともにのみ送られ、サーバーで破棄されます。法的根拠:正当な利益。 -- **`theme`** と **`tablepro:banner-dismissed`**(ローカルストレージ、消去するまで):ライト、ダーク、システムの表示設定と、閉じたバナーおよび非表示の期限を保存します。期限は 30 日、ライセンスを持っていると申告した場合や購入した場合は 1 年です。法的根拠:正当な利益。 +- **`theme`**(ローカルストレージ、消去するまで):ライト、ダーク、システムの表示設定を保存します。法的根拠:正当な利益。 - **`tablepro:language`**(ローカルストレージ、消去するまで):言語メニューまたは言語バーで選んだ言語と、提案を閉じた言語を保存し、同じ言語を再び提案しないようにします。法的根拠:正当な利益。 - **`mintlify_anonymous_id`**(docs.tablepro.app のローカルストレージ、Mintlify が設定、そこで Google Analytics を許可した場合のみ):[ウェブサイト](#website)で説明した訪問者 ID です。拒否すると削除されます。ドキュメントは独自の `tablepro:analytics-consent` の回答を保存します。法的根拠:同意。 - **`crisp-client/` で始まる Cookie**(Crisp、例:`crisp-client/session/…`、6 か月、再訪時に更新、チャット読み込み後はすべてのページで設定):ページや訪問をまたいでチャットと会話を維持します。法的根拠:すべてのページでサポートを提供するための正当な利益。 diff --git a/resources/data/legal/ko/privacy.md b/resources/data/legal/ko/privacy.md index 5449a199..93020026 100644 --- a/resources/data/legal/ko/privacy.md +++ b/resources/data/legal/ko/privacy.md @@ -1,7 +1,7 @@ --- title: 개인정보 처리방침 description: TablePro 앱, 웹사이트 및 계정 포털이 수집하는 정보, 전송 대상, 보관 기간과 변경 또는 삭제 방법을 설명합니다. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- 이 방침은 Mac용 TablePro, iPhone 및 iPad용 TablePro, tablepro.app 웹사이트, docs.tablepro.app 문서 및 tablepro.app/account 계정 포털에 적용됩니다. 각 제품이 현재 실제로 전송하고 저장하는 정보를 설명합니다. 두 앱은 모두 AGPLv3로 공개된 오픈 소스이므로 아래 데이터를 전송하는 코드를 [TablePro 저장소]({github})에서 확인할 수 있습니다. @@ -151,7 +151,7 @@ tablepro.app/account의 [계정 포털](/account?locale=ko)은 라이선스 구 - **`_ga` 및 `_ga_`**(Google Analytics 쿠키, 최대 2년, 분석 허용 시에만): 브라우저의 임의 식별자와 현재 방문 상태입니다. 거부하거나 나중에 응답을 변경하면 삭제됩니다. 법적 근거: 동의. - **`tablepro:analytics-consent`**(로컬 저장소, 사용자가 지울 때까지): 분석 질문에 대한 응답을 저장해 페이지마다 다시 묻지 않게 합니다. 웹사이트와 계정 포털이 공유합니다. 법적 근거: 선택을 존중하기 위해 반드시 필요. - **`tablepro:attribution`**(로컬 저장소, 90일): [웹사이트](#website)에 설명된 첫 방문 기록입니다. 사용자 식별자는 없으며 결제 요청과 함께만 전송되고 서버에서 폐기됩니다. 법적 근거: 정당한 이익. -- **`theme`** 및 **`tablepro:banner-dismissed`**(로컬 저장소, 사용자가 지울 때까지): 선택한 라이트, 다크 또는 시스템 화면 모드, 닫은 배너와 숨김 기한을 저장합니다. 기한은 30일이며 라이선스 보유를 표시하거나 구매하면 1년입니다. 법적 근거: 정당한 이익. +- **`theme`**(로컬 저장소, 사용자가 지울 때까지): 선택한 라이트, 다크 또는 시스템 화면 모드를 저장합니다. 법적 근거: 정당한 이익. - **`tablepro:language`**(로컬 저장소, 사용자가 지울 때까지): 언어 메뉴나 언어 표시줄에서 선택한 언어와 제안을 닫은 언어를 저장하여 다시 제안하지 않습니다. 법적 근거: 정당한 이익. - **`mintlify_anonymous_id`**(docs.tablepro.app의 로컬 저장소, Mintlify가 설정, 문서에서 Google Analytics를 허용한 경우에만): [웹사이트](#website)에 설명된 방문자 ID입니다. 거부하면 삭제됩니다. 문서는 자체 `tablepro:analytics-consent` 답변을 따로 저장합니다. 법적 근거: 동의. - **`crisp-client/`로 시작하는 쿠키**(Crisp, 예: `crisp-client/session/…`, 6개월, 재방문 시 갱신, 채팅 로딩 후 모든 페이지에 설정): 페이지와 방문 간에 채팅과 대화를 유지합니다. 법적 근거: 모든 페이지에서 지원을 제공하기 위한 정당한 이익. diff --git a/resources/data/legal/pt-BR/privacy.md b/resources/data/legal/pt-BR/privacy.md index c3c31422..3b2a8369 100644 --- a/resources/data/legal/pt-BR/privacy.md +++ b/resources/data/legal/pt-BR/privacy.md @@ -1,7 +1,7 @@ --- title: Política de privacidade description: O que os apps, site e portal da conta TablePro coletam, para onde os dados vão, por quanto tempo são mantidos e como alterá-los ou excluí-los. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- Esta política abrange TablePro para Mac, TablePro para iPhone e iPad, o site tablepro.app, a documentação em docs.tablepro.app e o portal da conta tablepro.app/account. Descreve o que cada um realmente envia e armazena hoje. Os dois apps são de código aberto sob a AGPLv3, portanto você pode ler o código que envia os dados abaixo no [repositório TablePro]({github}). @@ -151,7 +151,7 @@ Ler o site público e suas solicitações de newsletter, checkout e códigos de - **`_ga` e `_ga_`** (cookies Google Analytics, até 2 anos, apenas se permitir análises): um identificador aleatório do navegador e o estado da visita atual. Recusar ou alterar sua resposta depois os exclui. Base legal: consentimento. - **`tablepro:analytics-consent`** (armazenamento local, até você limpar): sua resposta à pergunta de análises, para não perguntar em todas as páginas. Site e portal da conta compartilham o registro. Base legal: estritamente necessário para respeitar sua escolha. - **`tablepro:attribution`** (armazenamento local, 90 dias): registro da primeira visita descrito em [Site](#website). Não contém um identificador seu e só é enviado com a solicitação de checkout, onde nosso servidor o descarta. Base legal: interesse legítimo. -- **`theme`** e **`tablepro:banner-dismissed`** (armazenamento local, até você limpar): se escolheu aparência clara, escura ou do sistema e qual aviso fechou e até quando: 30 dias ou um ano se informar que tem licença ou comprar uma. Base legal: interesse legítimo. +- **`theme`** (armazenamento local, até você limpar): se escolheu aparência clara, escura ou do sistema. Base legal: interesse legítimo. - **`tablepro:language`** (armazenamento local, até você limpar): o idioma que você escolheu no menu de idiomas ou na barra de idioma e cada idioma cuja sugestão você fechou, para que não seja oferecido de novo. Base legal: interesse legítimo. - **`mintlify_anonymous_id`** (armazenamento local em docs.tablepro.app, definido pela Mintlify, somente se você permitir Google Analytics lá): o ID de visitante descrito em [Site](#website). Recusar o remove. A documentação guarda a própria resposta `tablepro:analytics-consent`. Base legal: consentimento. - **Cookies que começam com `crisp-client/`** (Crisp, por exemplo `crisp-client/session/…`; 6 meses, renovados quando você volta; definidos em todas as páginas quando o chat carrega): mantêm o chat e sua conversa entre páginas e visitas. Base legal: interesse legítimo, para oferecer suporte em todas as páginas. diff --git a/resources/data/legal/vi/privacy.md b/resources/data/legal/vi/privacy.md index ae4e07bb..6ebe6ff0 100644 --- a/resources/data/legal/vi/privacy.md +++ b/resources/data/legal/vi/privacy.md @@ -1,7 +1,7 @@ --- title: Chính sách quyền riêng tư description: Ứng dụng, website và trang tài khoản của TablePro thu thập những gì, gửi đi đâu, lưu trong bao lâu, và cách bạn thay đổi hoặc xóa dữ liệu đó. -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- Chính sách này áp dụng cho TablePro cho Mac, TablePro cho iPhone và iPad, website tablepro.app, trang tài liệu docs.tablepro.app và trang tài khoản tablepro.app/account. Chính sách mô tả đúng những gì từng thành phần đang gửi đi và lưu lại ở thời điểm hiện tại. Cả hai ứng dụng đều là mã nguồn mở theo giấy phép AGPLv3, nên bạn có thể đọc phần mã gửi đi mọi dữ liệu nêu dưới đây trong [kho mã nguồn TablePro]({github}). @@ -151,7 +151,7 @@ Website công khai và request đăng ký bản tin, thanh toán, kiểm tra mã - **`_ga` và `_ga_`** (cookie của Google Analytics, tối đa 2 năm, chỉ khi bạn cho phép phân tích): một mã ngẫu nhiên cho trình duyệt của bạn và trạng thái của lượt truy cập hiện tại. Khi bạn từ chối, hoặc đổi câu trả lời sau đó, các cookie này bị xóa. Cơ sở pháp lý: sự đồng ý. - **`tablepro:analytics-consent`** (local storage, cho tới khi bạn xóa): câu trả lời của bạn cho câu hỏi về phân tích, để bạn không bị hỏi lại ở mỗi trang. Website và trang tài khoản dùng chung giá trị này. Cơ sở pháp lý: thực sự cần thiết để tôn trọng lựa chọn của bạn. - **`tablepro:attribution`** (local storage, 90 ngày): bản ghi lần truy cập đầu tiên được mô tả trong mục [Website](#website). Bản ghi không chứa mã định danh nào của bạn và chỉ được gửi kèm yêu cầu thanh toán, nơi máy chủ của chúng tôi bỏ nó đi. Cơ sở pháp lý: lợi ích hợp pháp. -- **`theme`** và **`tablepro:banner-dismissed`** (local storage, cho tới khi bạn xóa): giao diện bạn chọn (sáng, tối hoặc theo hệ thống), và banner nào bạn đã đóng cùng thời hạn ẩn: 30 ngày, hoặc một năm nếu bạn cho biết đã có license hoặc vừa mua license. Cơ sở pháp lý: lợi ích hợp pháp. +- **`theme`** (local storage, cho tới khi bạn xóa): giao diện bạn chọn (sáng, tối hoặc theo hệ thống). Cơ sở pháp lý: lợi ích hợp pháp. - **`tablepro:language`** (local storage, cho tới khi bạn xóa): ngôn ngữ bạn chọn trong menu ngôn ngữ hoặc thanh gợi ý ngôn ngữ, và từng ngôn ngữ có gợi ý bạn đã đóng, để website không gợi ý lại. Cơ sở pháp lý: lợi ích hợp pháp. - **`mintlify_anonymous_id`** (local storage trên docs.tablepro.app, do Mintlify đặt, chỉ khi bạn cho phép Google Analytics ở đó): ID người xem được mô tả trong mục [Website](#website). Từ chối sẽ xóa nó. Trang tài liệu giữ câu trả lời `tablepro:analytics-consent` của riêng nó. Cơ sở pháp lý: sự đồng ý. - **Cookie có tên bắt đầu bằng `crisp-client/`** (của Crisp, ví dụ `crisp-client/session/…`; 6 tháng, được gia hạn khi bạn quay lại; được đặt trên mọi trang sau khi khung chat được tải): giữ khung chat và cuộc chat của bạn qua các trang và các lần truy cập. Cơ sở pháp lý: lợi ích hợp pháp, để hỗ trợ bạn trên mọi trang. diff --git a/resources/data/legal/zh-Hans/privacy.md b/resources/data/legal/zh-Hans/privacy.md index c342d8bb..c5af401b 100644 --- a/resources/data/legal/zh-Hans/privacy.md +++ b/resources/data/legal/zh-Hans/privacy.md @@ -1,7 +1,7 @@ --- title: 隐私政策 description: TablePro 应用、网站和账户门户收集什么信息、发送到哪里、保存多久,以及如何更改或删除。 -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- 本政策涵盖 Mac 版 TablePro、iPhone 和 iPad 版 TablePro、位于 tablepro.app 的网站、位于 docs.tablepro.app 的文档,以及位于 tablepro.app/account 的账户门户。它描述各产品目前实际发送和存储的信息。两款应用均采用 AGPLv3 开源,您可在 [TablePro 仓库]({github})中查看发送下列数据的代码。 @@ -151,7 +151,7 @@ Handoff 通过 Apple 在您的设备之间传递当前连接的 ID 和打开的 - **`_ga` 和 `_ga_`**(Google Analytics Cookie,最长两年,仅在允许分析时):浏览器的随机标识符及当前访问状态。拒绝或之后更改回答时会删除。合法依据:同意。 - **`tablepro:analytics-consent`**(本地存储,直至您清除):保存对分析提问的回答,避免每个页面重复询问。网站和账户门户共用。合法依据:履行您的选择所严格必要。 - **`tablepro:attribution`**(本地存储,90 天):[网站](#website)一节所述的首访记录。不包含您的标识符,仅随结账请求发送,服务器随后丢弃。合法依据:合法利益。 -- **`theme`** 和 **`tablepro:banner-dismissed`**(本地存储,直至您清除):保存所选浅色、深色或系统主题,以及关闭的横幅和隐藏期限:30 天;若您表示已持有许可证或购买许可证,则为一年。合法依据:合法利益。 +- **`theme`**(本地存储,直至您清除):保存所选浅色、深色或系统主题。合法依据:合法利益。 - **`tablepro:language`**(本地存储,直至您清除):保存您在语言菜单或语言栏中选择的语言,以及您关闭过推荐的语言,以免再次推荐。合法依据:合法利益。 - **`mintlify_anonymous_id`**(docs.tablepro.app 的本地存储,由 Mintlify 设置,仅在您于文档中允许 Google Analytics 时):[网站](#website)一节所述的访客 ID。拒绝后即删除。文档单独保存自己的 `tablepro:analytics-consent` 回答。合法依据:同意。 - **以 `crisp-client/` 开头的 Cookie**(Crisp,如 `crisp-client/session/…`;六个月,再次访问时续期;聊天加载后每个页面都会设置):在页面和访问之间保持聊天及对话。合法依据:为每个页面提供支持的合法利益。 diff --git a/resources/data/legal/zh-Hant/privacy.md b/resources/data/legal/zh-Hant/privacy.md index ba3d394c..d5977369 100644 --- a/resources/data/legal/zh-Hant/privacy.md +++ b/resources/data/legal/zh-Hant/privacy.md @@ -1,7 +1,7 @@ --- title: 隱私權政策 description: TablePro App、網站與帳戶入口網站收集哪些資訊、傳送至何處、保存多久,以及如何變更或刪除。 -updatedAt: "2026-10-10" +updatedAt: "2026-10-22" --- 本政策涵蓋 Mac 版 TablePro、iPhone 與 iPad 版 TablePro、位於 tablepro.app 的網站、位於 docs.tablepro.app 的文件,以及位於 tablepro.app/account 的帳戶入口網站。它描述各產品目前實際傳送和儲存的資訊。兩款 App 均採用 AGPLv3 開放原始碼,您可在 [TablePro 儲存庫]({github})中查看傳送下列資料的程式碼。 @@ -151,7 +151,7 @@ Mac 上的 iCloud Sync 屬於 Starter 或 Team 授權功能,在 iPhone 與 iPa - **`_ga` 和 `_ga_`**(Google Analytics Cookie,最長兩年,僅在允許分析時):瀏覽器的隨機識別碼及目前造訪狀態。拒絕或之後變更回答時會刪除。合法依據:同意。 - **`tablepro:analytics-consent`**(本機儲存空間,直至您清除):保存對分析提問的回答,避免每個網頁重複詢問。網站和帳戶入口網站共用。合法依據:履行您的選擇所絕對必要。 - **`tablepro:attribution`**(本機儲存空間,90 天):[網站](#website)一節所述的首次造訪紀錄。不包含您的識別碼,僅隨結帳請求傳送,伺服器之後捨棄。合法依據:正當利益。 -- **`theme`** 和 **`tablepro:banner-dismissed`**(本機儲存空間,直至您清除):保存所選淺色、深色或系統外觀,以及關閉的橫幅和隱藏期限:30 天;若您表示已有授權或購買授權,則為一年。合法依據:正當利益。 +- **`theme`**(本機儲存空間,直至您清除):保存所選淺色、深色或系統外觀。合法依據:正當利益。 - **`tablepro:language`**(本機儲存空間,直至您清除):保存您在語言選單或語言列中選擇的語言,以及您關閉過建議的語言,以免再次建議。合法依據:正當利益。 - **`mintlify_anonymous_id`**(docs.tablepro.app 的本機儲存空間,由 Mintlify 設定,僅在您於文件中允許 Google Analytics 時):[網站](#website)一節所述的訪客 ID。拒絕後即刪除。文件另外保存自己的 `tablepro:analytics-consent` 回答。合法依據:同意。 - **以 `crisp-client/` 開頭的 Cookie**(Crisp,例如 `crisp-client/session/…`;六個月,再次造訪時延長;聊天載入後每個網頁都會設定):在網頁和造訪之間保持聊天與對話。合法依據:為每個網頁提供支援的正當利益。 diff --git a/resources/js/components/site/support-banner.tsx b/resources/js/components/site/support-banner.tsx index ab48d653..1fa32291 100644 --- a/resources/js/components/site/support-banner.tsx +++ b/resources/js/components/site/support-banner.tsx @@ -1,96 +1,74 @@ import { useEffect } from 'react'; import { usePage } from '@inertiajs/react'; -import { X } from 'lucide-react'; import Container from '@/components/ui/container'; import LocaleLink from '@/components/ui/locale-link'; import { textLinkClasses } from '@/components/ui/text-link'; import { useI18n } from '@/i18n'; import { trackEvent } from '@/lib/analytics'; -import { dismissBanner } from '@/lib/banner'; /** * The license banner: one line above the header asking a regular user to buy * a license, saying what it adds and what it pays for (design-system §5.3.17). * * On by default and off Pricing (`App\Support\Banner`). When shown, the shared - * `banner` prop carries its link and version, and the words come from the - * `banner` catalog in the page's language. It is a quiet `--surface` band, not - * an orange fill, and it scrolls away with the page instead of sticking. + * `banner` prop carries its link, and the words come from the `banner` catalog + * in the page's language. It cannot be closed: there is no close button, no + * stored record and no head script reading one, so every reader sees it on + * every page it belongs on. Its ground is a wash of the brand hue (app.css), + * a tint under `--foreground` text rather than an orange fill, and it scrolls + * away with the page instead of sticking. * * Visibility is CSS, settled before paint: the element renders whenever the - * page shows the banner, `html.has-banner` decides whether it is seen, and the - * head script removes that class while this browser's dismissal lasts - * (`lib/banner.ts`). Deciding it in React state would flash the bar on every - * load for every reader who had closed it. `--banner-h` (app.css) is its - * height. + * page shows the banner, `html.has-banner`, stamped by the server, decides + * whether it is seen, and `--banner-h` (app.css) is its height. The one + * exception is the iPhone and iPad page, where the head script drops the + * class for a reader on one of those devices, whose app has no license. * * One 40px line at every width, never truncated: the sentence and its link * show from 1024px, and below that the short question and the same link, so a - * phone reader is still asked whether they are a regular user. "Have a - * license? Hide this" joins from 1280px, where the line has room for it. - * TopBannerTest holds the catalog strings in every language to lengths that - * fit. The link and the dismiss button take the band's height as their - * target, and the 32px button 44px of its width. + * phone reader is still asked whether they are a regular user. TopBannerTest + * holds the catalog strings in every language to lengths that fit. The link + * takes the band's height as its target. * * It shares its slot with the language bar, which takes it over when it * applies (TopBanner, app.css). * - * It reports `license_banner_view`, `_click` and `_dismiss` to Google + * It reports `license_banner_view` and `license_banner_click` to Google * Analytics, which applies the reader's consent choice itself. */ export default function SupportBanner() { const banner = usePage().props.banner; const { m } = useI18n(); - const version = banner?.version ?? ''; + const shown = banner !== null; useEffect(() => { const root = document.documentElement.classList; // Seen only while it holds the slot: the language bar takes it over (TopBanner). - if (version !== '' && root.contains('has-banner') && !root.contains('has-language-bar')) { - trackEvent('license_banner_view', { version }); + if (shown && root.contains('has-banner') && !root.contains('has-language-bar')) { + trackEvent('license_banner_view'); } - }, [version]); + }, [shown]); if (!banner) { return null; } - function dismiss(licensed: boolean): void { - dismissBanner(version, licensed); - trackEvent('license_banner_dismiss', { version, reason: licensed ? 'licensed' : 'closed' }); - } - return ( -
- +
+

{m.banner.message} {m.banner.short} trackEvent('license_banner_click', { version })} + onClick={() => trackEvent('license_banner_click')} className={textLinkClasses('standalone', 'relative whitespace-nowrap after:absolute after:-inset-x-1 after:-inset-y-3')} > {m.banner.cta}

- -
); diff --git a/resources/js/i18n/messages/de/banner.ts b/resources/js/i18n/messages/de/banner.ts index 7a772d3f..5a1b011e 100644 --- a/resources/js/i18n/messages/de/banner.ts +++ b/resources/js/i18n/messages/de/banner.ts @@ -5,5 +5,4 @@ export default { "message": "Kostenpflichtige Tarife ergänzen Funktionen und finanzieren TablePros Entwicklung.", "short": "Optionale Tarife", "cta": "Tarife ansehen", - "licensed": "Lizenz? Hinweis ausblenden" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/de/controls.ts b/resources/js/i18n/messages/de/controls.ts index 9b040ec7..d7848ecb 100644 --- a/resources/js/i18n/messages/de/controls.ts +++ b/resources/js/i18n/messages/de/controls.ts @@ -34,6 +34,5 @@ export default { "included": "Enthalten", "notIncluded": "Nicht enthalten" }, - "dismiss": "Ausblenden", "close": "Schließen" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/en/banner.ts b/resources/js/i18n/messages/en/banner.ts index 42267bf1..5fa7ca34 100644 --- a/resources/js/i18n/messages/en/banner.ts +++ b/resources/js/i18n/messages/en/banner.ts @@ -1,22 +1,22 @@ /** * The `banner` namespace: the license banner above the header. * - * `config/banner.php` holds only the switch, the link and the dismissal - * version; the words live here, in each language. The line is addressed to a - * regular user and says what a license adds and what it pays for. It states a - * fact and never pleads, and it never says the whole app is free. + * `config/banner.php` holds only the switch and the link; the words live + * here, in each language. The line is addressed to a regular user and says + * what a license adds and what it pays for. It states a fact and never + * pleads, and it never says the whole app is free. It cannot be closed, so + * there is no "hide this" wording. * * Length limits, held by TopBannerTest in every language, in display columns * (a CJK character is two): the band is one 40px line and nothing in it may * be truncated (design-system §3.3). From 1024px the line is `message` and * the `cta` link, together within 110, and `cta` within 20. Below 1024px * `short` asks the same question before the link, together within 34: 248px - * on a 320px screen. `licensed` shows from 1280px, within 26. + * on a 320px screen. */ export default { label: 'Announcement', message: 'Paid plans add features and fund TablePro’s development.', short: 'Optional paid plans', cta: 'See plans', - licensed: 'Have a license? Hide this', }; diff --git a/resources/js/i18n/messages/en/controls.ts b/resources/js/i18n/messages/en/controls.ts index 0d811a1b..4241cbc4 100644 --- a/resources/js/i18n/messages/en/controls.ts +++ b/resources/js/i18n/messages/en/controls.ts @@ -47,6 +47,5 @@ export default { included: 'Included', notIncluded: 'Not included', }, - dismiss: 'Dismiss', close: 'Close', }; diff --git a/resources/js/i18n/messages/es/banner.ts b/resources/js/i18n/messages/es/banner.ts index e73cbd54..45ad84ad 100644 --- a/resources/js/i18n/messages/es/banner.ts +++ b/resources/js/i18n/messages/es/banner.ts @@ -5,5 +5,4 @@ export default { "message": "Los planes de pago añaden funciones y financian el desarrollo de TablePro.", "short": "Planes opcionales", "cta": "Ver planes", - "licensed": "¿Tienes licencia? Ocultar" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/es/controls.ts b/resources/js/i18n/messages/es/controls.ts index 2b13ba3e..78c876d6 100644 --- a/resources/js/i18n/messages/es/controls.ts +++ b/resources/js/i18n/messages/es/controls.ts @@ -34,6 +34,5 @@ export default { "included": "Incluido", "notIncluded": "No incluido" }, - "dismiss": "Descartar", "close": "Cerrar" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/fr/banner.ts b/resources/js/i18n/messages/fr/banner.ts index b9c16a7c..10df8d53 100644 --- a/resources/js/i18n/messages/fr/banner.ts +++ b/resources/js/i18n/messages/fr/banner.ts @@ -5,5 +5,4 @@ export default { "message": "Les forfaits payants ajoutent des fonctions et financent le développement de TablePro.", "short": "Forfaits payants", "cta": "Voir les forfaits", - "licensed": "Déjà une licence ? Masquer" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/fr/controls.ts b/resources/js/i18n/messages/fr/controls.ts index 886807ba..72da976c 100644 --- a/resources/js/i18n/messages/fr/controls.ts +++ b/resources/js/i18n/messages/fr/controls.ts @@ -34,6 +34,5 @@ export default { "included": "Inclus", "notIncluded": "Non inclus" }, - "dismiss": "Ignorer", "close": "Fermer" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/id/banner.ts b/resources/js/i18n/messages/id/banner.ts index 5df90fc2..8f362f66 100644 --- a/resources/js/i18n/messages/id/banner.ts +++ b/resources/js/i18n/messages/id/banner.ts @@ -5,5 +5,4 @@ export default { "message": "Paket berbayar menambahkan fitur dan mendanai pengembangan TablePro.", "short": "Paket opsional", "cta": "Lihat paket", - "licensed": "Punya lisensi? Sembunyikan" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/id/controls.ts b/resources/js/i18n/messages/id/controls.ts index cb5d6f4d..0f89ea5e 100644 --- a/resources/js/i18n/messages/id/controls.ts +++ b/resources/js/i18n/messages/id/controls.ts @@ -34,6 +34,5 @@ export default { "included": "Termasuk", "notIncluded": "Tidak termasuk" }, - "dismiss": "Abaikan", "close": "Tutup" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/it/banner.ts b/resources/js/i18n/messages/it/banner.ts index 2417df3e..28b983ec 100644 --- a/resources/js/i18n/messages/it/banner.ts +++ b/resources/js/i18n/messages/it/banner.ts @@ -5,5 +5,4 @@ export default { "message": "I piani a pagamento aggiungono funzionalità e finanziano lo sviluppo di TablePro.", "short": "Piani facoltativi", "cta": "Vedi i piani", - "licensed": "Hai la licenza? Nascondi" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/it/controls.ts b/resources/js/i18n/messages/it/controls.ts index 75fb4971..4b08fe9c 100644 --- a/resources/js/i18n/messages/it/controls.ts +++ b/resources/js/i18n/messages/it/controls.ts @@ -34,6 +34,5 @@ export default { "included": "Incluso", "notIncluded": "Non incluso" }, - "dismiss": "Ignora", "close": "Chiudi" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/ja/banner.ts b/resources/js/i18n/messages/ja/banner.ts index 7e5356b7..15705474 100644 --- a/resources/js/i18n/messages/ja/banner.ts +++ b/resources/js/i18n/messages/ja/banner.ts @@ -5,5 +5,4 @@ export default { "message": "有料プランは機能を追加し、TablePro の開発を支えます。", "short": "任意の有料プラン", "cta": "プランを見る", - "licensed": "購入済みなら非表示に" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/ja/controls.ts b/resources/js/i18n/messages/ja/controls.ts index 4403f414..54549837 100644 --- a/resources/js/i18n/messages/ja/controls.ts +++ b/resources/js/i18n/messages/ja/controls.ts @@ -34,6 +34,5 @@ export default { "included": "含まれます", "notIncluded": "含まれません" }, - "dismiss": "非表示", "close": "閉じる" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/ko/banner.ts b/resources/js/i18n/messages/ko/banner.ts index 557936f1..b74a61d8 100644 --- a/resources/js/i18n/messages/ko/banner.ts +++ b/resources/js/i18n/messages/ko/banner.ts @@ -5,5 +5,4 @@ export default { "message": "유료 플랜은 기능을 추가하고 TablePro 개발을 지원합니다.", "short": "선택 사항인 유료 플랜", "cta": "플랜 보기", - "licensed": "이미 구매했나요? 숨기기" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/ko/controls.ts b/resources/js/i18n/messages/ko/controls.ts index 5015d78f..f466f987 100644 --- a/resources/js/i18n/messages/ko/controls.ts +++ b/resources/js/i18n/messages/ko/controls.ts @@ -34,6 +34,5 @@ export default { "included": "포함됨", "notIncluded": "포함되지 않음" }, - "dismiss": "숨기기", "close": "닫기" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/pt-BR/banner.ts b/resources/js/i18n/messages/pt-BR/banner.ts index 0b6bbc1d..af2f70fa 100644 --- a/resources/js/i18n/messages/pt-BR/banner.ts +++ b/resources/js/i18n/messages/pt-BR/banner.ts @@ -5,5 +5,4 @@ export default { "message": "Os planos pagos adicionam recursos e financiam o desenvolvimento do TablePro.", "short": "Planos opcionais", "cta": "Veja os planos", - "licensed": "Já tem licença? Ocultar" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/pt-BR/controls.ts b/resources/js/i18n/messages/pt-BR/controls.ts index 014729fb..609914bd 100644 --- a/resources/js/i18n/messages/pt-BR/controls.ts +++ b/resources/js/i18n/messages/pt-BR/controls.ts @@ -34,6 +34,5 @@ export default { "included": "Incluído", "notIncluded": "Não incluído" }, - "dismiss": "Dispensar", "close": "Fechar" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/vi/banner.ts b/resources/js/i18n/messages/vi/banner.ts index a6ac9396..12fd353b 100644 --- a/resources/js/i18n/messages/vi/banner.ts +++ b/resources/js/i18n/messages/vi/banner.ts @@ -5,5 +5,4 @@ export default { message: 'Gói trả phí bổ sung tính năng và tài trợ phát triển TablePro.', short: 'Gói trả phí tùy chọn', cta: 'Xem các gói', - licensed: 'Đã có license? Ẩn đi', } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/vi/controls.ts b/resources/js/i18n/messages/vi/controls.ts index b81f58d0..67b2d472 100644 --- a/resources/js/i18n/messages/vi/controls.ts +++ b/resources/js/i18n/messages/vi/controls.ts @@ -34,6 +34,5 @@ export default { included: 'Có', notIncluded: 'Không có', }, - dismiss: 'Ẩn thông báo', close: 'Đóng', } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/zh-Hans/banner.ts b/resources/js/i18n/messages/zh-Hans/banner.ts index 5fa594e3..ce97c6ec 100644 --- a/resources/js/i18n/messages/zh-Hans/banner.ts +++ b/resources/js/i18n/messages/zh-Hans/banner.ts @@ -5,5 +5,4 @@ export default { "message": "付费方案提供额外功能,并支持 TablePro 的开发。", "short": "可选付费方案", "cta": "查看方案", - "licensed": "已有许可证?隐藏" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/zh-Hans/controls.ts b/resources/js/i18n/messages/zh-Hans/controls.ts index afa7766e..4da00eb0 100644 --- a/resources/js/i18n/messages/zh-Hans/controls.ts +++ b/resources/js/i18n/messages/zh-Hans/controls.ts @@ -34,6 +34,5 @@ export default { "included": "包含", "notIncluded": "不包含" }, - "dismiss": "隐藏", "close": "关闭" } satisfies Messages['controls']; diff --git a/resources/js/i18n/messages/zh-Hant/banner.ts b/resources/js/i18n/messages/zh-Hant/banner.ts index 5b0ca68d..a31a9c14 100644 --- a/resources/js/i18n/messages/zh-Hant/banner.ts +++ b/resources/js/i18n/messages/zh-Hant/banner.ts @@ -5,5 +5,4 @@ export default { "message": "付費方案提供額外功能,並支持 TablePro 的開發。", "short": "可選付費方案", "cta": "查看方案", - "licensed": "已有授權?隱藏" } satisfies Messages['banner']; diff --git a/resources/js/i18n/messages/zh-Hant/controls.ts b/resources/js/i18n/messages/zh-Hant/controls.ts index e087c654..9e4c3833 100644 --- a/resources/js/i18n/messages/zh-Hant/controls.ts +++ b/resources/js/i18n/messages/zh-Hant/controls.ts @@ -34,6 +34,5 @@ export default { "included": "包含", "notIncluded": "不包含" }, - "dismiss": "隱藏", "close": "關閉" } satisfies Messages['controls']; diff --git a/resources/js/lib/banner.ts b/resources/js/lib/banner.ts deleted file mode 100644 index 0ed97de7..00000000 --- a/resources/js/lib/banner.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * How long a closed license banner stays closed. - * - * The record lives in `localStorage` under `tablepro:banner-dismissed` as - * `{"version": "3", "until": 1767225600000}`: the banner version the reader - * closed and the moment it may come back. `version` is `"*"` for a reader who - * has a license, either because they said so or because they just bought one - * (the account app's thank-you page writes the same record), which covers every - * later message too. - * - * The root template's head script decides visibility before first paint with - * the same rule as `isBannerDismissed`; tests/js/banner.test.ts runs that - * script against this function. - * - * Pure apart from the browser globals `dismissBanner` touches, and with no - * imports, so `node --test` loads it directly. - */ - -export const BANNER_STORAGE_KEY = 'tablepro:banner-dismissed'; - -/** A license holder's record matches every banner version. */ -export const ANY_VERSION = '*'; - -/** Closing the banner hides it this long; the same message may then return. */ -export const SNOOZE_DAYS = 30; - -/** "Have a license? Hide this", or a purchase, hides it this long, for every version. */ -export const LICENSED_DAYS = 365; - -const DAY_MS = 24 * 60 * 60 * 1000; - -export interface DismissalRecord { - version: string; - until: number; -} - -/** The stored value for a dismissal of `version` that lasts `days` from `now`. */ -export function dismissalRecord(version: string, days: number, now: number): string { - return JSON.stringify({ version, until: now + days * DAY_MS } satisfies DismissalRecord); -} - -/** - * Whether the stored value hides the banner at `version` at `now`. Anything - * unreadable, including the bare version string earlier releases stored, - * hides nothing. - */ -export function isBannerDismissed(stored: string | null, version: string, now: number): boolean { - let record: unknown; - - try { - record = JSON.parse(stored ?? 'null'); - } catch { - return false; - } - - if (record === null || typeof record !== 'object') { - return false; - } - - const { version: closed, until } = record as Partial; - - return typeof until === 'number' && until > now && (closed === ANY_VERSION || closed === version); -} - -/** - * Hides the banner now and records it: for `SNOOZE_DAYS` at this version, or - * for `LICENSED_DAYS` at every version when the reader has a license. - */ -export function dismissBanner(version: string, licensed: boolean): void { - try { - window.localStorage.setItem( - BANNER_STORAGE_KEY, - licensed ? dismissalRecord(ANY_VERSION, LICENSED_DAYS, Date.now()) : dismissalRecord(version, SNOOZE_DAYS, Date.now()), - ); - } catch { - // No storage: the dismissal lasts for this page only. - } - - document.documentElement.classList.remove('has-banner'); -} diff --git a/resources/js/types/shared-props.ts b/resources/js/types/shared-props.ts index 5b43350d..1d05c53b 100644 --- a/resources/js/types/shared-props.ts +++ b/resources/js/types/shared-props.ts @@ -35,10 +35,9 @@ export interface SwitcherItem { fallback: boolean; } -/** The banner's switch, link and dismissal version; its words are the `banner` catalog's. */ +/** The banner's link; its words are the `banner` catalog's. It cannot be closed, so it has no version. */ export interface BannerProp { href: string; - version: string; } export interface SharedProps extends Record { diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 407600ae..acb73845 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -8,9 +8,9 @@ banner is seen and how much room it takes above the header. It is set here rather than in React because `--banner-h` has to be settled by the time the first frame renders: resolving it in state would drop the header 40px on - every load for every reader who had already closed the bar. It is set only - on pages that show the banner (App\Support\Banner), and the script further - down removes it again while this browser's dismissal lasts. + the first frame of every load. It is set only on pages that show the banner + (App\Support\Banner). The banner cannot be closed, so nothing in the + reader's browser removes it, except the iPhone and iPad rule further down. No `overflow-x: hidden` on or : it masked horizontal-scroll regressions instead of preventing them. Anything wide scrolls inside its @@ -105,38 +105,22 @@ @endif @inertiaHead - @if(\App\Support\Banner::shownOn(request())) + @if(\App\Support\Banner::shownOn(request()) && \App\Support\Banner::hiddenOnIosDevices(request())) {{-- - The banner dismissal, before first paint. - - The record is `{"version", "until"}` (resources/js/lib/banner.ts, - `isBannerDismissed`, which this mirrors and tests/js/banner.test.ts - runs against). It hides the bar until `until` for the version the - reader closed, or for every version when it is "*": a reader who - has a license, or just bought one. Bumping `banner.version` brings - the bar back for a new message. Wrapped, because localStorage - throws outright in a private window rather than returning null — - and a banner is not worth a blank page. - - Emitted only where the banner is shown, so elsewhere there is no - element, no class, no reserved height and no dead script. - The license is for the Mac app, so on the iPhone and iPad page a - reader on one of those devices gets no banner either. + reader on one of those devices gets no banner, decided before first + paint from the `ios` class the script above set. It is the only + thing that ever removes `has-banner` in the browser: the banner + cannot be closed, so there is no stored record, no version and no + script reading one. + + Emitted only on that page, so elsewhere there is no dead script. --}} @endif diff --git a/tests/Feature/Chrome/SiteChromeTest.php b/tests/Feature/Chrome/SiteChromeTest.php index c938eb69..c9984d32 100644 --- a/tests/Feature/Chrome/SiteChromeTest.php +++ b/tests/Feature/Chrome/SiteChromeTest.php @@ -370,10 +370,9 @@ function chromeLocales(): array it('gives the small controls of the header and the banner a finger-sized target', function (): void { $read = static fn(string $file): string => (string) file_get_contents(resource_path("js/components/site/{$file}")); - // Hit-tested at 390px, the header Download was 92 by 32 and the banner's dismiss 32 by 32. + // Hit-tested at 390px, the header Download was 92 by 32 and the banner's link one line of small text. expect($read('site-header.tsx'))->toContain("buttonClasses('primary', 'sm', 'relative whitespace-nowrap after:absolute after:inset-x-0 after:-inset-y-[7px]')"); - expect($read('support-banner.tsx'))->toContain('after:absolute after:-inset-x-1 after:-inset-y-3') - ->toContain('after:absolute after:-inset-1.5'); + expect($read('support-banner.tsx'))->toContain('after:absolute after:-inset-x-1 after:-inset-y-3'); }); it('gives the footer bar\'s controls a 44px target on a touch screen', function (): void { diff --git a/tests/Feature/Contracts/PublicContractsTest.php b/tests/Feature/Contracts/PublicContractsTest.php index 39eb6ec2..a66b0334 100644 --- a/tests/Feature/Contracts/PublicContractsTest.php +++ b/tests/Feature/Contracts/PublicContractsTest.php @@ -92,13 +92,14 @@ function contractSources(): array $names = []; foreach (contractSources() as $file => $text) { - preg_match_all("/trackEvent\('([a-z_]+)', \{([^}]*)\}/", $text, $events, PREG_SET_ORDER); + // `trackEvent('name')` carries no parameters; `trackEvent('name', { … })` carries the keys inside the braces. + preg_match_all("/trackEvent\('([a-z_]+)'(?:, \{([^}]*)\})?\)/", $text, $events, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL); foreach ($events as [, $name, $params]) { // `{ tier: tier.key, cycle }`: a key before a colon, or a shorthand property. $keys = array_values(array_filter(array_map( static fn(string $pair): string => preg_match('/^\s*(\w+)/', $pair, $key) ? $key[1] : '', - explode(',', $params), + explode(',', $params ?? ''), ))); $names[$name][] = $keys; @@ -117,9 +118,9 @@ function contractSources(): array 'download_click' => ['location', 'platform'], 'checkout_started' => ['tier', 'cycle'], 'newsletter_signup_clicked' => ['source'], - 'license_banner_view' => ['version'], - 'license_banner_click' => ['version'], - 'license_banner_dismiss' => ['version', 'reason'], + // The banner cannot be closed, so there is no dismiss event, and it has no version to report. + 'license_banner_view' => [], + 'license_banner_click' => [], ]; foreach ($names as $name => $sets) { @@ -144,7 +145,7 @@ function contractSources(): array expect((string) file_get_contents(resource_path('js/components/databases/download-band.tsx')))->toContain('toContain('location={location}'); }); -it('keeps the four storage keys both apps read', function (): void { +it('keeps the three storage keys both apps read', function (): void { $key = static function (string $file, string $constant): ?string { preg_match("/{$constant} = '([^']+)'/", (string) file_get_contents(resource_path("js/{$file}")), $match); @@ -154,13 +155,13 @@ function contractSources(): array Assert::assertSame('tablepro:analytics-consent', $key('lib/consent.ts', 'CONSENT_STORAGE_KEY')); Assert::assertSame('theme', $key('lib/theme.ts', 'THEME_STORAGE_KEY')); Assert::assertSame('tablepro:attribution', $key('lib/attribution.ts', 'ATTRIBUTION_STORAGE_KEY')); - Assert::assertSame('tablepro:banner-dismissed', $key('lib/banner.ts', 'BANNER_STORAGE_KEY')); $blade = (string) file_get_contents(resource_path('views/app.blade.php')); $partial = (string) file_get_contents(resource_path('views/partials/head-theme.blade.php')); + // The banner cannot be closed, so the template reads no record of one. expect($blade)->toContain("localStorage.getItem('tablepro:analytics-consent')") - ->toContain("localStorage.getItem('tablepro:banner-dismissed')"); + ->not->toContain('banner-dismissed'); expect($partial)->toContain("localStorage.getItem('theme')"); }); diff --git a/tests/Feature/Landing/LegalPagesTest.php b/tests/Feature/Landing/LegalPagesTest.php index d9c3fc6e..6f9ac3b3 100644 --- a/tests/Feature/Landing/LegalPagesTest.php +++ b/tests/Feature/Landing/LegalPagesTest.php @@ -144,8 +144,8 @@ function legalShape(string $document, string $locale): array ]); it('formats the update date in each language on the server', function (): void { - get('/privacy')->assertInertia(fn(AssertableInertia $page) => $page->where('document.updatedAtFormatted', 'October 10, 2026')); - get('/vi/privacy')->assertInertia(fn(AssertableInertia $page) => $page->where('document.updatedAtFormatted', '10 tháng 10 năm 2026')); + get('/privacy')->assertInertia(fn(AssertableInertia $page) => $page->where('document.updatedAtFormatted', 'October 22, 2026')); + get('/vi/privacy')->assertInertia(fn(AssertableInertia $page) => $page->where('document.updatedAtFormatted', '22 tháng 10 năm 2026')); }); it('indexes each document in every supported language', function (string $route): void { @@ -201,10 +201,13 @@ function legalShape(string $document, string $locale): array $privacy = legalSource('privacy', $locale); $cookies = legalSection('privacy', $locale, 'cookies'); - foreach (['_ga', 'tablepro:analytics-consent', 'tablepro:attribution', 'crisp-client/', 'tablepro-session', 'XSRF-TOKEN', 'theme', 'tablepro:banner-dismissed'] as $name) { + foreach (['_ga', 'tablepro:analytics-consent', 'tablepro:attribution', 'crisp-client/', 'tablepro-session', 'XSRF-TOKEN', 'theme'] as $name) { expect($cookies)->toContain($name); } + // The banner cannot be closed, so nothing is stored about it and the policy must not say otherwise. + expect($cookies)->not->toContain('banner'); + expect($privacy)->toContain('{#cookies}')->toContain('Google Analytics')->toContain($settings); expect(mb_strtolower($privacy))->toContain($basis); expect($cookies)->toContain(LegalDocuments::COOKIE_SETTINGS_MARKER); diff --git a/tests/Feature/Landing/TopBannerTest.php b/tests/Feature/Landing/TopBannerTest.php index 6a3bc38f..16b6ed8e 100644 --- a/tests/Feature/Landing/TopBannerTest.php +++ b/tests/Feature/Landing/TopBannerTest.php @@ -33,17 +33,15 @@ function bannerLocales(): array $defaults = require base_path('config/banner.php'); expect($defaults['enabled'])->toBeTrue(); - expect(array_keys($defaults))->toEqualCanonicalizing(['enabled', 'href', 'version']); + expect(array_keys($defaults))->toEqualCanonicalizing(['enabled', 'href']); }); it('leaves nothing behind when it is switched off', function (): void { config(['banner.enabled' => false]); $response = get('/download')->assertOk(); - $html = $response->getContent(); - Assert::assertStringNotContainsString('has-banner', $html, 'A disabled banner must reserve no height'); - Assert::assertStringNotContainsString('tablepro:banner-dismissed', $html, 'A disabled banner must not ship a dismissal script'); + Assert::assertStringNotContainsString('has-banner', $response->getContent(), 'A disabled banner must reserve no height'); $response->assertInertia(fn(AssertableInertia $page) => $page->where('banner', null)); }); @@ -54,10 +52,8 @@ function bannerLocales(): array $path = "{$prefix}/pricing"; $response = get($path)->assertOk(); - $html = $response->getContent(); - Assert::assertStringNotContainsString('has-banner', $html, "{$path} must reserve no banner height"); - Assert::assertStringNotContainsString('tablepro:banner-dismissed', $html, "{$path} must not ship a dismissal script"); + Assert::assertStringNotContainsString('has-banner', $response->getContent(), "{$path} must reserve no banner height"); $response->assertInertia(fn(AssertableInertia $page) => $page->where('banner', null)); })->with(['English' => '', 'Vietnamese' => '/vi']); @@ -77,48 +73,49 @@ function bannerLocales(): array Assert::assertStringNotContainsString($rule, get("{$prefix}/download")->assertOk()->getContent()); })->with(['English' => '', 'Vietnamese' => '/vi']); -it('sends only its link and version to the page, so the words come from the catalog', function (): void { - config(['banner.enabled' => true, 'banner.href' => '/pricing', 'banner.version' => '7']); +it('sends only its link to the page, so the words come from the catalog', function (): void { + config(['banner.enabled' => true, 'banner.href' => '/pricing']); - get('/download')->assertInertia(fn(AssertableInertia $page) => $page->where('banner', ['href' => '/pricing', 'version' => '7'])); + get('/download')->assertInertia(fn(AssertableInertia $page) => $page->where('banner', ['href' => '/pricing'])); }); -it('settles the dismissal before the first paint, per version, with an end date', function (): void { - config(['banner.enabled' => true, 'banner.version' => '7']); +it('cannot be closed: no close button, no stored record, no head script reading one', function (): void { + config(['banner.enabled' => true]); $html = get('/download')->assertOk()->getContent(); + $component = (string) file_get_contents(resource_path('js/components/site/support-banner.tsx')); + $blade = (string) file_get_contents(resource_path('views/app.blade.php')); - // Decided in React state, the bar would drop the header 40px on every load for a reader who had closed it. - $read = strpos($html, "JSON.parse(localStorage.getItem('tablepro:banner-dismissed')"); - + // Stamped by the server and, off /ios, touched by nothing in the head: every reader has the band on every load. expect(str_contains($html, ''))->toBeTrue(); - Assert::assertNotFalse($read, 'The dismissal must be settled by an inline script reading the stored record'); - Assert::assertLessThan(strpos($html, '', $read) - $read); + Assert::assertStringNotContainsString('banner-dismissed', $html); + Assert::assertStringNotContainsString("classList.remove('has-banner')", $html); - expect($script)->toContain('record.until > Date.now()') - ->toContain("record.version === '*'") - ->toContain('record.version === "7"'); + expect($component)->not->toContain('dismiss') + ->not->toContain('not->toContain('localStorage') + ->not->toContain("from '@/lib/banner'"); + expect($blade)->not->toContain('banner-dismissed')->not->toContain('banner.version'); + expect(file_exists(resource_path('js/lib/banner.ts')))->toBeFalse('The dismissal library went with the dismissal'); - // Wrapped, because localStorage throws in a private window, and a banner is not worth a broken head. - $guard = strrpos(substr($html, 0, $read), 'try {'); - Assert::assertNotFalse($guard, 'The dismissal read must be wrapped in try'); - Assert::assertLessThan(80, $read - $guard, 'The dismissal read must be the first thing inside its try'); + foreach (bannerLocales() as $locale) { + expect(bannerCatalog($locale))->not->toHaveKey('licensed'); + } }); -it('dismisses under the key the head script reads', function (): void { - $lib = (string) file_get_contents(resource_path('js/lib/banner.ts')); +it('is a tint of the brand hue under text the design system rates, never an accent fill', function (): void { + $css = (string) file_get_contents(resource_path('css/app.css')); $component = (string) file_get_contents(resource_path('js/components/site/support-banner.tsx')); - $blade = (string) file_get_contents(resource_path('views/app.blade.php')); - - preg_match("/BANNER_STORAGE_KEY = '([^']+)'/", $lib, $key); - Assert::assertSame('tablepro:banner-dismissed', $key[1] ?? null); - Assert::assertStringContainsString("localStorage.getItem('{$key[1]}')", $blade); - expect($component)->toContain("import { dismissBanner } from '@/lib/banner';") - ->toContain('dismiss(true)') - ->toContain('dismiss(false)'); + // 14% of --accent over the page colour measures 5.1:1 under --accent-text in light; an accent fill would not. + // In oklab: the neutral tokens write their hue as 0, so an oklch mix would turn the brand's 55 towards red. + expect($css)->toContain("color-mix(in oklab, var(--accent) 14%, var(--background))") + ->toContain("color-mix(in oklab, var(--accent) 6%, var(--background))") + ->toContain('border-color: color-mix(in oklab, var(--accent) 30%, var(--rule));') + ->toContain("@media (forced-colors: active) {\n .support-banner {\n background-image: none;"); + expect($component)->toContain('className="support-banner border-b bg-surface"') + ->toContain('text-foreground') + ->not->toContain('bg-accent'); }); it('sits above the sticky header and scrolls away with the page', function (): void { @@ -138,13 +135,12 @@ function bannerLocales(): array it('keeps its wording to one line, in every language', function (string $locale): void { $copy = bannerCatalog($locale); - expect($copy)->toHaveKeys(['label', 'message', 'short', 'cta', 'licensed']); + expect($copy)->toHaveKeys(['label', 'message', 'short', 'cta']); - // Display columns, a CJK character two: about 920px of room from 1024px, and 248px beside the dismiss button at 320px. + // Display columns, a CJK character two: about 920px of room from 1024px, and 248px on a 320px screen. Assert::assertLessThanOrEqual(110, mb_strwidth($copy['message']) + mb_strwidth($copy['cta']), "{$locale}: the sentence and its link will not fit one line at 1024px"); Assert::assertLessThanOrEqual(20, mb_strwidth($copy['cta']), "{$locale}: the call to action is too long"); Assert::assertLessThanOrEqual(34, mb_strwidth($copy['short']) + 1 + mb_strwidth($copy['cta']), "{$locale}: the short question and the link will not fit a 320px screen"); - Assert::assertLessThanOrEqual(26, mb_strwidth($copy['licensed']), "{$locale}: the license holder's link will not fit at 1280px"); })->with(bannerLocales(...)); it('asks the phone reader whether they are a regular user, before the link', function (): void { @@ -206,7 +202,7 @@ function bannerLocales(): array Assert::assertStringContainsString(htmlspecialchars($copy['message'], ENT_QUOTES), $banner, "{$path} shows the banner in the wrong language"); Assert::assertStringContainsString($locale === 'vi' ? 'href="/vi/pricing"' : 'href="/pricing"', $banner, "{$path}: the banner link must stay in the page language"); - Assert::assertStringContainsString(htmlspecialchars($copy['licensed'], ENT_QUOTES), $banner, "{$path}: the license holder's link is missing"); + Assert::assertStringNotContainsString('') - (int) strpos($banner, ' { const calls = installBrowser(answer); - trackEvent('license_banner_view', { version: '3' }); + trackEvent('license_banner_view'); trackDownload('hero'); assert.deepEqual(calls, []); diff --git a/tests/js/banner.test.ts b/tests/js/banner.test.ts deleted file mode 100644 index 429a48cf..00000000 --- a/tests/js/banner.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -import { - ANY_VERSION, - BANNER_STORAGE_KEY, - LICENSED_DAYS, - SNOOZE_DAYS, - dismissalRecord, - dismissBanner, - isBannerDismissed, -} from '../../resources/js/lib/banner.ts'; - -/* - * How long a closed license banner stays closed, and the head script that - * applies it before first paint. Run with `npm run test:js`. - * - * The head script in app.blade.php cannot import this module, so it repeats - * the rule in plain JavaScript; the last test runs that script against - * `isBannerDismissed` for every case, so the two cannot drift. - */ - -const NOW = Date.UTC(2026, 9, 5); -const DAY = 24 * 60 * 60 * 1000; - -const cases: Array<[string, string | null, string, boolean]> = [ - ['nothing stored', null, '3', false], - ['the bare version string older releases stored', '3', '3', false], - ['unreadable JSON', '{nope', '3', false], - ['closed this version, still within its days', dismissalRecord('3', SNOOZE_DAYS, NOW - DAY), '3', true], - ['closed this version, its days are over', dismissalRecord('3', SNOOZE_DAYS, NOW - (SNOOZE_DAYS + 1) * DAY), '3', false], - ['closed an earlier version', dismissalRecord('2', SNOOZE_DAYS, NOW - DAY), '3', false], - ['has a license, within the year', dismissalRecord(ANY_VERSION, LICENSED_DAYS, NOW - 100 * DAY), '3', true], - ['has a license, a new message', dismissalRecord(ANY_VERSION, LICENSED_DAYS, NOW - DAY), '9', true], - ['had a license over a year ago', dismissalRecord(ANY_VERSION, LICENSED_DAYS, NOW - (LICENSED_DAYS + 1) * DAY), '3', false], - ['a record without an end date', JSON.stringify({ version: '3' }), '3', false], -]; - -test('a closed banner stays closed for its days, at its version', () => { - for (const [name, stored, version, hidden] of cases) { - assert.equal(isBannerDismissed(stored, version, NOW), hidden, name); - } -}); - -test('closing it snoozes this version for 30 days, and a license hides every version for a year', () => { - assert.equal(SNOOZE_DAYS, 30); - assert.equal(LICENSED_DAYS, 365); - assert.deepEqual(JSON.parse(dismissalRecord('3', SNOOZE_DAYS, NOW)), { version: '3', until: NOW + 30 * DAY }); - assert.deepEqual(JSON.parse(dismissalRecord(ANY_VERSION, LICENSED_DAYS, NOW)), { version: '*', until: NOW + 365 * DAY }); -}); - -test('dismissing writes the record and hides the bar at once, even without storage', () => { - const removed: string[] = []; - const stored: Record = {}; - - Object.assign(globalThis, { - document: { documentElement: { classList: { remove: (name: string) => removed.push(name) } } }, - window: { localStorage: { setItem: (key: string, value: string) => (stored[key] = value) } }, - }); - - dismissBanner('3', false); - assert.equal(JSON.parse(stored[BANNER_STORAGE_KEY]).version, '3'); - - dismissBanner('3', true); - assert.equal(JSON.parse(stored[BANNER_STORAGE_KEY]).version, '*'); - assert.deepEqual(removed, ['has-banner', 'has-banner']); - - Object.assign(globalThis, { - window: { - localStorage: { - setItem: () => { - throw new Error('private window'); - }, - }, - }, - }); - - dismissBanner('3', false); - assert.equal(removed.length, 3, 'the bar still goes away for this page'); -}); - -test('the head script hides the bar exactly when isBannerDismissed says so', () => { - const blade = readFileSync(new URL('../../resources/views/app.blade.php', import.meta.url), 'utf8'); - const start = blade.indexOf('var record = JSON.parse('); - const end = blade.indexOf('} catch (e) {}', start); - - assert.ok(start > 0 && end > start, 'the dismissal script is in app.blade.php'); - - const body = blade.slice(start, end).replace("@json((string) config('banner.version'))", 'VERSION'); - - for (const [name, stored, version, hidden] of cases) { - const removed: string[] = []; - // The script's own guard, as it stands in the template around this body. - const run = new Function('localStorage', 'document', 'Date', 'VERSION', `try { ${body} } catch (e) {}`); - - run( - { getItem: (key: string) => (key === BANNER_STORAGE_KEY ? stored : null) }, - { documentElement: { classList: { remove: (cls: string) => removed.push(cls) } } }, - { now: () => NOW }, - version, - ); - - assert.equal(removed.includes('has-banner'), hidden, `head script: ${name}`); - assert.equal(isBannerDismissed(stored, version, NOW), hidden, `lib: ${name}`); - } -}); diff --git a/tests/js/device.test.ts b/tests/js/device.test.ts index 88e055bc..4ea72fbf 100644 --- a/tests/js/device.test.ts +++ b/tests/js/device.test.ts @@ -66,7 +66,7 @@ test('the head script marks exactly the devices classifyDevice calls ios', () => const end = blade.indexOf('})();', start); assert.ok(start > 0 && end > start, 'the device script is in app.blade.php'); - assert.ok(start < blade.indexOf("localStorage.getItem('tablepro:banner-dismissed')"), 'the device is known before the banner script reads it'); + assert.ok(start < blade.indexOf("document.documentElement.classList.remove('has-banner')"), 'the device is known before the banner rule reads it'); const run = new Function('navigator', 'document', blade.slice(start, end));