Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0380208
Add invite and promo landing pages.
JonnyLuca Aug 29, 2026
68270a9
Add Playwright visuals for spent invite and promo landings.
JonnyLuca Aug 29, 2026
3159502
Add English Playwright visuals for invalid invite and promo landings.
JonnyLuca Aug 29, 2026
6692ebd
Add English Playwright visuals for spent invite and promo landings.
JonnyLuca Aug 29, 2026
f649eca
Add English Playwright visuals for unavailable invite and promo landi…
JonnyLuca Aug 30, 2026
d9d5397
Add Playwright visuals for invite and promo landings with no code.
JonnyLuca Aug 30, 2026
e97e3e4
Add English Playwright visuals for invite and promo with no code.
JonnyLuca Aug 30, 2026
cd9a3aa
Add loading mock and Playwright visuals for invite and promo landings.
JonnyLuca Aug 30, 2026
a1da83e
Add English Playwright visuals for invite and promo loading states.
JonnyLuca Aug 30, 2026
b0d337a
Add Android Playwright visuals for invite and promo loading states.
JonnyLuca Aug 30, 2026
8b89616
Add Android Playwright visuals for invalid invite and promo landings.
JonnyLuca Aug 30, 2026
48f28d8
Add Android Playwright visuals for unavailable invite and promo landi…
JonnyLuca Aug 30, 2026
683970c
Add Android Playwright visuals for spent invite and promo landings.
JonnyLuca Aug 30, 2026
16fbda1
Add Android Playwright visuals for invite and promo with no code.
JonnyLuca Aug 30, 2026
bac05eb
Add fallback mock and Playwright visuals for nameless invite and prom…
JonnyLuca Aug 30, 2026
d18a03f
Add English Playwright visuals for nameless invite and promo landings.
JonnyLuca Aug 30, 2026
1ddabb2
Add Android Playwright visuals for nameless invite and promo landings.
JonnyLuca Aug 30, 2026
0da8d38
Add English Android Playwright visuals for invite and promo loading s…
JonnyLuca Aug 30, 2026
2293e09
Add English Android Playwright visuals for invalid invite and promo l…
JonnyLuca Aug 30, 2026
4452dfb
Add English Android Playwright visuals for unavailable invite and pro…
JonnyLuca Aug 30, 2026
399db91
Pin referral lookup name fields
JonnyLuca Aug 30, 2026
7b307aa
test(web): refresh visual baselines
JonnyLuca Aug 30, 2026
7cc6dad
fix(referral): harden invite/promo landing per review
JonnyLuca Sep 4, 2026
86f6410
test(web): widen remaining English visual views to tablet and mobile
JonnyLuca Sep 4, 2026
ffcc272
fix(referral): address review findings on the landing hardening
JonnyLuca Sep 4, 2026
6a94d32
test(referral): strengthen the landing-hardening regression tests
JonnyLuca Sep 5, 2026
1698330
fix(invite): stop folding issued codes and foreign paths onto a code
TaprootFreakAI Sep 8, 2026
4d95b90
test(coverage): gate the code the Pages Function actually runs
TaprootFreakAI Sep 8, 2026
3cdb1e7
test(invite): assert values where the suite only asserted shape
TaprootFreakAI Sep 8, 2026
0171f61
docs: describe the architecture the repo actually has
TaprootFreakAI Sep 8, 2026
dc9d142
fix(invite): hand the app the kind the API confirmed
TaprootFreakAI Sep 8, 2026
796ce93
fix(invite): cap the code before the final strip and never split a su…
TaprootFreakAI Sep 9, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
COMMIT_MSG: ${{ github.event.head_commit.message }}
# wrangler looks for ./functions in the cwd (repo root), sibling to
# public/. That middleware injects Smart App Banner app-argument.
run: |
wrangler pages deploy public \
--project-name=realunit-web-dev \
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/prd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
COMMIT_MSG: ${{ github.event.head_commit.message }}
# wrangler looks for ./functions in the cwd (repo root), sibling to
# public/. That middleware injects Smart App Banner app-argument.
run: |
wrangler pages deploy public \
--project-name=realunit-web \
Expand Down
47 changes: 40 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,42 @@ This repo is the **realunit.app** website — public, static. See the

- **No build toolchain for the site.** Plain HTML/CSS/JS only. Everything in
`public/` ships verbatim to Cloudflare Pages — what you commit is what gets
served. The dev dependencies exist **only** for the quality gates below
served. The one exception is the invite/promo HTML: `functions/_middleware.js`
rewrites those bytes on the way out so crawlers see the code in
`apple-itunes-app`, `og:*` and the App Links before any script runs. Nothing
else is transformed, and there is no server-side rendering. The dev dependencies exist **only** for the quality gates below
(formatting, HTML validation, unit tests, screenshots); nothing compiles or
bundles the site.
- **Invite/promo HTML rewrite is banner, canonical, and store handoff.** Safari,
Play, and share crawlers snapshot `apple-itunes-app`, `og:url`,
`rel=canonical`, `twitter:url`, `og:title`, `twitter:title`,
`og:description`, `twitter:description`, `og:image:alt`, `twitter:image:alt`,
`og:locale`, `og:site_name`, html `lang`, the Play
referrer, android-app / ios-app alternate links, Facebook App Links
(`al:ios:url` / `al:android:url` are `realunit-wallet://…`; `al:android:class`
is `swiss.realunit.app.MainActivity`; `al:web:url`
is the HTTPS landing), and Twitter App Card `twitter:app:url:iphone` /
`twitter:app:url:ipad` / `twitter:app:url:googleplay` (same custom scheme)
from the HTML bytes before
`/js/invite-banner.js` / `invite.js` run, so `functions/_middleware.js`
(repo-root `functions/`, picked up by `wrangler pages deploy public`) and
`scripts/dev-server.mjs` inject those from the request URL (www folded onto
the apex). The campaign code is in `og:title` / `twitter:title` /
`og:description` / `og:image:alt` / `twitter:image:alt`; `?lang=en` sets English title/description/alt and `og:locale`;
invitee names wait for lookup JS. The committed
`public/invite` and `public/promo` HTML stay generic (`app-id` only, og:url /
twitter:url `/invite/` or `/promo/`, `og:site_name` RealUnit, generic titles and descriptions, Play
href without referrer, no `al:*`, no `twitter:app:*`, `format-detection`
`telephone=no, date=no` so iOS does not turn the code or Aktionstext date
into a link, `x-apple-data-detectors="false"` on `#ok-code` / `#ok-body`
because Safari re-scans JS-inserted text, no inline
`<script>`). Do not add other Pages Functions or server-side rendering.
- **Keep the page self-contained.** `public/_headers` sets a strict CSP:
- No inline `<script>` and no external resources of any kind (scripts, styles,
images, fonts, fetch). Load JS from **same-origin** files instead.
- No inline `<script>` and no third-party resources (scripts, styles, images,
fonts). Load JS from **same-origin** files instead. The single permitted
network call is the code lookup against the DFX API, which is why
`connect-src` names `api.dfx.swiss` and `dev.api.dfx.swiss` explicitly —
adding any other host to that allowlist needs a reason in the PR.
- Inline `style="…"` attributes and `<style>` blocks are fine (`style-src`
allows `'unsafe-inline'`).
- **Put the reusable, side-effect-free JS in `public/js/lib/`.** That is the only
Expand Down Expand Up @@ -96,10 +126,13 @@ npm run e2e:docker # run the suite + compare against baselines + check:
npm run e2e:docker:update # regenerate baselines after an intentional UI change
```

The visual matrix lives in `tests/pages.mjs` (`VIEWS`): the landing page in both
its equal-badge and platform-matched layouts, every confirm-page state in both
languages, and the 404 page — across `desktop-chromium`, `tablet-chromium` and
`mobile-safari`. `check:visual` enforces that every view × applicable viewport
The visual matrix lives in `tests/pages.mjs` (`VIEWS`), which is the single
source of truth — do not maintain a second list here. It currently covers six
families: the invite and promo landings (each in their loading, resolved,
invalid, missing-code and platform-matched variants), the confirm-page states,
the account-merge pages, the home landing in its equal-badge and
platform-matched layouts, and the 404 page — across `desktop-chromium`,
`tablet-chromium` and `mobile-safari`. `check:visual` enforces that every view × applicable viewport
has exactly one committed baseline, nothing is orphaned, and the report ran them
all. When you intentionally change a page's look, run `e2e:docker:update` and
commit the updated PNGs under `tests/__screenshots__/`.
Expand Down
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,57 @@ The website served at **realunit.app**. Public, static.

## v1 (current)

A minimal landing page — one hero image from the RealUnit app plus the three download buttons.
A minimal landing page — one hero image from the RealUnit app plus the store buttons —
plus invite/promo landings and the Aktionariat confirm / account-merge flows.
Deliberately **without a build toolchain**: plain HTML + assets in `public/`,
uploaded to Cloudflare Pages.

- `public/index.html` — landing, shows `assets/hero.jpg` centered on a light background
with the store/download buttons below it
- `public/invite/` and `public/promo/` — referral and campaign landings; look up
`GET /v1/realunit/referral/code/:code` (contract:
[JonnyLuca/dfx-referral-api](https://github.com/JonnyLuca/dfx-referral-api)),
open `realunit-wallet://invite|promo/{code}`,
and pass the code as a Play install referrer. Path and query codes
(including ads/Play `utm_content` / `referrer` and Facebook/Google/Outlook
`u=` / `q=` / `url=` / email `link=` wrapping `invite=` or a landing URL, with or without
`https://`; a campaign name in `utm_content` does not hide a later wrapper key;
an empty or foreign `code=` does not hide a later `invite=` / `promo=`;
short `ios-app://` alternate links; Proofpoint URL Defense and Outlook Safe Links wrapping a
RealUnit landing; a foreign `https://` URL is not a code) are
uppercased, stripped of messenger zero-width/fullwidth characters and
trailing sentence punct (`!`, `?`, `/`, …), and capped at 32 like the
API lookup — including in the crawler HTML bytes Safari snapshots.
The re-tap hint is iOS-only;
Android keeps the code via the Play referrer. An App Store / Play / CTA tap
also copies the code (user gesture) so an iOS badge install can still be
pasted at registration. iOS `format-detection` is `telephone=no, date=no`
so Safari does not turn the campaign code or Aktionstext date into a link.
`#ok-code` and `#ok-body` also set `x-apple-data-detectors="false"` because
those strings are written after load.
`www.realunit.app/invite|promo` is HTTP 200 (not a
301 to the apex) so Universal Links and the Smart App Banner keep the host.
The Smart App Banner `app-argument`, `og:url`, `rel=canonical`,
`twitter:url`, `og:title`, `twitter:title`, `og:description`,
`twitter:description`, `og:image:alt`, `twitter:image:alt`, `og:locale`,
`og:site_name`, Play install referrer,
android-app / ios-app alternate links, Facebook App Links
(`al:ios:url` / `al:android:url` are `realunit-wallet://…`; `al:android:class`
is `swiss.realunit.app.MainActivity`; `al:web:url`
is the HTTPS landing), and Twitter App Card `twitter:app:url:*` (same
custom scheme; `twitter:app:country` is CH) are injected
into the HTML bytes from the request URL (`functions/_middleware.js` on
Cloudflare Pages, and the local dev-server) so Safari, Play, WhatsApp, X,
and share crawlers can snapshot them before JS. `og:title`, `og:description`,
and image alt name the campaign code; `?lang=en` sets English copy and `og:locale=en_GB`;
invitee names wait for lookup JS. `/js/invite-banner.js` in `<head>`
is the CSP-safe JS fallback — Cloudflare Pages CSP blocks inline `<script>`.
- `public/.well-known/apple-app-site-association` and `assetlinks.json` — Universal
Links / App Links for `/invite/*` and `/promo/*` on apex and www (HTTP 200,
no 301). `sha256_cert_fingerprints`
lists the v2 signing cert of GitHub release APK `realunit-1.2.17.apk`
(`O=DFX AG`, `CN=Konstantin Ullrich`). If Play App Signing uses
a different app-signing key, add that SHA-256 from Play Console beside them.
- `public/platform.js` — enlarges the store button matching the visitor's platform
(iOS → App Store, Android → Play Store); without JS all buttons stay equal-size
- `public/assets/hero.jpg` — hero (source: the app's splash background)
Expand Down Expand Up @@ -40,10 +85,7 @@ projects in the DNS/deployment configuration. The `handbook.` subdomain is unaff

## Roadmap (v2+)

- `/confirm-aktionariat` — guided Aktionariat address confirmation (calls `api.dfx.swiss`)
- `/account-merge` — confirms adding a wallet address to the existing account (calls the public DFX API)
- Legal pages — rendered from the app's `assets/legal/*.md` (build-time fetch, single source)
- Universal Links / App Links (`/.well-known/*`)

From v2 a build toolchain (Astro) is introduced; the plain-image landing stays the home page.

Expand Down
32 changes: 32 additions & 0 deletions functions/_middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Rewrite invite/promo HTML so apple-itunes-app already carries
* app-argument, og:url / canonical / twitter:url already name the
* landing URL, og:title / twitter:title / og:description already name
* the campaign code, ?lang=en already sets html lang / og:locale, and
* Facebook App Links are present. Safari and share
* crawlers snapshot those from the HTML bytes before
* /js/invite-banner.js and invite.js run.
* public/ stays generic; this is not a site-wide renderer.
*/
import { injectLandingFromRequestUrl, shouldRewriteItunesBanner } from './lib/itunes-banner.js';

export async function onRequest(context) {
const url = new URL(context.request.url);
if (!shouldRewriteItunesBanner(url.pathname)) {
return context.next();
}
const response = await context.next();
const type = response.headers.get('content-type') || '';
if (context.request.method !== 'GET' || !type.includes('text/html')) {
return response;
}
const html = await response.text();
const injected = injectLandingFromRequestUrl(html, context.request.url);
const headers = new Headers(response.headers);
headers.delete('content-length');
return new Response(injected, {
status: response.status,
statusText: response.statusText,
headers,
});
}
Loading
Loading