From 108e0a8a8257a17b117a726ad472be6f4b06cc56 Mon Sep 17 00:00:00 2001 From: apphane Date: Wed, 9 Sep 2026 16:40:08 +0000 Subject: [PATCH 1/2] chore(mise): auto-install locked npm dependencies --- .config/mise/conf.d/tasks-dev.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.config/mise/conf.d/tasks-dev.toml b/.config/mise/conf.d/tasks-dev.toml index 6a10b1b..bd63ee2 100644 --- a/.config/mise/conf.d/tasks-dev.toml +++ b/.config/mise/conf.d/tasks-dev.toml @@ -1,3 +1,10 @@ +# Mise repairs missing/stale dependencies before every task in this checkout. +[deps.npm] +auto = true +sources = ["package-lock.json"] +outputs = ["node_modules/.package-lock.json"] +run = "npm ci --no-audit --no-fund" + [tasks.dev] description = "Start the Astro development server (binds localhost:4321 by default)" run = "npm run dev" From e89e0a9707b15e4ce4e9136b015d1a92de6752c2 Mon Sep 17 00:00:00 2001 From: apphane Date: Thu, 10 Sep 2026 11:44:57 +0000 Subject: [PATCH 2/2] feat(analytics): add opt-in PostHog usage tracking - Add localized consent controls with immediate opt-in and opt-out handling - Proxy cookieless PostHog ingestion through the app and Cloudflare Pages worker --- astro.config.mjs | 13 ++++ public/_worker.js | 15 ++++ src/components/Analytics.astro | 53 ++++++++++++++ src/components/CityPage.astro | 43 ++++++++++++ src/lib/i18n.ts | 24 +++++++ src/pages/de/index.astro | 2 + src/pages/en/index.astro | 2 + src/pages/index.astro | 2 + src/pages/ru/index.astro | 2 + src/styles/global.css | 122 +++++++++++++++++++++++++++++++++ 10 files changed, 278 insertions(+) create mode 100644 public/_worker.js create mode 100644 src/components/Analytics.astro diff --git a/astro.config.mjs b/astro.config.mjs index 131bb1c..34efe3d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -16,4 +16,17 @@ export default defineConfig({ host: true, allowedHosts: ['apphane.exe.xyz'], }, + // Cookieless PostHog needs a same-origin ingest proxy so the server can + // hash IP+user-agent. Same proxy in production via public/_worker.js. + vite: { + server: { + proxy: { + '/ingest': { + target: 'https://eu.i.posthog.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/ingest/, ''), + }, + }, + }, + }, }); diff --git a/public/_worker.js b/public/_worker.js new file mode 100644 index 0000000..591e38a --- /dev/null +++ b/public/_worker.js @@ -0,0 +1,15 @@ +// Cloudflare Pages advanced-mode worker (copied to dist/ by Astro). +// Serves static assets as usual and reverse-proxies /ingest/* to PostHog EU +// for cookieless tracking — the server-side IP+UA hash requires it. +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname.startsWith("/ingest/")) { + const target = new URL(request.url); + target.hostname = "eu.i.posthog.com"; + target.pathname = url.pathname.slice("/ingest".length); + return fetch(new Request(target, request)); + } + return env.ASSETS.fetch(request); + }, +}; diff --git a/src/components/Analytics.astro b/src/components/Analytics.astro new file mode 100644 index 0000000..b9e1fbd --- /dev/null +++ b/src/components/Analytics.astro @@ -0,0 +1,53 @@ +--- +// Opt-in PostHog, default off. +// +// The ask lives where it has context: a checkbox in the directions dialog +// ("support the service by sharing anonymous usage statistics"). Until the +// visitor checks it, no analytics script loads and nothing is stored or +// sent. Checking it is explicit, informed consent (GDPR Art. 4(11) shape); +// unchecking stops capture and wipes all PostHog storage immediately. +// +// Init happens live (no reload) so the dialog flow is never interrupted: +// the checkbox handler calls window.__eczaneInitPosthog() on check. +// +// DNT/GPC is honored as a standing opt-out even over a stored "on". +const key = import.meta.env.PUBLIC_POSTHOG_KEY ?? ""; +--- + +{ + key && ( + + ) +} diff --git a/src/components/CityPage.astro b/src/components/CityPage.astro index e3322ff..2ddfd1d 100644 --- a/src/components/CityPage.astro +++ b/src/components/CityPage.astro @@ -10,6 +10,7 @@ import { cityPath, } from "../lib/i18n"; import PharmacyCard from "./PharmacyCard.astro"; +import Analytics from "./Analytics.astro"; import MapView from "./MapView.astro"; import antalyaData from "../data/antalya.json"; import "../styles/global.css"; @@ -294,6 +295,17 @@ const runtimeStrings = { {t.directionsOsm} +
+ + +
+ +
+ {t.telemetryDetailsLabel} +

