Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
48 changes: 48 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<member>`)

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`.


1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<member>` 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--<channel>.web.app` preview channels send nothing. To measure a preview deliberately, build it with `PUBLIC_ANALYTICS_ALLOWED_HOSTS=<host>`.
Expand Down
10 changes: 9 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading