From b76f5bdc668b8659b168c7f1e1e0d0ca660d2277 Mon Sep 17 00:00:00 2001 From: Jonny Luca <320529100+JonnyLuca@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:33:13 +0000 Subject: [PATCH 01/22] Add invite and promo landing pages. --- .github/workflows/dev.yaml | 2 + .github/workflows/prd.yaml | 2 + CONTRIBUTING.md | 24 + README.md | 52 +- functions/_middleware.js | 32 + functions/lib/itunes-banner.js | 618 ++++++ playwright.config.mjs | 3 + public/.well-known/apple-app-site-association | 18 + public/.well-known/assetlinks.json | 13 + public/_headers | 46 + public/_redirects | 18 + public/_routes.json | 5 + public/apple-app-site-association | 18 + public/invite/index.html | 202 ++ public/invite/invite.js | 770 ++++++++ public/js/invite-banner.js | 15 + public/js/lib/invite-core.js | 1426 ++++++++++++++ public/promo/index.html | 202 ++ scripts/check-site.mjs | 564 ++++++ scripts/dev-server.mjs | 49 +- scripts/e2e-docker.sh | 2 +- test/invite-core.test.mjs | 1708 +++++++++++++++++ test/itunes-banner-function.test.mjs | 497 +++++ .../desktop-chromium/invite-invalid.png | Bin 0 -> 31130 bytes .../desktop-chromium/invite-ok-en.png | Bin 0 -> 46681 bytes .../desktop-chromium/invite-ok.png | Bin 0 -> 50616 bytes .../desktop-chromium/invite-unavailable.png | Bin 0 -> 66755 bytes .../desktop-chromium/promo-invalid.png | Bin 0 -> 31130 bytes .../desktop-chromium/promo-ok-en.png | Bin 0 -> 76332 bytes .../desktop-chromium/promo-ok.png | Bin 0 -> 87708 bytes .../desktop-chromium/promo-unavailable.png | Bin 0 -> 63355 bytes .../mobile-safari/invite-invalid.png | Bin 0 -> 27046 bytes .../mobile-safari/invite-ok-android.png | Bin 0 -> 37394 bytes .../mobile-safari/invite-ok-ios.png | Bin 0 -> 50073 bytes .../mobile-safari/invite-unavailable.png | Bin 0 -> 58958 bytes .../mobile-safari/promo-invalid.png | Bin 0 -> 27046 bytes .../mobile-safari/promo-ok-android.png | Bin 0 -> 63960 bytes .../mobile-safari/promo-ok-ios.png | Bin 0 -> 72036 bytes .../mobile-safari/promo-unavailable.png | Bin 0 -> 57080 bytes .../tablet-chromium/invite-invalid.png | Bin 0 -> 32009 bytes .../tablet-chromium/invite-ok.png | Bin 0 -> 51026 bytes .../tablet-chromium/invite-unavailable.png | Bin 0 -> 67199 bytes .../tablet-chromium/promo-invalid.png | Bin 0 -> 32009 bytes .../tablet-chromium/promo-ok.png | Bin 0 -> 87618 bytes .../tablet-chromium/promo-unavailable.png | Bin 0 -> 63843 bytes tests/behavior.spec.mjs | 1335 +++++++++++++ tests/pages.mjs | 94 +- tests/visual.spec.mjs | 45 +- 48 files changed, 7736 insertions(+), 24 deletions(-) create mode 100644 functions/_middleware.js create mode 100644 functions/lib/itunes-banner.js 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/_routes.json create mode 100644 public/apple-app-site-association create mode 100644 public/invite/index.html create mode 100644 public/invite/invite.js create mode 100644 public/js/invite-banner.js create mode 100644 public/js/lib/invite-core.js create mode 100644 public/promo/index.html create mode 100644 test/invite-core.test.mjs create mode 100644 test/itunes-banner-function.test.mjs create mode 100644 tests/__screenshots__/desktop-chromium/invite-invalid.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-ok-en.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-ok.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-unavailable.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-invalid.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-ok-en.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-ok.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-unavailable.png create mode 100644 tests/__screenshots__/mobile-safari/invite-invalid.png create mode 100644 tests/__screenshots__/mobile-safari/invite-ok-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-ok-ios.png create mode 100644 tests/__screenshots__/mobile-safari/invite-unavailable.png create mode 100644 tests/__screenshots__/mobile-safari/promo-invalid.png create mode 100644 tests/__screenshots__/mobile-safari/promo-ok-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-ok-ios.png create mode 100644 tests/__screenshots__/mobile-safari/promo-unavailable.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-invalid.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-ok.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-unavailable.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-invalid.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-ok.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-unavailable.png diff --git a/.github/workflows/dev.yaml b/.github/workflows/dev.yaml index 50fa6eb..b6baa6f 100644 --- a/.github/workflows/dev.yaml +++ b/.github/workflows/dev.yaml @@ -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 \ diff --git a/.github/workflows/prd.yaml b/.github/workflows/prd.yaml index bf9cdae..f2706bb 100644 --- a/.github/workflows/prd.yaml +++ b/.github/workflows/prd.yaml @@ -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 \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a94f6ac..ef09961 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,30 @@ This repo is the **realunit.app** website — public, static. See the served. 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 + ` + + + + + + +
+
+

Einladung wird geladen…

+

Einen Moment bitte.

+
+ + + + + + +
+ + + diff --git a/public/invite/invite.js b/public/invite/invite.js new file mode 100644 index 0000000..83773aa --- /dev/null +++ b/public/invite/invite.js @@ -0,0 +1,770 @@ +(function () { + 'use strict'; + var core = window.RealUnitInvite; + var params = new URLSearchParams(window.location.search); + var lang = core.resolveLang({ + urlLang: params.get('lang'), + navigatorLang: navigator.language, + supported: core.SUPPORTED_LANGS, + defaultLang: 'de', + }); + var copy = core.I18N[lang]; + var parsed = core.parseCodeFromLocation( + window.location.pathname, + window.location.search, + window.location.hash, + ); + + function setText(id, text) { + var el = document.getElementById(id); + if (el) el.textContent = text; + } + + function setCodeHint(text, busy) { + setText('ok-code-hint', text); + var hint = document.getElementById('ok-code-hint'); + if (hint) hint.setAttribute('aria-busy', busy ? 'true' : 'false'); + } + + function hideLoading() { + var loading = document.getElementById('state-loading'); + if (!loading) return; + loading.hidden = true; + loading.setAttribute('aria-busy', 'false'); + } + + function setMeta(selector, content) { + var el = document.querySelector(selector); + if (el && content) el.setAttribute('content', content); + } + + var THEME_BLUE = '#1988C6'; + var THEME_RED = '#E02523'; + function setThemeColor(color) { + setMeta('meta[name="theme-color"]', color); + } + + function setCanonical(kind, code) { + var path = '/' + (kind || 'invite'); + if (code) path += '/' + encodeURIComponent(code); + var href = (core.canonicalOrigin(window.location.hostname) || window.location.origin) + path; + setMeta('meta[property="og:url"]', href); + setMeta('meta[name="twitter:url"]', href); + var link = document.querySelector('link[rel="canonical"]'); + if (!link) { + link = document.createElement('link'); + link.setAttribute('rel', 'canonical'); + document.head.appendChild(link); + } + link.setAttribute('href', href); + } + + function ensureMeta(selector, keyAttr, key) { + var el = document.querySelector(selector); + if (el) return el; + el = document.createElement('meta'); + el.setAttribute(keyAttr, key); + document.head.appendChild(el); + return el; + } + + function setShareMeta(title, description) { + if (title) { + setMeta('meta[property="og:title"]', title); + setMeta('meta[name="twitter:title"]', title); + setMeta('meta[property="og:image:alt"]', title); + setMeta('meta[name="twitter:image:alt"]', title); + } + if (description) { + setMeta('meta[property="og:description"]', description); + setMeta('meta[name="twitter:description"]', description); + setMeta('meta[name="description"]', description); + } + } + + function show(id) { + ['state-loading', 'state-ok', 'state-invalid', 'state-unavailable'].forEach(function (name) { + var node = document.getElementById(name); + if (!node) return; + node.hidden = name !== id; + if (name === id) { + node.setAttribute('aria-busy', name === 'state-loading' ? 'true' : 'false'); + if (name === 'state-loading') return; + var active = document.activeElement; + var keepFocus = core.keepLandingFocus(active); + if (!keepFocus) { + var action = + name === 'state-unavailable' + ? document.getElementById('unavailable-cta') + : name === 'state-invalid' + ? document.getElementById('invalid-home') + : null; + if (action) { + action.focus({ preventScroll: true }); + return; + } + var heading = node.querySelector('h1'); + if (heading) heading.focus({ preventScroll: true }); + } + } + }); + } + + function setCodeBoxVisible(visible) { + var box = document.getElementById('ok-code-box'); + if (box) box.hidden = !visible; + } + + function setOpenVisible(visible) { + var open = document.getElementById('ok-open'); + if (!open) return; + var platform = document.documentElement.getAttribute('data-platform'); + open.hidden = !visible || (platform !== 'ios' && platform !== 'android'); + } + + document.documentElement.lang = lang; + var pathIsPromo = /^\/+promo(\/|$)/i.test(window.location.pathname); + var isPromoPath = parsed ? parsed.kind === 'promo' : pathIsPromo; + document.title = isPromoPath ? copy['doc.title.promo'] : copy['doc.title.invite']; + setText('loading-title', isPromoPath ? copy['loading.title.promo'] : copy['loading.title']); + setText('loading-body', copy['loading.body']); + setText('invalid-title', copy['invalid.title']); + setText('invalid-body', copy['invalid.body']); + setText('invalid-home', copy['invalid.home']); + setText('unavailable-title', copy['unavailable.title']); + setText('unavailable-body', copy['unavailable.body']); + setText('unavailable-cta', copy['unavailable.cta']); + setText('unavailable-home', copy['unavailable.home']); + var homeHref = core.homeUrl(window.location.hostname); + ['invalid-home', 'unavailable-home'].forEach(function (id) { + var link = document.getElementById(id); + if (link) link.setAttribute('href', homeHref); + }); + setText('ok-cta', copy['cta']); + setText('ok-desktop', copy['cta.desktop']); + function setPitch(kind) { + setText('ok-pitch', kind === 'promo' ? copy['promo.body.fallback'] : copy['invite.pitch']); + } + setPitch(parsed ? parsed.kind : isPromoPath ? 'promo' : 'invite'); + setText('ok-retap', copy['retap']); + setText('ok-code-label', copy['code.label']); + setText('ok-code-hint', copy['code.hint']); + setText('ok-copy', copy['code.copy']); + setText('ok-copy-link', copy['link.copy']); + var descEl = document.querySelector('meta[name="description"]'); + if (descEl && copy['doc.desc']) descEl.setAttribute('content', copy['doc.desc']); + setShareMeta(document.title, copy['doc.desc']); + setCanonical(parsed ? parsed.kind : isPromoPath ? 'promo' : 'invite', parsed && parsed.code); + setMeta('meta[property="og:locale"]', lang === 'en' ? 'en_GB' : 'de_CH'); + setMeta('meta[property="og:locale:alternate"]', lang === 'en' ? 'de_CH' : 'en_GB'); + var storesNav = document.querySelector('.stores'); + if (storesNav && copy['stores.nav']) { + storesNav.setAttribute('aria-label', copy['stores.nav']); + } + var platform = document.documentElement.getAttribute('data-platform'); + document.querySelectorAll('a[data-store="apple"]').forEach(function (el) { + if (copy['stores.apple.aria']) el.setAttribute('aria-label', copy['stores.apple.aria']); + var img = el.querySelector('img'); + if (img) img.setAttribute('alt', ''); + if (platform === 'ios') el.setAttribute('aria-current', 'true'); + else el.removeAttribute('aria-current'); + }); + document.querySelectorAll('a[data-store="play"]').forEach(function (el) { + if (copy['stores.play.aria']) el.setAttribute('aria-label', copy['stores.play.aria']); + var img = el.querySelector('img'); + if (img) img.setAttribute('alt', ''); + if (platform === 'android') el.setAttribute('aria-current', 'true'); + else el.removeAttribute('aria-current'); + }); + + if (!parsed) { + setCodeBoxVisible(false); + setOpenVisible(false); + document.title = copy['invalid.title']; + setShareMeta(copy['invalid.title'], copy['invalid.body']); + setThemeColor(THEME_RED); + show('state-invalid'); + return; + } + + setText('ok-code', parsed.code); + setCodeHint(copy['code.checking'], true); + setCodeBoxVisible(true); + setOpenVisible(false); + hideLoading(); + var copyBtn = document.getElementById('ok-copy'); + var copyLinkBtn = document.getElementById('ok-copy-link'); + var codeEl = document.getElementById('ok-code'); + if (codeEl) { + codeEl.setAttribute('title', copy['code.copy']); + codeEl.setAttribute('role', 'button'); + codeEl.setAttribute('tabindex', '0'); + codeEl.setAttribute('aria-label', copy['code.label'] + ' ' + parsed.code); + } + var copyInFlight = { code: false, link: false }; + function copyControl(slot) { + return slot === 'link' ? copyLinkBtn : copyBtn; + } + function setCopyBusy(slot, busy) { + var btn = copyControl(slot); + if (btn) { + btn.disabled = !!busy; + btn.setAttribute('aria-busy', busy ? 'true' : 'false'); + } + if (slot !== 'code' || !codeEl) return; + codeEl.setAttribute('aria-busy', busy ? 'true' : 'false'); + if (busy) codeEl.setAttribute('aria-disabled', 'true'); + else codeEl.removeAttribute('aria-disabled'); + } + function copyValue(value, onCopied, onFailed, slot) { + slot = slot || 'code'; + if (copyInFlight[slot]) return; + copyInFlight[slot] = true; + setCopyBusy(slot, true); + var fallback = function () { + var area = document.createElement('textarea'); + area.value = value; + area.setAttribute('readonly', ''); + area.setAttribute('aria-hidden', 'true'); + area.setAttribute('tabindex', '-1'); + area.style.position = 'fixed'; + area.style.left = '-9999px'; + document.body.appendChild(area); + area.select(); + var ok = false; + try { + ok = !!document.execCommand('copy'); + } catch (e) { + ok = false; + } finally { + document.body.removeChild(area); + } + return ok; + }; + var settled = false; + var copyWait; + var finish = function (ok) { + if (settled) return; + settled = true; + if (copyWait) clearTimeout(copyWait); + copyInFlight[slot] = false; + setCopyBusy(slot, false); + if (ok) onCopied(); + else if (onFailed) onFailed(); + }; + copyWait = setTimeout(function () { + finish(fallback()); + }, core.COPY_TIMEOUT_MS); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(value) + .then(function () { + finish(true); + }) + .catch(function () { + finish(fallback()); + }); + } else { + finish(fallback()); + } + } + var lookupInFlight = false; + var retry = document.getElementById('unavailable-cta'); + function setRetryBusy(busy) { + if (!retry) return; + retry.disabled = !!busy; + retry.setAttribute('aria-busy', busy ? 'true' : 'false'); + } + var copyAnnounced = false; + function announceCopy(copied) { + if (copied) { + copyAnnounced = true; + setCodeHint(copy['code.copied'], false); + return; + } + if (!copyAnnounced) return; + copyAnnounced = false; + if (lookupInFlight) setCodeHint(copy['code.checking'], true); + else setCodeHint(copy['code.hint'], false); + } + function setCopyState(copied) { + var label = copied ? copy['code.copied'] : copy['code.copy']; + if (copyBtn) { + copyBtn.textContent = label; + copyBtn.setAttribute('aria-label', label + ' ' + parsed.code); + copyBtn.removeAttribute('aria-live'); + } + if (codeEl) { + codeEl.setAttribute( + 'aria-label', + (copied ? copy['code.copied'] : copy['code.label']) + ' ' + parsed.code, + ); + } + announceCopy(copied); + } + setCopyState(false); + var copyResetId; + function copyCode() { + if (copyInFlight.code) return; + copyValue( + parsed.code, + function () { + if (copyBtn) copyBtn.classList.remove('btn-copy-failed'); + if (codeEl) codeEl.classList.remove('code-copy-failed'); + setCopyState(true); + window.clearTimeout(copyResetId); + copyResetId = window.setTimeout(function () { + setCopyState(false); + }, 2000); + }, + function () { + if (copyBtn) copyBtn.classList.add('btn-copy-failed'); + if (codeEl) codeEl.classList.add('code-copy-failed'); + setCopyState(false); + window.clearTimeout(copyResetId); + copyResetId = window.setTimeout(function () { + if (copyBtn) copyBtn.classList.remove('btn-copy-failed'); + if (codeEl) codeEl.classList.remove('code-copy-failed'); + }, 2000); + }, + 'code', + ); + } + if (copyBtn) copyBtn.addEventListener('click', copyCode); + function copyLinkHref() { + var canonical = document.querySelector('link[rel="canonical"]'); + return ( + (canonical && canonical.getAttribute('href')) || + window.location.origin + '/' + parsed.kind + '/' + encodeURIComponent(parsed.code) + ); + } + function setCopyLinkState(copied) { + if (!copyLinkBtn) return; + var label = copied ? copy['code.copied'] : copy['link.copy']; + copyLinkBtn.textContent = label; + copyLinkBtn.setAttribute('aria-label', label + ' ' + copyLinkHref()); + copyLinkBtn.removeAttribute('aria-live'); + announceCopy(copied); + } + setCopyLinkState(false); + var copyLinkResetId; + function copyLink() { + copyValue( + copyLinkHref(), + function () { + if (copyLinkBtn) copyLinkBtn.classList.remove('btn-copy-failed'); + setCopyLinkState(true); + window.clearTimeout(copyLinkResetId); + copyLinkResetId = window.setTimeout(function () { + setCopyLinkState(false); + }, 2000); + }, + function () { + if (copyLinkBtn) copyLinkBtn.classList.add('btn-copy-failed'); + setCopyLinkState(false); + window.clearTimeout(copyLinkResetId); + copyLinkResetId = window.setTimeout(function () { + if (copyLinkBtn) copyLinkBtn.classList.remove('btn-copy-failed'); + }, 2000); + }, + 'link', + ); + } + if (copyLinkBtn) copyLinkBtn.addEventListener('click', copyLink); + if (codeEl) { + codeEl.addEventListener('click', copyCode); + codeEl.addEventListener('keydown', function (event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + copyCode(); + } + }); + } + + var base = core.apiBase({ host: window.location.hostname, paramApi: params.get('api') }); + var url = core.buildLookupUrl(base, parsed.code); + var appHref = core.openInAppUrl( + parsed.kind, + parsed.code, + document.documentElement.getAttribute('data-platform'), + ); + var cta = document.getElementById('ok-cta'); + if (cta) { + cta.setAttribute('href', appHref); + if (document.documentElement.getAttribute('data-platform') === 'ios') { + cta.setAttribute('aria-describedby', 'ok-retap'); + } + } + var androidAlt = document.querySelector('link[rel="alternate"][data-android-app]'); + var iosAlt = document.querySelector('link[rel="alternate"][data-ios-app]'); + function ensureAltLink(existing, attr) { + if (existing) return existing; + var link = document.createElement('link'); + link.setAttribute('rel', 'alternate'); + link.setAttribute(attr, ''); + document.head.appendChild(link); + return link; + } + var handoffActive = false; + function setShareAppLinks() { + var href = core.landingCanonicalHref(window.location.hostname, parsed.kind, parsed.code); + var scheme = core.appLink(parsed.kind, parsed.code); + ensureMeta('meta[property="al:ios:url"]', 'property', 'al:ios:url').setAttribute( + 'content', + scheme, + ); + ensureMeta( + 'meta[property="al:ios:app_store_id"]', + 'property', + 'al:ios:app_store_id', + ).setAttribute('content', '6759720010'); + ensureMeta('meta[property="al:ios:app_name"]', 'property', 'al:ios:app_name').setAttribute( + 'content', + 'RealUnit', + ); + ensureMeta('meta[property="al:android:url"]', 'property', 'al:android:url').setAttribute( + 'content', + scheme, + ); + ensureMeta( + 'meta[property="al:android:package"]', + 'property', + 'al:android:package', + ).setAttribute('content', 'swiss.realunit.app'); + ensureMeta('meta[property="al:android:class"]', 'property', 'al:android:class').setAttribute( + 'content', + 'swiss.realunit.app.MainActivity', + ); + ensureMeta( + 'meta[property="al:android:app_name"]', + 'property', + 'al:android:app_name', + ).setAttribute('content', 'RealUnit'); + ensureMeta('meta[property="al:web:url"]', 'property', 'al:web:url').setAttribute( + 'content', + href, + ); + ensureMeta( + 'meta[name="twitter:app:name:iphone"]', + 'name', + 'twitter:app:name:iphone', + ).setAttribute('content', 'RealUnit'); + ensureMeta('meta[name="twitter:app:id:iphone"]', 'name', 'twitter:app:id:iphone').setAttribute( + 'content', + '6759720010', + ); + ensureMeta( + 'meta[name="twitter:app:url:iphone"]', + 'name', + 'twitter:app:url:iphone', + ).setAttribute('content', scheme); + ensureMeta('meta[name="twitter:app:name:ipad"]', 'name', 'twitter:app:name:ipad').setAttribute( + 'content', + 'RealUnit', + ); + ensureMeta('meta[name="twitter:app:id:ipad"]', 'name', 'twitter:app:id:ipad').setAttribute( + 'content', + '6759720010', + ); + ensureMeta('meta[name="twitter:app:url:ipad"]', 'name', 'twitter:app:url:ipad').setAttribute( + 'content', + scheme, + ); + ensureMeta( + 'meta[name="twitter:app:name:googleplay"]', + 'name', + 'twitter:app:name:googleplay', + ).setAttribute('content', 'RealUnit'); + ensureMeta( + 'meta[name="twitter:app:id:googleplay"]', + 'name', + 'twitter:app:id:googleplay', + ).setAttribute('content', 'swiss.realunit.app'); + ensureMeta( + 'meta[name="twitter:app:url:googleplay"]', + 'name', + 'twitter:app:url:googleplay', + ).setAttribute('content', scheme); + ensureMeta('meta[name="twitter:app:country"]', 'name', 'twitter:app:country').setAttribute( + 'content', + 'CH', + ); + } + function clearShareAppLinks() { + [ + 'al:ios:url', + 'al:ios:app_store_id', + 'al:ios:app_name', + 'al:android:url', + 'al:android:package', + 'al:android:class', + 'al:android:app_name', + 'al:web:url', + ].forEach(function (prop) { + var el = document.querySelector('meta[property="' + prop + '"]'); + if (el) el.removeAttribute('content'); + }); + [ + 'twitter:app:name:iphone', + 'twitter:app:id:iphone', + 'twitter:app:url:iphone', + 'twitter:app:name:ipad', + 'twitter:app:id:ipad', + 'twitter:app:url:ipad', + 'twitter:app:name:googleplay', + 'twitter:app:id:googleplay', + 'twitter:app:url:googleplay', + 'twitter:app:country', + ].forEach(function (name) { + var el = document.querySelector('meta[name="' + name + '"]'); + if (el) el.removeAttribute('content'); + }); + } + function setInstallHandoff() { + handoffActive = true; + setMeta('meta[name="apple-itunes-app"]', core.itunesBanner(parsed.kind, parsed.code)); + androidAlt = ensureAltLink(androidAlt, 'data-android-app'); + androidAlt.setAttribute('href', core.androidAppUrl(parsed.kind, parsed.code)); + iosAlt = ensureAltLink(iosAlt, 'data-ios-app'); + iosAlt.setAttribute('href', core.iosAppUrl(parsed.kind, parsed.code)); + document.querySelectorAll('a[data-store="play"]').forEach(function (el) { + el.setAttribute('href', core.playStoreUrl(parsed.code, parsed.kind)); + }); + setShareAppLinks(); + } + function clearInstallHandoff() { + handoffActive = false; + setMeta('meta[name="apple-itunes-app"]', core.itunesBanner()); + document.querySelectorAll('a[data-store="play"]').forEach(function (el) { + el.setAttribute('href', core.playStoreUrl()); + }); + if (androidAlt) androidAlt.removeAttribute('href'); + if (iosAlt) iosAlt.removeAttribute('href'); + clearShareAppLinks(); + } + document.querySelectorAll('a[data-store="apple"]').forEach(function (el) { + el.setAttribute('href', core.appStoreUrl()); + }); + // Store badges stay visible during lookup — attach the code now so a + // Play/iTunes tap still carries it. Invalid lookup clears this. + setInstallHandoff(); + // App Store has no Play referrer. Copy on the tap (user gesture) so a + // badge/CTA install can still be pasted at registration (Offerte Punkt 3). + function bindInstallHandoffCopy(el) { + if (!el) return; + el.addEventListener('click', function () { + if (!handoffActive) return; + copyCode(); + }); + } + bindInstallHandoffCopy(cta); + document + .querySelectorAll('a[data-store="apple"], a[data-store="play"]') + .forEach(bindInstallHandoffCopy); + + function render(result) { + lookupInFlight = false; + setRetryBusy(false); + if (result.state === 'invalid') { + var invalidCopy = core.invalidLandingCopy(result, copy); + document.title = invalidCopy.title; + setText('invalid-title', invalidCopy.title); + setText('invalid-body', invalidCopy.body); + setShareMeta(invalidCopy.title, invalidCopy.body); + setThemeColor(THEME_RED); + setCodeBoxVisible(false); + setOpenVisible(false); + clearInstallHandoff(); + setCodeHint(copy['code.hint'], false); + show('state-invalid'); + return; + } + if (result.state === 'unavailable') { + document.title = copy['unavailable.title']; + setShareMeta(copy['unavailable.title'], copy['unavailable.body']); + setThemeColor(THEME_BLUE); + setPitch(parsed.kind); + setCodeHint(copy['code.hint'], false); + setCodeBoxVisible(true); + setOpenVisible(true); + setInstallHandoff(); + show('state-unavailable'); + return; + } + setThemeColor(THEME_BLUE); + setCodeHint(copy['code.hint'], false); + setInstallHandoff(); + setCodeBoxVisible(true); + setOpenVisible(true); + var payload = result.payload || {}; + var okTitleEl = document.getElementById('ok-title'); + var okBodyEl = document.getElementById('ok-body'); + function setNoTranslate(el, off) { + if (!el) return; + if (off) { + el.setAttribute('translate', 'no'); + el.classList.add('notranslate'); + } else { + el.removeAttribute('translate'); + el.classList.remove('notranslate'); + } + } + function setOkBody(text, sourceLang, lockTranslate) { + setText('ok-body', text); + if (!okBodyEl) return; + if (sourceLang && sourceLang !== lang) okBodyEl.setAttribute('lang', sourceLang); + else okBodyEl.removeAttribute('lang'); + setNoTranslate(okBodyEl, !!lockTranslate); + } + if (result.state === 'promo') { + setPitch('promo'); + document.title = copy['doc.title.promo']; + setText('ok-title', copy['promo.title']); + setNoTranslate(okTitleEl, false); + var promoText = String(core.promoBody(payload, lang) || '').trim(); + setOkBody( + promoText || copy['promo.body.fallback'], + promoText ? core.promoBodyLang(payload, lang) : lang, + !!promoText, + ); + } else { + setPitch('invite'); + document.title = copy['doc.title.invite']; + var invitee = core.personDisplayName(payload.inviteeName); + setText( + 'ok-title', + invitee + ? core.interpolate(copy['invite.title'], { invitee: invitee }) + : copy['invite.title.fallback'], + ); + setNoTranslate(okTitleEl, !!invitee); + var inviter = core.personDisplayName(payload.inviterName); + setOkBody(core.inviteLandingBody(payload, copy), lang, !!inviter); + } + show('state-ok'); + var okTitle = document.getElementById('ok-title'); + var okBody = document.getElementById('ok-body'); + setShareMeta( + (okTitle && okTitle.textContent) || document.title, + (okBody && okBody.textContent) || copy['doc.desc'], + ); + } + + // Local preview only (?mock=1|invalid|spent|unavailable). Never honored on + // realunit.app / dev.realunit.app, so a shared prod link cannot spoof state. + var mock = params.get('mock'); + if (mock && !core.isRealUnitHost(window.location.hostname)) { + if (mock === 'invalid') { + render({ state: 'invalid' }); + } else if (mock === 'spent') { + render({ state: 'invalid', code: 'SPENT' }); + } else if (mock === 'unavailable') { + render({ state: 'unavailable' }); + } else { + render( + core.mapResult( + 200, + parsed.kind === 'promo' + ? { + kind: 'promo', + actionText: + 'Mit dem Code EVT1 schenken wir dir bei deinem ersten erfolgreich abgewickelten Kauf von mindestens 200 RealUnit-Aktientoken 20 Token dazu. Die 20 Token werden als Zugabe zum Kauf gewährt und mindern damit den effektiven Kaufpreis. Gültig bis 7.9.2026, einmal je Person, begrenzt auf 100 Einlösungen, nicht kumulierbar mit einer Empfehlungsprämie. Die RealUnit Schweiz AG kann die Aktion jederzeit beenden.', + campaignTextEn: + 'With code EVT1 we give you 20 tokens on your first successful purchase of at least 200 RealUnit share tokens. The 20 tokens are granted as a bonus and reduce the effective purchase price. Valid until 7 Sep 2026, once per person, limited to 100 redemptions, not combinable with a referral prize. RealUnit Schweiz AG may end the campaign at any time.', + } + : { + kind: 'invite', + inviterName: 'Björn', + inviteeName: 'Alice', + actionText: '', + }, + ), + ); + } + } else { + lookupCode(); + } + + var lookupAbort; + var lookupTimeoutId; + function lookupCode() { + if (lookupAbort) lookupAbort.abort(); + clearTimeout(lookupTimeoutId); + var unavailable = document.getElementById('state-unavailable'); + var retrying = !!(unavailable && !unavailable.hidden); + lookupInFlight = true; + setRetryBusy(true); + if (!retrying) { + document.title = + parsed.kind === 'promo' ? copy['loading.title.promo'] : copy['loading.title']; + setShareMeta(document.title, copy['loading.body']); + setThemeColor(THEME_BLUE); + setCodeHint(copy['code.checking'], true); + setCodeBoxVisible(true); + setOpenVisible(false); + hideLoading(); + var active = document.activeElement; + ['state-ok', 'state-invalid', 'state-unavailable'].forEach(function (name) { + var node = document.getElementById(name); + if (!node) return; + node.hidden = true; + node.setAttribute('aria-busy', 'false'); + }); + if ( + active && + active !== document.body && + active !== document.documentElement && + (!active.isConnected || active.closest('[hidden]')) + ) { + var hint = document.getElementById('ok-code-hint'); + if (hint) hint.focus({ preventScroll: true }); + } + } else { + hideLoading(); + } + + var timedOut = false; + lookupAbort = + typeof AbortController === 'function' + ? new AbortController() + : { abort: function () {}, signal: undefined }; + var abort = lookupAbort; + lookupTimeoutId = setTimeout(function () { + timedOut = true; + abort.abort(); + if (abort !== lookupAbort) return; + // Budget elapsed — show Retry even if fetch abort is a no-op. + render({ state: 'unavailable' }); + }, core.LOOKUP_TIMEOUT_MS); + + fetch(url, core.lookupFetchInit(abort.signal)) + .then(function (res) { + return res.json().then( + function (body) { + return { status: res.status, body: body }; + }, + function () { + return { status: res.status, body: null }; + }, + ); + }) + .then(function (r) { + if (abort !== lookupAbort || timedOut) return; + clearTimeout(lookupTimeoutId); + render(core.finalizeLookup(timedOut, r.status, r.body, parsed.kind)); + }) + .catch(function (err) { + if (abort !== lookupAbort || timedOut) return; + clearTimeout(lookupTimeoutId); + if (err && err.name === 'AbortError') return; + render({ state: 'unavailable' }); + }); + } + + if (retry) { + retry.addEventListener('click', function () { + if (lookupInFlight) return; + lookupCode(); + }); + } +})(); diff --git a/public/js/invite-banner.js b/public/js/invite-banner.js new file mode 100644 index 0000000..5d0db91 --- /dev/null +++ b/public/js/invite-banner.js @@ -0,0 +1,15 @@ +/** + * Smart App Banner app-argument from the invite/promo URL. + * Must load from as a same-origin file: Cloudflare Pages CSP + * (`default-src 'self'`) blocks inline + + + + + + +
+
+

Promo-Code wird geladen…

+

Einen Moment bitte.

+
+ + + + + + +
+ + + diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs index ad8692e..b927ecf 100644 --- a/scripts/check-site.mjs +++ b/scripts/check-site.mjs @@ -135,6 +135,570 @@ 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, '/js/invite-banner.js', '/js/lib/invite-core.js'); + checkScriptOrder(label, html, '/invite/invite.js', '/js/lib/invite-core.js'); + checkScriptOrder(label, html, '/invite/invite.js', '/js/invite-banner.js'); + if (/]*\bsrc=)[^>]*>/i.test(html)) { + fail(`${label}: inline