{t.telemetryDetails}

+
+
+
@@ -484,6 +496,36 @@ const runtimeStrings = { directionsTrigger = null; }); + // --- Telemetry opt-in ("support the service") --- + // Default off: Analytics.astro loads nothing until this flips on. + // Both directions happen live — no reload, the dialog stays open. + const telemetryInput = document.getElementById( + "telemetry-consent", + ) as HTMLInputElement | null; + if (telemetryInput) { + try { + telemetryInput.checked = + localStorage.getItem("eczane.telemetry") === "on"; + } catch {} + telemetryInput.addEventListener("change", () => { + try { + localStorage.setItem( + "eczane.telemetry", + telemetryInput.checked ? "on" : "off", + ); + if (telemetryInput.checked) { + window.__eczaneInitPosthog?.(); + } else { + // Stop captures now, then wipe everything PostHog stored. + window.posthog?.opt_out_capturing(); + for (const key of Object.keys(localStorage)) { + if (key.startsWith("ph_")) localStorage.removeItem(key); + } + } + } catch {} + }); + } + document.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && directionsDialog?.open) { closeDirections(); @@ -657,5 +699,6 @@ const runtimeStrings = { ); }); + diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index a675bad..4a40f5d 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -62,6 +62,10 @@ export interface Strings { mapSelectedAnnounce: (name: string) => string; footer: string; languageLabel: string; + /** Opt-in ask inside the directions dialog. */ + telemetryOptIn: string; + telemetryDetailsLabel: string; + telemetryDetails: string; } export const translations: Record = { @@ -104,6 +108,11 @@ export const translations: Record = { footer: "Veriler Antalya Eczacı Odası kaynaklıdır. Acil durumda eczaneyi arayarak teyit ediniz.", languageLabel: "Dil", + telemetryOptIn: + "Hizmeti desteklemek için anonim kullanım istatistiklerini paylaşmak istiyorum.", + telemetryDetailsLabel: "Neler paylaşılır?", + telemetryDetails: + "Hangi sayfaların görüntülendiği ve hangi düğmelerin kullanıldığı, tarayıcı ve cihaz türü. Yazdığınız içerikler, ad, adres veya telefon toplanmaz. Veriler yalnızca bu sitenin istatistikleri için AB'deki PostHog sunucularında işlenir. Kapatınca cihazınızda saklanan her şey anında silinir.", }, en: { htmlLang: "en", @@ -144,6 +153,11 @@ export const translations: Record = { footer: "Data sourced from the Antalya Chamber of Pharmacists. In an emergency, call the pharmacy to confirm.", languageLabel: "Language", + telemetryOptIn: + "I want to support the service by sharing anonymous usage statistics.", + telemetryDetailsLabel: "What is shared?", + telemetryDetails: + "Which pages are viewed and which buttons are used, plus browser and device type. Nothing you type, and no names, addresses or phone numbers. Data is processed on PostHog servers in the EU, only for this site's usage statistics. Switching off erases everything stored on your device immediately.", }, ru: { htmlLang: "ru", @@ -184,6 +198,11 @@ export const translations: Record = { footer: "Данные предоставлены Палатой фармацевтов Антальи. В экстренном случае позвоните в аптеку для подтверждения.", languageLabel: "Язык", + telemetryOptIn: + "Хочу поддержать сервис, делясь анонимной статистикой использования.", + telemetryDetailsLabel: "Что передаётся?", + telemetryDetails: + "Какие страницы просмотрены и какие кнопки нажаты, тип браузера и устройства. Вводимый текст, имена, адреса и телефоны не собираются. Данные обрабатываются на серверах PostHog в ЕС только для статистики этого сайта. При выключении всё сохранённое на устройстве удаляется сразу.", }, de: { htmlLang: "de", @@ -224,6 +243,11 @@ export const translations: Record = { footer: "Daten der Antalya Apothekerkammer. Rufen Sie im Notfall die Apotheke zur Bestätigung an.", languageLabel: "Sprache", + telemetryOptIn: + "Ich möchte den Service unterstützen, indem ich anonyme Nutzungsstatistiken teile.", + telemetryDetailsLabel: "Was wird geteilt?", + telemetryDetails: + "Welche Seiten angesehen und welche Schaltflächen benutzt werden, dazu Browser- und Gerätetyp. Keine Eingaben, keine Namen, Adressen oder Telefonnummern. Verarbeitung auf PostHog-Servern in der EU, ausschließlich für Nutzungsstatistik dieser Seite. Beim Ausschalten wird alles auf dem Gerät Gespeicherte sofort gelöscht.", }, }; diff --git a/src/pages/de/index.astro b/src/pages/de/index.astro index 3caeca0..42656cf 100644 --- a/src/pages/de/index.astro +++ b/src/pages/de/index.astro @@ -1,5 +1,6 @@ --- import { locales, type Locale, cityPath } from "../../lib/i18n"; +import Analytics from "../../components/Analytics.astro"; // This page's locale. const here: Locale = "de"; @@ -36,5 +37,6 @@ for (const loc of locales) {

Antalya Notdienstapotheken

+ diff --git a/src/pages/en/index.astro b/src/pages/en/index.astro index 394c1d4..85f864b 100644 --- a/src/pages/en/index.astro +++ b/src/pages/en/index.astro @@ -1,5 +1,6 @@ --- import { locales, type Locale, cityPath } from "../../lib/i18n"; +import Analytics from "../../components/Analytics.astro"; // This page's locale. const here: Locale = "en"; @@ -36,5 +37,6 @@ for (const loc of locales) {

Antalya On-Duty Pharmacies

+ diff --git a/src/pages/index.astro b/src/pages/index.astro index bd8d18f..655058a 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -2,6 +2,7 @@ // Single-city site for now; send visitors straight to Antalya. // Designed to become a city picker once more provinces are added. import { locales, type Locale, cityPath } from "../lib/i18n"; +import Analytics from "../components/Analytics.astro"; // This page's locale. const here: Locale = "tr"; @@ -38,5 +39,6 @@ for (const loc of locales) {

Antalya Nöbetçi Eczaneler

+ diff --git a/src/pages/ru/index.astro b/src/pages/ru/index.astro index 31fc7ec..e046bad 100644 --- a/src/pages/ru/index.astro +++ b/src/pages/ru/index.astro @@ -1,5 +1,6 @@ --- import { locales, type Locale, cityPath } from "../../lib/i18n"; +import Analytics from "../../components/Analytics.astro"; // This page's locale. const here: Locale = "ru"; @@ -36,5 +37,6 @@ for (const loc of locales) {

Дежурные аптеки Антальи

+ diff --git a/src/styles/global.css b/src/styles/global.css index 18629ed..74920e1 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -978,6 +978,128 @@ main { gap: 0.4rem; } +.sheet__telemetry { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 0.75rem; + line-height: 1.45; +} + +/* Immediate-effect control: a switch, not a checkbox. The native input stays + in the accessibility tree; the track is decoration. */ +.sheet__telemetry input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.sheet__switch { + flex: none; + margin-top: 0.1rem; + width: 2.25rem; + height: 1.25rem; + border-radius: 999px; + background: var(--line); + position: relative; + transition: background 120ms ease; +} + +.sheet__switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 1rem; + height: 1rem; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + transition: transform 120ms ease; +} + +.sheet__telemetry input:checked + .sheet__switch { + background: var(--accent-fill); +} + +.sheet__telemetry input:checked + .sheet__switch::after { + transform: translateX(1rem); +} + +.sheet__telemetry input:focus-visible + .sheet__switch { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sheet__telemetry__body { + min-width: 0; +} + +.sheet__telemetry__body label { + cursor: pointer; +} + +.sheet__telemetry__details summary { + display: inline; + cursor: pointer; + text-decoration: underline dotted; + text-underline-offset: 2px; +} + +.sheet__telemetry__details summary::-webkit-details-marker { + display: none; +} + +.sheet__telemetry__details p { + margin: 0.4rem 0 0; + padding: 0.5rem 0.6rem; + background: var(--surface, transparent); + border-radius: 0.5rem; +} + +/* Show = answer to the action: a crisp two-step blink, no sliding. + Opacity only — the panel is transform-positioned on desktop. */ +@keyframes sheet-blink { + 0% { + opacity: 0; + } + 50%, + 100% { + opacity: 1; + } +} + +@keyframes sheet-backdrop-in { + from { + opacity: 0; + } +} + +dialog.sheet[open] { + animation: sheet-blink 180ms steps(1, end); +} + +dialog.sheet[open]::backdrop { + animation: sheet-backdrop-in 150ms ease; +} + +@media (prefers-reduced-motion: reduce) { + dialog.sheet[open], + dialog.sheet[open]::backdrop { + animation: none; + } + + .sheet__switch, + .sheet__switch::after { + transition: none; + } +} + .sheet__option { display: flex; align-items: center;