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.

+
+ + + + + + + + + + + + +
+
+ + + + + + 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',