diff --git a/.env.example b/.env.example index 8b4cc8d1..5b614107 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,9 @@ PUBLIC_FIREBASE_APPCHECK_DEBUG_TOKEN= # production property. Set to a preview host only when you deliberately want to # verify measurement there. PUBLIC_ANALYTICS_ALLOWED_HOSTS= + +# Origin used to build absolute URLs that must resolve on the deployed site +# (currently just the invite OG image). Defaults to `https://devfest.cz` — +# only set this when redeploying a preview channel to verify one of those +# URLs against that channel's own build instead of production. +PUBLIC_SITE_URL= diff --git a/.gitignore b/.gitignore index 3a871832..5508c77b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ firestore-debug.log* # Claude Code worktrees + local IDE configs .claude/ + +# impeccable local tooling (design hook cache, sidecar, critiques) +.impeccable/ +.agent_context/ diff --git a/CLAUDE.md b/CLAUDE.md index 799bf1ae..bd6e635e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,54 @@ rationale behind them — lives in [DESIGN.md](DESIGN.md); treat it as the source of truth. Global CSS variables are in `BaseLayout.scss`; React components use co-located `.module.scss` files. +### Personal invitation pages (`/invite/`) + +An unlisted referral channel: one page per person in `src/content/team.json` +(`src/pages/invite/[member].astro`, eleven pages), written in that member's +voice, which they share with their own network themselves. + +- **Unlisted means three things at once**, and dropping any one of them makes it + cosmetic: `noindex` (the `BaseLayout` prop also drops the canonical + JSON-LD), + excluded from the sitemap (the `/invite/` clause in `astro.config.mjs`), and + **linked from nowhere** — not the menu, not the footer, not `/team`. It is not + access control; the URLs are guessable and the content isn't sensitive. +- **The route slug is the `team.json` entry key** (`id`), so a URL and the roster + can't drift apart. Adding a member to the roster ships their invite page. +- **Copy lives in `src/lib/invite.ts`**, not in the page. It is deliberately + GENERIC v1 — the members write their own paragraphs later, and when they do + only `body` changes. `roleLine` is the one line that varies between the eleven + pages, keyed by `role`; an unrecognised role falls back to the organiser line + rather than dropping the sentence. +- **`InviteCta.tsx` is the only primary action**, and it is an island for two + reasons: it resolves the ti.to href client-side from `/api/tickets` (the live + wave isn't known at build time), and it reports `begin_checkout` with + `invite_member` / `invite_member_name`. That click is the ONLY per-member + attribution the channel has — checkout runs on ti.to and its redirect carries + no source, and there is no discount code doing the job (no perk in v1). Until + the endpoint answers, and if it never does, the href is `/#tickets`: a working + link beats a dead primary action. +- **Visual direction B ("The Plate")**, approved 3 Sep 2026: the portrait owns + the right half of the frame and the B&W → colour bleed is the page's only + effect. On a phone there is no side-by-side frame, so the **scrim** over the + read zone is what keeps the type off the face — a legibility condition, not a + finish. Do not lighten it. The base plate is the LCP element (`fetchpriority` + high); the colour layer is the enhancement (`low`). +- `Closer.astro` takes an optional `actions` **slot** so the closing repeat of + the CTA can be that same tracked island; its `actions` prop stays the path for + every other page. +- **The header's red `Tickets` action is dropped on `/invite/*`** (`isInvite` in + `Menu.astro`), decided 4 Sep 2026: it is a second primary action pointing off + the invitation. The mark and the menu toggle stay, so the bar is still a bar + and every destination is one click away behind it. +- **`begin_checkout` fires only on the ti.to click**, never on the `/#tickets` + fallback — that one lands on the ticket section whose own Buy CTA sends the + event, so reporting both would double-count the wave and file an item-less + event against the member. `invite_member` / `invite_member_name` are custom + parameters: GA4 reports nothing on them until they are registered as custom + dimensions (README, "Analytics (GA4)"). + ### SEO & Metadata `BaseLayout.astro` handles all meta tags, Open Graph/Twitter Card, and JSON-LD structured data (Event + WebSite schemas). Sitemap auto-generated via `@astrojs/sitemap`. + + diff --git a/README.md b/README.md index b319eae2..41149f80 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ The invoice **price is taken automatically** from the active ti.to release whose Firebase Analytics, measurement ID `G-L5NK2S2EZ0`, in Google Consent Mode. Architecture and the gotchas behind it are in [CLAUDE.md](CLAUDE.md#firebase-integration-srclibfirebasets); this section is the console-side setup, which is **not** in the repo. - **Mark the conversions as key events.** GA4 → Admin → Events → mark as key event: `ticket_purchase_confirmed` (ti.to purchase completed), `sign_up` (newsletter), `generate_lead` (company invoice request). `begin_checkout` is a recommended ecommerce event and needs no marking; pair it with `ticket_purchase_confirmed` for the checkout drop-off rate. +- **Register the invitation parameters as custom dimensions.** GA4 → Admin → Custom definitions → create two event-scoped dimensions on `invite_member` (the `team.json` id) and `invite_member_name`. `begin_checkout` from `/invite/` carries both, but GA4 does not report on an unregistered custom parameter — until they exist, the per-member attribution the invitation channel is built for is collected and invisible. Registration is not retroactive, so do it before the links go out. - **No revenue in reports.** ti.to's thank-you redirect carries no order id or amount, so `ticket_purchase_confirmed` deliberately isn't GA4's `purchase` and carries no value. Ticket revenue lives in ti.to, not GA4. `begin_checkout` and `generate_lead` do carry `value` (gross CZK), so *intent* is measurable. - **Consent Mode is already the source of truth** — do not add a second GA4 tag or a GTM container. A visitor who declines still produces cookieless, identifier-free pings by design; those are aggregate-only and cannot be attributed to a user or a session. - **Development traffic is excluded in code**, not by a GA4 filter: measurement is limited to `devfest.cz` (and subdomains) plus the live Hosting default domains `devfest-public.web.app` / `devfest-public.firebaseapp.com`, so `npm run dev` and `devfest-public--.web.app` preview channels send nothing. To measure a preview deliberately, build it with `PUBLIC_ANALYTICS_ALLOWED_HOSTS=`. diff --git a/astro.config.mjs b/astro.config.mjs index 90b03bb4..0f0236a7 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -109,7 +109,15 @@ export default defineConfig({ sitemap({ filter: (page) => !page.includes('/newsletter-subscription-thank-you') && - !page.includes('/thank-you'), + !page.includes('/thank-you') && + // The team's personal invitation pages are unlisted: `noindex`, + // out of the sitemap, and linked from nowhere on the site. All + // three together are what "secret" means here — see + // src/pages/invite/[member].astro. + !page.includes('/invite/') && + // Their OG cards are a generated asset, not a page — never a + // sitemap entry regardless of `/invite/`'s own secrecy. + !page.includes('/og/invite/'), serialize(item) { item.lastmod = BUILD_DATE; item.changefreq = 'weekly'; diff --git a/package-lock.json b/package-lock.json index 18d2d2e0..88990925 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,15 @@ "dependencies": { "@astrojs/react": "^6.0.0", "@astrojs/sitemap": "^3.7.2", + "@resvg/resvg-js": "^2.6.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "astro": "^7.1.0", "firebase": "^12.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "sass": "^1.99.0" + "sass": "^1.99.0", + "satori": "^0.33.4" }, "devDependencies": { "@axe-core/playwright": "^4.11.3", @@ -2788,6 +2790,221 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -3158,6 +3375,22 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "license": "MIT", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -3530,6 +3763,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.20", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", @@ -3581,6 +3823,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -3764,6 +4015,36 @@ "uncrypto": "^0.1.3" } }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", + "license": "MIT" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", + "license": "MIT" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-gradient-parser": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz", + "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -3780,6 +4061,17 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -4007,6 +4299,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -4075,6 +4376,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -4146,6 +4453,12 @@ } } }, + "node_modules/fflate": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.3.tgz", + "integrity": "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==", + "license": "MIT" + }, "node_modules/firebase": { "version": "12.16.0", "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.16.0.tgz", @@ -4282,6 +4595,12 @@ "uncrypto": "^0.1.3" } }, + "node_modules/harfbuzzjs": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.0.tgz", + "integrity": "sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==", + "license": "MIT" + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4386,6 +4705,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -4801,6 +5132,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -4974,9 +5315,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -5155,6 +5496,22 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -5266,6 +5623,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -5536,6 +5899,30 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/satori": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.33.4.tgz", + "integrity": "sha512-dCnaW6D/tkCJxG5UvpZPWnnvfTdGTVVSAq1tzH6xC5MeOhAeeswRcSq5zT7C6lRCdTGow9PRGus7PCzdp5JJYw==", + "license": "MPL-2.0", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.17", + "css-to-react-native": "^3.0.0", + "emoji-regex-xs": "^2.0.1", + "escape-html": "^1.0.3", + "fflate": "0.7.3", + "harfbuzzjs": "0.10.0", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-layout": "^3.2.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/satteri": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz", @@ -5731,6 +6118,12 @@ "node": ">=8" } }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5872,6 +6265,16 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -6359,6 +6762,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 0a3f44c5..63c18649 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,15 @@ "dependencies": { "@astrojs/react": "^6.0.0", "@astrojs/sitemap": "^3.7.2", + "@resvg/resvg-js": "^2.6.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "astro": "^7.1.0", "firebase": "^12.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "sass": "^1.99.0" + "sass": "^1.99.0", + "satori": "^0.33.4" }, "devDependencies": { "@axe-core/playwright": "^4.11.3", diff --git a/scripts/a11y.mjs b/scripts/a11y.mjs index 0bdb814a..d07294fd 100644 --- a/scripts/a11y.mjs +++ b/scripts/a11y.mjs @@ -28,6 +28,11 @@ const PATHS = [ '/press/downloads/', '/invoice/', '/privacy-policy/', + // One of the eleven personal invitation pages. They are the same template + // with a different photograph and one different line, so auditing one + // audits all of them — and this is the only page where type sits over a + // photograph, which is exactly the contrast case worth watching. + '/invite/eliska-cejpova/', '/newsletter-subscription-thank-you/', '/thank-you/', '/404.html', diff --git a/src/assets/fonts/BebasNeue-Regular.ttf b/src/assets/fonts/BebasNeue-Regular.ttf new file mode 100644 index 00000000..c328c6e0 Binary files /dev/null and b/src/assets/fonts/BebasNeue-Regular.ttf differ diff --git a/src/assets/fonts/JetBrainsMono-Regular.ttf b/src/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..711830ed Binary files /dev/null and b/src/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/src/assets/fonts/SpecialElite-Regular.ttf b/src/assets/fonts/SpecialElite-Regular.ttf new file mode 100644 index 00000000..76a925d6 Binary files /dev/null and b/src/assets/fonts/SpecialElite-Regular.ttf differ diff --git a/src/components/Closer.astro b/src/components/Closer.astro index ee2d9866..7980d9f0 100644 --- a/src/components/Closer.astro +++ b/src/components/Closer.astro @@ -19,7 +19,12 @@ interface Props { /** Allows a accent word; rendered with set:html. */ titleHtml: string; note?: string; - actions: readonly Action[]; + /** + * The closing actions as plain links. Omit them and pass an `actions` slot + * instead when a control has to be an island (the invitation pages resolve + * their ti.to href client-side and report the click). + */ + actions?: readonly Action[]; tone?: 'accent' | 'raised' | 'plain'; titleId?: string; } @@ -28,7 +33,7 @@ const { eyebrow, titleHtml, note, - actions, + actions = [], tone = 'raised', titleId, } = Astro.props; @@ -46,6 +51,7 @@ const toneClass = tone === 'accent' ? ' band--accent' : tone === 'raised' ? ' ba
{note &&

{note}

}
+ {actions.map(({ href, label, kind = 'primary', external }) => ( `). + * + * Two jobs the plain `` of the prototype could not do: + * + * 1. **It goes straight to ti.to.** The invitation has exactly one action, so + * routing through the homepage's ticket section would put a second decision + * between the visitor and the checkout. The live wave isn't known at build + * time, so the href resolves client-side from the cached `/api/tickets` + * endpoint — the same read `Tickets.tsx` does, no Firebase SDK on the path. + * 2. **It reports `begin_checkout` carrying the member's identity.** Checkout + * happens on ti.to and its redirect back carries no source, so this click is + * the last thing GA4 can attribute to a member's invitation. Without the + * `invite_member` params the whole channel is unmeasurable (there is no + * discount code doing the attribution — see the product definition). + * + * Until the endpoint answers — and if it never does — the href falls back to + * the on-site ticket section, so the CTA is a working link at first paint and + * the event still fires. A dead primary action is worse than a detour. + */ + +interface Props { + /** `team.json` id — the attribution key for every event this page sends. */ + memberId: string; + memberName: string; + label: string; + /** Filled by default; the below-fold repeat is the same control. */ + kind?: 'primary' | 'ghost'; + className?: string; +} + +/** On-site fallback while tickets are loading or the endpoint is down. */ +const FALLBACK_HREF = '/#tickets'; + +interface Target { + href: string; + external: boolean; + /** The buyable releases behind this href, for the ecommerce payload. */ + releases: TitoRelease[]; +} + +const INITIAL: Target = { href: FALLBACK_HREF, external: false, releases: [] }; + +/** Two decimals — long VAT floats are noise in the GA4 payload. */ +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +export default function InviteCta({ memberId, memberName, label, kind = 'primary', className }: Props) { + const [target, setTarget] = useState(INITIAL); + + useEffect(() => { + const ac = new AbortController(); + fetchTickets(ac.signal) + .then((data) => { + if (!data) return; + const accountSlug = data.accountSlug ?? ''; + const eventSlug = data.eventSlug ?? ''; + if (!accountSlug || !eventSlug) return; + const visible = filterDisplayable(data.releases ?? []); + const buyable = visible.filter((r) => releaseStatus(r).purchasable); + // A single buyable release can be linked precisely; with several + // (individual + company-funded of the same wave) the event page is + // the honest destination — it lists them both. + const href = + buyable.length === 1 + ? checkoutUrl(buyable[0], accountSlug, eventSlug) + : eventUrl(accountSlug, eventSlug); + // Nothing on sale: the ti.to event page still says so truthfully, + // which beats sending an invited guest to an empty anchor. + setTarget({ href, external: true, releases: buyable }); + }) + .catch((err) => { + if (ac.signal.aborted) return; + console.warn('[invite] Failed to resolve the checkout link:', err); + }); + return () => ac.abort(); + }, []); + + const onClick = () => { + // Only the ti.to click is a checkout. The fallback goes to `/#tickets`, + // where the visitor still has to press Buy — which sends its own + // `begin_checkout` from `Tickets.tsx`. Reporting both would double-count + // the wave and file an item-less event against the member. + if (!target.external) return; + const items = target.releases.map((release) => { + const price = grossPrice(release); + return { + item_id: release.slug, + item_name: releaseTitle(release), + ...(price != null ? { price: round2(price) } : {}), + quantity: 1, + }; + }); + // `value` is one ticket's price, not the sum: the releases are + // alternatives, not a cart (same rule as Tickets.tsx). GA4 drops `value` + // without a `currency`, so the pair is sent together or not at all. + const value = items.find((i) => typeof i.price === 'number')?.price; + const currency = target.releases.find((r) => r.currency)?.currency; + track('begin_checkout', { + ...(value != null ? { currency: (currency ?? 'CZK').toUpperCase(), value } : {}), + items, + // The attribution this whole channel rests on. + invite_member: memberId, + invite_member_name: memberName, + }); + }; + + return ( + + {label} + + ); +} diff --git a/src/components/Menu.astro b/src/components/Menu.astro index 253f79ca..573689dc 100644 --- a/src/components/Menu.astro +++ b/src/components/Menu.astro @@ -11,6 +11,13 @@ interface NavLink { const currentPath = Astro.url.pathname.replace(/\/$/, '') || '/'; const isHome = currentPath === '/'; +// A personal invitation has exactly ONE primary action — `Take the ticket`, +// which goes straight to ti.to. The red `Tickets` bar action is a second +// primary action pointing somewhere else (`/#tickets`, off the invitation), so +// it is dropped on `/invite/*`. Decided 4 Sep 2026. The mark and the menu +// toggle stay, so the bar is still a bar and every destination is still one +// click away behind it. +const isInvite = currentPath === '/invite' || currentPath.startsWith('/invite/'); // On the home page #tickets is a same-page anchor; from subpages we need // to send the visitor back to / first so the section actually exists. const ticketsHref = isHome ? '#tickets' : '/#tickets'; @@ -57,7 +64,7 @@ const isCurrent = (href: string) => {
- Tickets + {!isInvite && Tickets}