From d6f2aa2201e4fe62aac1b979e7bf3d614ec58d06 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Mon, 24 Aug 2026 23:10:59 +0200
Subject: [PATCH] feat: add personal invite landing and app-link verification
files
Serves /invite/{code} against the referral landing API, store badges with
an Android install referrer, and well-known files for Universal Links.
---
README.md | 8 +-
public/.well-known/apple-app-site-association | 11 +
public/.well-known/assetlinks.json | 10 +
public/_headers | 9 +
public/_redirects | 1 +
public/invite/index.html | 257 ++++++++++++++++++
public/invite/invite.js | 174 ++++++++++++
public/js/lib/invite-core.js | 237 ++++++++++++++++
scripts/check-site.mjs | 3 +-
scripts/dev-server.mjs | 13 +
test/invite-core.test.mjs | 251 +++++++++++++++++
tests/behavior.spec.mjs | 152 +++++++++++
tests/pages.mjs | 72 ++++-
13 files changed, 1192 insertions(+), 6 deletions(-)
create mode 100644 public/.well-known/apple-app-site-association
create mode 100644 public/.well-known/assetlinks.json
create mode 100644 public/_redirects
create mode 100644 public/invite/index.html
create mode 100644 public/invite/invite.js
create mode 100644 public/js/lib/invite-core.js
create mode 100644 test/invite-core.test.mjs
diff --git a/README.md b/README.md
index 5db6097..1e7be9f 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,13 @@ uploaded to Cloudflare Pages.
with the store/download buttons below it
- `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/invite/` — personal invite / promo landing at `/invite/{code}` (store badges,
+ open-installed-app, landing fetch against the API)
+- `public/_redirects` — Cloudflare Pages rewrite so `/invite/*` serves the invite page
+- `public/.well-known/apple-app-site-association` — iOS Universal Links for `/invite`
+- `public/.well-known/assetlinks.json` — Android App Links for the RealUnit package
+ (Android App Link verification fingerprints are filled from Play App Signing after
+ first release.)
- `public/assets/hero.jpg` — hero (source: the app's splash background)
- `public/assets/og.png` — social sharing image (source: the app's Android feature graphic)
- `public/assets/favicon.svg` — app icon
@@ -43,7 +50,6 @@ projects in the DNS/deployment configuration. The `handbook.` subdomain is unaff
- `/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.
diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association
new file mode 100644
index 0000000..2d35fb2
--- /dev/null
+++ b/public/.well-known/apple-app-site-association
@@ -0,0 +1,11 @@
+{
+ "applinks": {
+ "apps": [],
+ "details": [
+ {
+ "appID": "N2BP27J7N6.swiss.realunit.app",
+ "paths": ["/invite", "/invite/*"]
+ }
+ ]
+ }
+}
diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json
new file mode 100644
index 0000000..d52d101
--- /dev/null
+++ b/public/.well-known/assetlinks.json
@@ -0,0 +1,10 @@
+[
+ {
+ "relation": ["delegate_permission/common.handle_all_urls"],
+ "target": {
+ "namespace": "android_app",
+ "package_name": "swiss.realunit.app",
+ "sha256_cert_fingerprints": []
+ }
+ }
+]
diff --git a/public/_headers b/public/_headers
index ae86b75..5a970f8 100644
--- a/public/_headers
+++ b/public/_headers
@@ -20,3 +20,12 @@
/js/*
Cache-Control: public, max-age=3600
+
+/invite/invite.js
+ Cache-Control: public, max-age=3600
+
+/.well-known/apple-app-site-association
+ Content-Type: application/json
+
+/.well-known/assetlinks.json
+ Content-Type: application/json
diff --git a/public/_redirects b/public/_redirects
new file mode 100644
index 0000000..9c4f59d
--- /dev/null
+++ b/public/_redirects
@@ -0,0 +1 @@
+/invite/* /invite/index.html 200
diff --git a/public/invite/index.html b/public/invite/index.html
new file mode 100644
index 0000000..53c7a30
--- /dev/null
+++ b/public/invite/index.html
@@ -0,0 +1,257 @@
+
+
+
+
+
+ RealUnit — Einladung
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Einladung wird geladen…
+
Einen Moment, wir laden deine Einladung.
+
+
+
+
+
+
Willkommen bei RealUnit
+
Hey, jemand lädt dich ein zu RealUnit.
+
Öffne diesen Link auf deinem Smartphone, um die RealUnit-App zu starten oder herunterzuladen.
+
+
+
+
+
+
+
+
+
+
+
Link ungültig oder abgelaufen
+
Dieser Einladungslink ist ungültig oder bereits abgelaufen. Bitte fordere in der App einen neuen an, oder tippe den Code manuell ein.
+
+
+
+
+
+
Dienst vorübergehend nicht erreichbar
+
Wir konnten die Einladung gerade nicht laden. Bitte versuche es in ein paar Minuten erneut.
+
+
+
+
+
+
+
+
+
+
diff --git a/public/invite/invite.js b/public/invite/invite.js
new file mode 100644
index 0000000..71ec792
--- /dev/null
+++ b/public/invite/invite.js
@@ -0,0 +1,174 @@
+/* DOM + network glue for the personal invite / promo landing page. The pure,
+ testable logic (language resolution, host/API-base derivation, path→code,
+ response → state mapping, store/app URLs, and the i18n copy) lives in
+ js/lib/invite-core.js, loaded before this file; everything here touches the
+ DOM/network and is covered by the Playwright functional suite. */
+(function () {
+ 'use strict';
+
+ var core = window.RealUnitInvite;
+ var params = new URLSearchParams(window.location.search);
+ var host = window.location.hostname;
+ var pathname = window.location.pathname;
+
+ var lang = core.resolveLang({
+ urlLang: params.get('lang'),
+ navigatorLang: navigator.language,
+ supported: core.SUPPORTED_LANGS,
+ defaultLang: 'de',
+ });
+ document.documentElement.lang = lang;
+ var t = core.I18N[lang];
+
+ // Apply translations: text content, alt text, aria-label, and document meta.
+ document.querySelectorAll('[data-i18n]').forEach(function (el) {
+ var v = t[el.getAttribute('data-i18n')];
+ if (v) el.textContent = v;
+ });
+ document.querySelectorAll('[data-i18n-alt]').forEach(function (el) {
+ var v = t[el.getAttribute('data-i18n-alt')];
+ if (v) el.setAttribute('alt', v);
+ });
+ document.querySelectorAll('[data-i18n-aria]').forEach(function (el) {
+ var v = t[el.getAttribute('data-i18n-aria')];
+ if (v) el.setAttribute('aria-label', v);
+ });
+ if (t['doc.title']) document.title = t['doc.title'];
+ var descEl = document.querySelector('meta[name="description"]');
+ if (descEl && t['doc.desc']) descEl.setAttribute('content', t['doc.desc']);
+
+ var STATES = ['loading', 'invite', 'promo', 'invalid', 'unavailable'];
+
+ function show(state) {
+ STATES.forEach(function (s) {
+ document.getElementById('state-' + s).hidden = s !== state;
+ });
+ // Expose the active state for tests / tooling (same pattern as waitFor views).
+ document.documentElement.dataset.state = state;
+ }
+
+ function setOpenAppHref(code) {
+ var href = core.appSchemeUrl(code);
+ document.querySelectorAll('[data-open-app]').forEach(function (el) {
+ el.setAttribute('href', href);
+ });
+ }
+
+ function setStoreHrefs(code) {
+ document.querySelectorAll('a[data-store="apple"]').forEach(function (el) {
+ el.setAttribute('href', core.appStoreUrl());
+ });
+ document.querySelectorAll('a[data-store="play"]').forEach(function (el) {
+ el.setAttribute('href', core.playStoreUrl(code));
+ });
+ }
+
+ function applyInviteCopy(body) {
+ var greeting = core.formatInviteGreeting(
+ lang,
+ body && body.guestName,
+ body && body.hostDisplayName,
+ );
+ document.querySelectorAll('[data-invite-body]').forEach(function (el) {
+ el.textContent = greeting;
+ });
+ }
+
+ function applyPromoCopy(body) {
+ var text = core.formatPromoBody(lang, body || {});
+ document.querySelectorAll('[data-promo-body]').forEach(function (el) {
+ el.textContent = text;
+ });
+ }
+
+ function render(state, body, code) {
+ if (state === 'invite') {
+ applyInviteCopy(body);
+ setStoreHrefs(code);
+ setOpenAppHref(code);
+ show('invite');
+ } else if (state === 'promo') {
+ applyPromoCopy(body);
+ setStoreHrefs(code);
+ setOpenAppHref(code);
+ show('promo');
+ } else if (state === 'invalid') {
+ show('invalid');
+ } else {
+ show('unavailable');
+ }
+ }
+
+ function load() {
+ show('loading');
+
+ // Mock hook for LOCAL preview only (?mock=invite|promo|invalid|unavailable).
+ // Never honored on the real realunit.app / dev.realunit.app hosts, so a
+ // shared prod link cannot render a spoofed landing screen.
+ var mock = params.get('mock');
+ if (mock && !core.isRealUnitHost(host)) {
+ setTimeout(function () {
+ var demoCode = core.extractCode(pathname) || 'MOCKINVITECODE01';
+ if (mock === 'invite') {
+ render('invite', { guestName: 'Alex', hostDisplayName: 'Sam' }, demoCode);
+ } else if (mock === 'promo') {
+ render(
+ 'promo',
+ {
+ campaignText: 'Starte mit RealUnit und sichere dir den Bonus.',
+ campaignTextEn: 'Get started with RealUnit and claim your bonus.',
+ },
+ demoCode,
+ );
+ } else if (mock === 'invalid') {
+ render('invalid');
+ } else {
+ render('unavailable');
+ }
+ }, 400);
+ return;
+ }
+
+ var code = core.extractCode(pathname);
+ if (!code) {
+ // No code in the path → invalid without fetching.
+ render('invalid');
+ return;
+ }
+
+ var url = core.buildLandingUrl(core.apiBase({ host: host, paramApi: params.get('api') }), code);
+
+ // Abort a stalled request so the spinner can never hang forever.
+ var controller = new AbortController();
+ var timeoutId = setTimeout(function () {
+ controller.abort();
+ }, 15000);
+
+ fetch(url, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ signal: controller.signal,
+ })
+ .then(function (res) {
+ return res
+ .json()
+ .then(function (body) {
+ return { status: res.status, body: body };
+ })
+ .catch(function () {
+ return { status: res.status, body: {} };
+ });
+ })
+ .then(function (r) {
+ clearTimeout(timeoutId);
+ render(core.mapLandingResponse(r), r.body, code);
+ })
+ .catch(function () {
+ clearTimeout(timeoutId);
+ render('unavailable'); // network error / timeout (abort) → retryable
+ });
+ }
+
+ document.getElementById('retry').addEventListener('click', load);
+ load();
+})();
diff --git a/public/js/lib/invite-core.js b/public/js/lib/invite-core.js
new file mode 100644
index 0000000..82ecbd7
--- /dev/null
+++ b/public/js/lib/invite-core.js
@@ -0,0 +1,237 @@
+/**
+ * Pure, side-effect-free helpers + copy shared by invite/invite.js.
+ *
+ * Loaded as a classic script *before* invite.js so window.RealUnitInvite exists
+ * when invite.js runs. Kept free of DOM/network access so it can be unit-tested
+ * in isolation with 100% coverage (see test/invite-core.test.mjs); the DOM and
+ * fetch glue stays in invite.js and is covered by the Playwright functional
+ * suite.
+ */
+(function (global) {
+ 'use strict';
+
+ var SUPPORTED_LANGS = ['de', 'en'];
+
+ // The host names realunit.app is served under. On these the local-preview mock
+ // hook is refused and the API base is fixed, so a shared production link can
+ // neither render a spoofed landing nor be pointed at an arbitrary API.
+ var REALUNIT_HOSTS = ['realunit.app', 'www.realunit.app', 'dev.realunit.app'];
+
+ var APP_STORE_URL = 'https://apps.apple.com/ch/app/realunit/id6759720010';
+ var PLAY_STORE_BASE = 'https://play.google.com/store/apps/details?id=swiss.realunit.app';
+ var SITE_ORIGIN = 'https://realunit.app';
+
+ // Copy for every state, German (authored) + English. Both languages carry the
+ // exact same keys — test/invite-core.test.mjs enforces parity and that every
+ // data-i18n key used in the page is present here.
+ var I18N = {
+ de: {
+ 'doc.title': 'RealUnit — Einladung',
+ 'doc.desc': 'Persönliche Einladung zur RealUnit-App.',
+ 'loading.title': 'Einladung wird geladen…',
+ 'loading.body': 'Einen Moment, wir laden deine Einladung.',
+ 'invite.title': 'Willkommen bei RealUnit',
+ 'invite.greeting': 'Hey {guestName}, {hostDisplayName} lädt dich ein zu RealUnit.',
+ 'promo.title': 'Aktion',
+ 'invalid.title': 'Link ungültig oder abgelaufen',
+ 'invalid.body':
+ 'Dieser Einladungslink ist ungültig oder bereits abgelaufen. Bitte fordere in der App einen neuen an, oder tippe den Code manuell ein.',
+ 'unavailable.title': 'Dienst vorübergehend nicht erreichbar',
+ 'unavailable.body':
+ 'Wir konnten die Einladung gerade nicht laden. Bitte versuche es in ein paar Minuten erneut.',
+ 'unavailable.cta': 'Erneut versuchen',
+ 'cta.openApp': 'App öffnen',
+ 'cta.desktop':
+ 'Öffne diesen Link auf deinem Smartphone, um die RealUnit-App zu starten oder herunterzuladen.',
+ 'stores.nav': 'App herunterladen',
+ 'stores.apple.aria': 'RealUnit im App Store laden',
+ 'stores.apple.alt': 'Laden im App Store',
+ 'stores.play.aria': 'RealUnit jetzt bei Google Play',
+ 'stores.play.alt': 'Jetzt bei Google Play',
+ },
+ en: {
+ 'doc.title': 'RealUnit — Invite',
+ 'doc.desc': 'Personal invite to the RealUnit app.',
+ 'loading.title': 'Loading invite…',
+ 'loading.body': 'One moment — we’re loading your invite.',
+ 'invite.title': 'Welcome to RealUnit',
+ 'invite.greeting': 'Hey {guestName}, {hostDisplayName} is inviting you to RealUnit.',
+ 'promo.title': 'Promotion',
+ 'invalid.title': 'Link invalid or expired',
+ 'invalid.body':
+ 'This invite link is invalid or has already expired. Please request a new one in the app, or enter the code manually.',
+ 'unavailable.title': 'Service temporarily unavailable',
+ 'unavailable.body':
+ 'We couldn’t load the invite right now. Please try again in a few minutes.',
+ 'unavailable.cta': 'Try again',
+ 'cta.openApp': 'Open app',
+ 'cta.desktop': 'Open this link on your phone to launch or download the RealUnit app.',
+ 'stores.nav': 'Download the app',
+ 'stores.apple.aria': 'Get RealUnit on the App Store',
+ 'stores.apple.alt': 'Download on the App Store',
+ 'stores.play.aria': 'Get RealUnit on Google Play',
+ 'stores.play.alt': 'Get it on Google Play',
+ },
+ };
+
+ function normalizeLang(value) {
+ if (typeof value !== 'string') {
+ return '';
+ }
+ return value.slice(0, 2).toLowerCase();
+ }
+
+ // Resolve the active language. A present ?lang= is authoritative: it is
+ // validated and, if unsupported, falls back to the default WITHOUT consulting
+ // the browser language — the browser is only a fallback when no ?lang= is given.
+ function resolveLang(options) {
+ var supported = options.supported;
+ var fromUrl = normalizeLang(options.urlLang);
+ if (fromUrl) {
+ return supported.indexOf(fromUrl) !== -1 ? fromUrl : options.defaultLang;
+ }
+ var fromNavigator = normalizeLang(options.navigatorLang);
+ if (supported.indexOf(fromNavigator) !== -1) {
+ return fromNavigator;
+ }
+ return options.defaultLang;
+ }
+
+ function isRealUnitHost(host) {
+ return REALUNIT_HOSTS.indexOf(host) !== -1;
+ }
+
+ // Resolve the DFX API base for a host. Production hosts are fixed; on a local
+ // preview / unknown host an explicit ?api= override wins, else DEV. There is no
+ // silent production default — an unknown host is deliberately pointed at DEV.
+ function apiBase(options) {
+ var host = options.host;
+ if (host === 'realunit.app' || host === 'www.realunit.app') {
+ return 'https://api.dfx.swiss';
+ }
+ if (host === 'dev.realunit.app') {
+ return 'https://dev.api.dfx.swiss';
+ }
+ if (options.paramApi) {
+ return options.paramApi;
+ }
+ return 'https://dev.api.dfx.swiss';
+ }
+
+ // Last non-empty path segment after `/invite/`. `/invite/` and `/invite` alone
+ // yield an empty string (invalid, no fetch). Query strings are not part of the
+ // pathname and are ignored here.
+ function extractCode(pathname) {
+ if (typeof pathname !== 'string' || !pathname) {
+ return '';
+ }
+ var parts = pathname.split('/').filter(function (p) {
+ return p.length > 0;
+ });
+ var inviteIdx = -1;
+ for (var i = 0; i < parts.length; i++) {
+ if (parts[i] === 'invite') {
+ inviteIdx = i;
+ break;
+ }
+ }
+ if (inviteIdx === -1) {
+ return '';
+ }
+ var after = parts.slice(inviteIdx + 1);
+ if (after.length === 0) {
+ return '';
+ }
+ return after[after.length - 1];
+ }
+
+ function buildLandingUrl(base, code) {
+ return base + '/v1/realunit/referral/landing/' + encodeURIComponent(code);
+ }
+
+ // Map an API response to a UI state. 404 is a hard invalid link; any other
+ // non-2xx (5xx, network-shaped callers) is unavailable. On 2xx the body's
+ // `kind` decides Invite vs Promo; anything else is unavailable.
+ function mapLandingResponse(response) {
+ var status = response && response.status;
+ if (status === 404) {
+ return 'invalid';
+ }
+ if (!(status >= 200 && status < 300)) {
+ return 'unavailable';
+ }
+ var kind = response.body && response.body.kind;
+ if (kind === 'Invite') {
+ return 'invite';
+ }
+ if (kind === 'Promo') {
+ return 'promo';
+ }
+ return 'unavailable';
+ }
+
+ // Fill `{guestName}` / `{hostDisplayName}` in the invite.greeting template.
+ function formatInviteGreeting(lang, guestName, hostDisplayName) {
+ var template = (I18N[lang] || I18N.de)['invite.greeting'];
+ return template
+ .replace('{guestName}', guestName == null ? '' : String(guestName))
+ .replace('{hostDisplayName}', hostDisplayName == null ? '' : String(hostDisplayName));
+ }
+
+ // Promo body: English prefers campaignTextEn, falls back to German campaignText.
+ function formatPromoBody(lang, body) {
+ var campaignText = body && body.campaignText;
+ var campaignTextEn = body && body.campaignTextEn;
+ if (lang === 'en') {
+ return campaignTextEn || campaignText || '';
+ }
+ return campaignText || '';
+ }
+
+ // Play Store URL; when a code is present, attach an install referrer so the
+ // code survives a fresh Android install (`invite=`).
+ function playStoreUrl(code) {
+ if (code) {
+ return PLAY_STORE_BASE + '&referrer=' + encodeURIComponent('invite=' + code);
+ }
+ return PLAY_STORE_BASE;
+ }
+
+ function appStoreUrl() {
+ return APP_STORE_URL;
+ }
+
+ // Custom-scheme deep link used by the visible "open app" button. Prefer this
+ // over the https Universal Link so the CTA works before AASA is live.
+ function appSchemeUrl(code) {
+ if (code) {
+ return 'realunit-wallet://invite/' + code;
+ }
+ return 'realunit-wallet://open';
+ }
+
+ // https self-link for the same invite path (Universal Link target).
+ function universalLinkUrl(code) {
+ if (code) {
+ return SITE_ORIGIN + '/invite/' + code;
+ }
+ return SITE_ORIGIN + '/invite/';
+ }
+
+ global.RealUnitInvite = {
+ SUPPORTED_LANGS: SUPPORTED_LANGS,
+ I18N: I18N,
+ resolveLang: resolveLang,
+ isRealUnitHost: isRealUnitHost,
+ apiBase: apiBase,
+ extractCode: extractCode,
+ buildLandingUrl: buildLandingUrl,
+ mapLandingResponse: mapLandingResponse,
+ formatInviteGreeting: formatInviteGreeting,
+ formatPromoBody: formatPromoBody,
+ playStoreUrl: playStoreUrl,
+ appStoreUrl: appStoreUrl,
+ appSchemeUrl: appSchemeUrl,
+ universalLinkUrl: universalLinkUrl,
+ };
+})(window);
diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs
index ad8692e..b2e4c85 100644
--- a/scripts/check-site.mjs
+++ b/scripts/check-site.mjs
@@ -10,7 +10,7 @@
* - index.html without an https og:url to anchor the site origin
* - a page that loads a glue script without first loading the js/lib core it
* depends on (platform.js → platform-core.js, confirm.js → confirm-core.js,
- * merge.js → merge-core.js)
+ * merge.js → merge-core.js, invite.js → invite-core.js)
*
* i18n key parity (de/en) and the data-i18n coverage of the confirm page live in
* the unit test (test/confirm-core.test.mjs), which can import the copy directly.
@@ -135,6 +135,7 @@ for (const file of htmlFiles) {
checkScriptOrder(label, html, '/platform.js', '/js/lib/platform-core.js');
checkScriptOrder(label, html, '/confirm-aktionariat/confirm.js', '/js/lib/confirm-core.js');
checkScriptOrder(label, html, '/account-merge/merge.js', '/js/lib/merge-core.js');
+ checkScriptOrder(label, html, '/invite/invite.js', '/js/lib/invite-core.js');
}
if (errors.length > 0) {
diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs
index 0808bf6..1ecc30c 100644
--- a/scripts/dev-server.mjs
+++ b/scripts/dev-server.mjs
@@ -45,6 +45,7 @@ function sendNotFound(response) {
function resolveRequestPath(url) {
const pathname = decodeURIComponent(new URL(url, `http://127.0.0.1:${port}`).pathname);
const cleanPath = normalize(pathname).replace(/^(\.\.[/\\])+/, '');
+
const filePath = join(root, cleanPath === '/' ? 'index.html' : cleanPath);
const resolved = resolve(filePath);
@@ -52,10 +53,22 @@ function resolveRequestPath(url) {
return null;
}
+ // Prefer a real file under public/ (e.g. /invite/invite.js). Cloudflare Pages
+ // does the same: static assets win over _redirects.
+ if (existsSync(resolved) && statSync(resolved).isFile()) {
+ return resolved;
+ }
+
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
return join(resolved, 'index.html');
}
+ // Mirror public/_redirects: /invite/* → /invite/index.html (200). Keeps the
+ // browser pathname as /invite/{code} so invite.js can extract the code.
+ if (/^\/invite\/.+/.test(cleanPath)) {
+ return join(root, 'invite', 'index.html');
+ }
+
return resolved;
}
diff --git a/test/invite-core.test.mjs b/test/invite-core.test.mjs
new file mode 100644
index 0000000..7524368
--- /dev/null
+++ b/test/invite-core.test.mjs
@@ -0,0 +1,251 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, test } from 'vitest';
+
+// Importing the classic script runs it against the jsdom window and exposes the
+// helpers + copy on window.RealUnitInvite without any side effects.
+import '../public/js/lib/invite-core.js';
+
+const core = window.RealUnitInvite;
+const {
+ SUPPORTED_LANGS,
+ I18N,
+ resolveLang,
+ isRealUnitHost,
+ apiBase,
+ extractCode,
+ buildLandingUrl,
+ mapLandingResponse,
+ formatInviteGreeting,
+ formatPromoBody,
+ playStoreUrl,
+ appStoreUrl,
+ appSchemeUrl,
+ universalLinkUrl,
+} = core;
+
+function resolve(overrides) {
+ return resolveLang({
+ urlLang: null,
+ navigatorLang: null,
+ supported: SUPPORTED_LANGS,
+ defaultLang: 'de',
+ ...overrides,
+ });
+}
+
+describe('resolveLang', () => {
+ test('prefers a supported ?lang= over the browser language', () => {
+ expect(resolve({ urlLang: 'en', navigatorLang: 'de-DE' })).toBe('en');
+ });
+
+ test('normalizes a region-tagged ?lang= (EN-us → en)', () => {
+ expect(resolve({ urlLang: 'EN-us' })).toBe('en');
+ });
+
+ test('a present but unsupported ?lang= falls back to the default (browser not consulted)', () => {
+ expect(resolve({ urlLang: 'pt', navigatorLang: 'en-US' })).toBe('de');
+ });
+
+ test('uses the browser language when there is no ?lang=', () => {
+ expect(resolve({ navigatorLang: 'en-GB' })).toBe('en');
+ });
+
+ test('falls back to the explicit default for an unsupported browser language', () => {
+ expect(resolve({ navigatorLang: 'fr-FR' })).toBe('de');
+ });
+
+ test('falls back to the default when both inputs are absent (null)', () => {
+ expect(resolve({ urlLang: null, navigatorLang: null })).toBe('de');
+ });
+
+ test('treats a non-string value as absent', () => {
+ expect(resolve({ urlLang: 123, navigatorLang: undefined })).toBe('de');
+ });
+});
+
+describe('isRealUnitHost', () => {
+ test('true for the production and dev hosts', () => {
+ expect(isRealUnitHost('realunit.app')).toBe(true);
+ expect(isRealUnitHost('www.realunit.app')).toBe(true);
+ expect(isRealUnitHost('dev.realunit.app')).toBe(true);
+ });
+
+ test('false for any other host', () => {
+ expect(isRealUnitHost('localhost')).toBe(false);
+ expect(isRealUnitHost('127.0.0.1')).toBe(false);
+ });
+});
+
+describe('apiBase', () => {
+ test('production hosts map to the production API', () => {
+ expect(apiBase({ host: 'realunit.app' })).toBe('https://api.dfx.swiss');
+ expect(apiBase({ host: 'www.realunit.app' })).toBe('https://api.dfx.swiss');
+ });
+
+ test('the dev host maps to the dev API', () => {
+ expect(apiBase({ host: 'dev.realunit.app' })).toBe('https://dev.api.dfx.swiss');
+ });
+
+ test('an unknown host uses an explicit ?api= override when present', () => {
+ expect(apiBase({ host: 'localhost', paramApi: 'https://api.example.test' })).toBe(
+ 'https://api.example.test',
+ );
+ });
+
+ test('an unknown host without an override falls back to the dev API', () => {
+ expect(apiBase({ host: 'localhost', paramApi: null })).toBe('https://dev.api.dfx.swiss');
+ });
+});
+
+describe('extractCode', () => {
+ test('returns the last non-empty segment after /invite/', () => {
+ expect(extractCode('/invite/AbCdEfGhIjKlMnOp')).toBe('AbCdEfGhIjKlMnOp');
+ expect(extractCode('/invite/AbCdEfGhIjKlMnOp/')).toBe('AbCdEfGhIjKlMnOp');
+ });
+
+ test('returns empty when there is no code segment', () => {
+ expect(extractCode('/invite/')).toBe('');
+ expect(extractCode('/invite')).toBe('');
+ expect(extractCode('/')).toBe('');
+ });
+
+ test('ignores non-invite paths and non-string input', () => {
+ expect(extractCode('/confirm-aktionariat/')).toBe('');
+ expect(extractCode('')).toBe('');
+ expect(extractCode(null)).toBe('');
+ expect(extractCode(undefined)).toBe('');
+ });
+
+ test('uses the last segment when more than one follows invite', () => {
+ expect(extractCode('/invite/foo/bar')).toBe('bar');
+ });
+});
+
+describe('buildLandingUrl', () => {
+ test('appends the landing endpoint and encodes the code', () => {
+ expect(buildLandingUrl('https://dev.api.dfx.swiss', 'Ab C')).toBe(
+ 'https://dev.api.dfx.swiss/v1/realunit/referral/landing/Ab%20C',
+ );
+ });
+});
+
+describe('mapLandingResponse', () => {
+ test('404 maps to invalid', () => {
+ expect(mapLandingResponse({ status: 404, body: {} })).toBe('invalid');
+ });
+
+ test('non-2xx (other than 404) maps to unavailable', () => {
+ expect(mapLandingResponse({ status: 500, body: {} })).toBe('unavailable');
+ expect(mapLandingResponse({ status: 503, body: {} })).toBe('unavailable');
+ expect(mapLandingResponse({ status: 0, body: {} })).toBe('unavailable');
+ });
+
+ test('200 Invite maps to invite', () => {
+ expect(mapLandingResponse({ status: 200, body: { kind: 'Invite' } })).toBe('invite');
+ });
+
+ test('200 Promo maps to promo', () => {
+ expect(mapLandingResponse({ status: 200, body: { kind: 'Promo' } })).toBe('promo');
+ });
+
+ test('200 with an unrecognized kind maps to unavailable', () => {
+ expect(mapLandingResponse({ status: 200, body: { kind: 'Other' } })).toBe('unavailable');
+ expect(mapLandingResponse({ status: 200, body: {} })).toBe('unavailable');
+ expect(mapLandingResponse({ status: 200, body: null })).toBe('unavailable');
+ });
+
+ test('a missing response object maps to unavailable', () => {
+ expect(mapLandingResponse(null)).toBe('unavailable');
+ expect(mapLandingResponse(undefined)).toBe('unavailable');
+ });
+});
+
+describe('formatInviteGreeting', () => {
+ test('fills German and English templates with the guest and host names', () => {
+ expect(formatInviteGreeting('de', 'Alex', 'Sam')).toBe(
+ 'Hey Alex, Sam lädt dich ein zu RealUnit.',
+ );
+ expect(formatInviteGreeting('en', 'Alex', 'Sam')).toBe(
+ 'Hey Alex, Sam is inviting you to RealUnit.',
+ );
+ });
+
+ test('treats missing names as empty strings and unknown lang as German', () => {
+ expect(formatInviteGreeting('de', null, undefined)).toBe('Hey , lädt dich ein zu RealUnit.');
+ expect(formatInviteGreeting('fr', 'A', 'B')).toBe('Hey A, B lädt dich ein zu RealUnit.');
+ });
+});
+
+describe('formatPromoBody', () => {
+ test('German uses campaignText', () => {
+ expect(formatPromoBody('de', { campaignText: 'DE text', campaignTextEn: 'EN text' })).toBe(
+ 'DE text',
+ );
+ });
+
+ test('English prefers campaignTextEn and falls back to German', () => {
+ expect(formatPromoBody('en', { campaignText: 'DE text', campaignTextEn: 'EN text' })).toBe(
+ 'EN text',
+ );
+ expect(formatPromoBody('en', { campaignText: 'DE only' })).toBe('DE only');
+ });
+
+ test('missing body fields yield an empty string', () => {
+ expect(formatPromoBody('de', {})).toBe('');
+ expect(formatPromoBody('en', null)).toBe('');
+ });
+});
+
+describe('store and app URLs', () => {
+ test('playStoreUrl appends an invite referrer when a code is present', () => {
+ expect(playStoreUrl('AbCdEfGhIjKlMnOp')).toBe(
+ 'https://play.google.com/store/apps/details?id=swiss.realunit.app&referrer=invite%3DAbCdEfGhIjKlMnOp',
+ );
+ });
+
+ test('playStoreUrl without a code is the plain store URL', () => {
+ expect(playStoreUrl('')).toBe(
+ 'https://play.google.com/store/apps/details?id=swiss.realunit.app',
+ );
+ expect(playStoreUrl(null)).toBe(
+ 'https://play.google.com/store/apps/details?id=swiss.realunit.app',
+ );
+ });
+
+ test('appStoreUrl is the fixed App Store listing', () => {
+ expect(appStoreUrl()).toBe('https://apps.apple.com/ch/app/realunit/id6759720010');
+ });
+
+ test('appSchemeUrl uses the invite path when a code is present', () => {
+ expect(appSchemeUrl('CODE123456789012')).toBe('realunit-wallet://invite/CODE123456789012');
+ expect(appSchemeUrl('')).toBe('realunit-wallet://open');
+ expect(appSchemeUrl(null)).toBe('realunit-wallet://open');
+ });
+
+ test('universalLinkUrl builds the https self-link', () => {
+ expect(universalLinkUrl('CODE123456789012')).toBe(
+ 'https://realunit.app/invite/CODE123456789012',
+ );
+ expect(universalLinkUrl('')).toBe('https://realunit.app/invite/');
+ expect(universalLinkUrl(null)).toBe('https://realunit.app/invite/');
+ });
+});
+
+describe('i18n copy', () => {
+ test('de and en carry the exact same keys', () => {
+ expect(Object.keys(I18N.en).sort()).toEqual(Object.keys(I18N.de).sort());
+ });
+
+ test('every data-i18n* key used in the invite page exists in both languages', () => {
+ const html = readFileSync('public/invite/index.html', 'utf8');
+ const keys = new Set();
+ for (const match of html.matchAll(/data-i18n(?:-alt|-aria)?=["']([^"']+)["']/g)) {
+ keys.add(match[1]);
+ }
+ expect(keys.size).toBeGreaterThan(0);
+ for (const key of keys) {
+ expect(I18N.de).toHaveProperty([key]);
+ expect(I18N.en).toHaveProperty([key]);
+ }
+ });
+});
diff --git a/tests/behavior.spec.mjs b/tests/behavior.spec.mjs
index 6248c0f..9e53326 100644
--- a/tests/behavior.spec.mjs
+++ b/tests/behavior.spec.mjs
@@ -573,3 +573,155 @@ test.describe('account-merge flow', () => {
expect(requestedUrl).toContain('code=abc');
});
});
+
+const INVITE_LANDING_ENDPOINT = '**/v1/realunit/referral/landing/**';
+const INVITE_CODE = 'AbCdEfGhIjKlMnOp';
+
+test.describe('invite landing flow', () => {
+ // The invite logic is device-agnostic; run it once on desktop.
+ test.beforeEach(async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop-only invite-flow checks');
+ });
+
+ test('a link without a code shows the invalid state and makes no landing request', async ({
+ page,
+ }) => {
+ const landingCalls = [];
+ await page.route(INVITE_LANDING_ENDPOINT, (route) => {
+ landingCalls.push(route.request().url());
+ route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
+ });
+ await page.goto('/invite/');
+ await expect(page.locator('#state-invalid')).toBeVisible();
+ await expect(page.locator('#state-loading')).toBeHidden();
+ await expect(page.locator('html')).toHaveAttribute('data-state', 'invalid');
+ expect(landingCalls).toEqual([]);
+ });
+
+ for (const state of ['invite', 'promo', 'invalid', 'unavailable']) {
+ test(`?mock=${state} renders the ${state} state`, async ({ page }) => {
+ await page.goto(`/invite/?mock=${state}`);
+ await expect(page.locator(`#state-${state}`)).toBeVisible();
+ await expect(page.locator('html')).toHaveAttribute('data-state', state);
+ });
+ }
+
+ test('a valid invite code shows the invite state and calls the DEV base', async ({ page }) => {
+ let requestedUrl = null;
+ await page.route(INVITE_LANDING_ENDPOINT, (route) => {
+ requestedUrl = route.request().url();
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ kind: 'Invite',
+ guestName: 'Alex',
+ hostDisplayName: 'Sam',
+ }),
+ });
+ });
+ await page.goto(`/invite/${INVITE_CODE}`);
+ await expect(page.locator('#state-invite')).toBeVisible();
+ await expect(page.locator('[data-invite-body]')).toHaveText(
+ 'Hey Alex, Sam lädt dich ein zu RealUnit.',
+ );
+ expect(requestedUrl).toContain(
+ `https://dev.api.dfx.swiss/v1/realunit/referral/landing/${INVITE_CODE}`,
+ );
+ });
+
+ test('a promo response shows the campaign text and sets the Play referrer', async ({ page }) => {
+ await page.route(INVITE_LANDING_ENDPOINT, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ kind: 'Promo',
+ campaignText: 'Bonus für Neukunden',
+ campaignTextEn: 'Bonus for new customers',
+ }),
+ }),
+ );
+ await page.goto(`/invite/${INVITE_CODE}?lang=de`);
+ await expect(page.locator('#state-promo')).toBeVisible();
+ await expect(page.locator('[data-promo-body]')).toHaveText('Bonus für Neukunden');
+ await expect(page.locator('#state-promo a[data-store="play"]')).toHaveAttribute(
+ 'href',
+ `https://play.google.com/store/apps/details?id=swiss.realunit.app&referrer=invite%3D${INVITE_CODE}`,
+ );
+ await expect(page.locator('#state-promo a[data-open-app]')).toHaveAttribute(
+ 'href',
+ `realunit-wallet://invite/${INVITE_CODE}`,
+ );
+ });
+
+ test('a 404 response shows the invalid state', async ({ page }) => {
+ await page.route(INVITE_LANDING_ENDPOINT, (route) =>
+ route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }),
+ );
+ await page.goto(`/invite/${INVITE_CODE}`);
+ await expect(page.locator('#state-invalid')).toBeVisible();
+ });
+
+ test('a non-2xx API response shows the unavailable state', async ({ page }) => {
+ await page.route(INVITE_LANDING_ENDPOINT, (route) =>
+ route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }),
+ );
+ await page.goto(`/invite/${INVITE_CODE}`);
+ await expect(page.locator('#state-unavailable')).toBeVisible();
+ });
+
+ test('a network error shows the unavailable state', async ({ page }) => {
+ await page.route(INVITE_LANDING_ENDPOINT, (route) => route.abort());
+ await page.goto(`/invite/${INVITE_CODE}`);
+ await expect(page.locator('#state-unavailable')).toBeVisible();
+ });
+
+ test('the retry button re-runs the landing fetch', async ({ page }) => {
+ let calls = 0;
+ await page.route(INVITE_LANDING_ENDPOINT, (route) => {
+ calls += 1;
+ const ok = calls > 1;
+ route.fulfill({
+ status: ok ? 200 : 500,
+ contentType: 'application/json',
+ body: JSON.stringify(
+ ok ? { kind: 'Invite', guestName: 'Alex', hostDisplayName: 'Sam' } : {},
+ ),
+ });
+ });
+ await page.goto(`/invite/${INVITE_CODE}`);
+ await expect(page.locator('#state-unavailable')).toBeVisible();
+ await page.locator('#retry').click();
+ await expect(page.locator('#state-invite')).toBeVisible();
+ expect(calls).toBe(2);
+ });
+
+ test('?lang=en renders English copy and sets ', async ({ page }) => {
+ await page.goto('/invite/?mock=invalid&lang=en');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ const expected = await page.evaluate(() => window.RealUnitInvite.I18N.en['invalid.title']);
+ await expect(page.locator('#state-invalid h1')).toHaveText(expected);
+ });
+
+ test('an ?api= override sends the landing request to that API base', async ({ page }) => {
+ let requestedUrl = null;
+ await page.route(INVITE_LANDING_ENDPOINT, (route) => {
+ requestedUrl = route.request().url();
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ kind: 'Invite',
+ guestName: 'Alex',
+ hostDisplayName: 'Sam',
+ }),
+ });
+ });
+ await page.goto(`/invite/${INVITE_CODE}?api=https%3A%2F%2Fapi.example.test`);
+ await expect(page.locator('#state-invite')).toBeVisible();
+ expect(requestedUrl).toContain(
+ `https://api.example.test/v1/realunit/referral/landing/${INVITE_CODE}`,
+ );
+ });
+});
diff --git a/tests/pages.mjs b/tests/pages.mjs
index c40dd9e..2222372 100644
--- a/tests/pages.mjs
+++ b/tests/pages.mjs
@@ -8,7 +8,7 @@ export const PORT = 4173;
// Every public HTML page, used by the smoke spec. `/confirm-aktionariat/` and
// `/account-merge/` load with no query params, so they render the "invalid link"
// state without making a network request.
-export const PAGES = ['/', '/confirm-aktionariat/', '/account-merge/', '/404.html'];
+export const PAGES = ['/', '/confirm-aktionariat/', '/account-merge/', '/invite/', '/404.html'];
// Viewports the visual suite renders: desktop, a real tablet width, and a phone.
export const PROJECTS = ['desktop-chromium', 'tablet-chromium', 'mobile-safari'];
@@ -19,9 +19,10 @@ export const PROJECTS = ['desktop-chromium', 'tablet-chromium', 'mobile-safari']
// platform — optional forced platform ('ios' | 'android'); applied via a UA
// override before the page scripts run, so platform.js sets
// html[data-platform] deterministically regardless of the device
-// waitFor — optional confirm/merge-page state ('confirmed' | 'already-completed' |
-// 'invalid' | 'no-registration' | 'unavailable') to wait for before the
-// shot (the ?mock hook renders it after a short delay)
+// waitFor — optional confirm/merge/invite-page state ('confirmed' |
+// 'already-completed' | 'invalid' | 'no-registration' | 'unavailable' |
+// 'invite' | 'promo') to wait for before the shot (the ?mock hook
+// renders it after a short delay)
// projects — the viewports this view applies to
//
// Coverage: the landing page in both its equal-badge (desktop/tablet) and
@@ -164,6 +165,69 @@ export const VIEWS = [
projects: ['desktop-chromium'],
},
+ // Invite — invalid state (no code in the path).
+ {
+ slug: 'invite-invalid',
+ path: '/invite/?lang=de',
+ waitFor: 'invalid',
+ projects: ['desktop-chromium', 'mobile-safari'],
+ },
+ {
+ slug: 'invite-invalid-en',
+ path: '/invite/?lang=en',
+ waitFor: 'invalid',
+ projects: ['desktop-chromium'],
+ },
+ // Invite — personal invite success (mock), German desktop + iOS phone.
+ {
+ slug: 'invite-success',
+ path: '/invite/?mock=invite&lang=de',
+ waitFor: 'invite',
+ projects: ['desktop-chromium'],
+ },
+ {
+ slug: 'invite-success-mobile',
+ path: '/invite/?mock=invite&lang=de',
+ platform: 'ios',
+ waitFor: 'invite',
+ projects: ['mobile-safari'],
+ },
+ // Invite — personal invite success, English desktop.
+ {
+ slug: 'invite-success-en',
+ path: '/invite/?mock=invite&lang=en',
+ waitFor: 'invite',
+ projects: ['desktop-chromium'],
+ },
+ // Invite — promo success, German + English desktop.
+ {
+ slug: 'invite-promo',
+ path: '/invite/?mock=promo&lang=de',
+ waitFor: 'promo',
+ projects: ['desktop-chromium'],
+ },
+ {
+ slug: 'invite-promo-en',
+ path: '/invite/?mock=promo&lang=en',
+ waitFor: 'promo',
+ projects: ['desktop-chromium'],
+ },
+ // Invite — unavailable (retry button).
+ {
+ slug: 'invite-unavailable',
+ path: '/invite/?mock=unavailable&lang=de',
+ waitFor: 'unavailable',
+ projects: ['desktop-chromium'],
+ },
+ // Invite — Android phone store-emphasis on the invite success layout.
+ {
+ slug: 'invite-success-android',
+ path: '/invite/?mock=invite&lang=de',
+ platform: 'android',
+ waitFor: 'invite',
+ projects: ['mobile-safari'],
+ },
+
// Custom 404 page.
{
slug: 'notfound',