From c1252f9d7db965b06655f42ddc124ea9c5a4a90e Mon Sep 17 00:00:00 2001 From: Jonny Luca Date: Wed, 9 Sep 2026 02:52:55 +0200 Subject: [PATCH 1/2] 01a03535 - Add invite and promo landing pages (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add invite and promo landing pages. * Add Playwright visuals for spent invite and promo landings. * Add English Playwright visuals for invalid invite and promo landings. * Add English Playwright visuals for spent invite and promo landings. * Add English Playwright visuals for unavailable invite and promo landings. * Add Playwright visuals for invite and promo landings with no code. * Add English Playwright visuals for invite and promo with no code. * Add loading mock and Playwright visuals for invite and promo landings. * Add English Playwright visuals for invite and promo loading states. * Add Android Playwright visuals for invite and promo loading states. * Add Android Playwright visuals for invalid invite and promo landings. * Add Android Playwright visuals for unavailable invite and promo landings. * Add Android Playwright visuals for spent invite and promo landings. * Add Android Playwright visuals for invite and promo with no code. * Add fallback mock and Playwright visuals for nameless invite and promo landings. * Add English Playwright visuals for nameless invite and promo landings. * Add Android Playwright visuals for nameless invite and promo landings. * Add English Android Playwright visuals for invite and promo loading states. * Add English Android Playwright visuals for invalid invite and promo landings. * Add English Android Playwright visuals for unavailable invite and promo landings. * Pin referral lookup name fields * test(web): refresh visual baselines * fix(referral): harden invite/promo landing per review EN: Escape reflected referral codes at the crawler HTML text sinks and use a function replacer so a $ in the code is never a backreference; treat a plain-text "Cannot GET" 404 as unavailable (read the body as text, not JSON); render the promo action text verbatim (do not trim the displayed value); drop the Android debug-keystore fingerprint from the production Digital Asset Links; widen the English visual matrix to tablet and mobile with regenerated baselines. DE: Reflektierte Referral-Codes an den Crawler-HTML-Textstellen escapen und per Funktions-Replacer einsetzen (ein $ im Code ist nie mehr eine Backreference); einen Klartext-404 "Cannot GET" als unavailable behandeln (Body als Text statt JSON lesen); Promo-Aktionstext 1:1 anzeigen (Anzeige nicht trimmen); den Android-Debug-Keystore-Fingerprint aus den Prod-assetlinks entfernen; die englische Visual-Matrix auf Tablet und Mobile erweitern (Baselines neu erzeugt). * test(web): widen remaining English visual views to tablet and mobile EN: Extend the loading/invalid/unavailable English invite and promo views to tablet-chromium and mobile-safari (completing the language x device x state matrix) and add the regenerated baselines. DE: Die englischen loading/invalid/unavailable-Views fuer Invite und Promo auf tablet-chromium und mobile-safari erweitern (Matrix Sprache x Geraet x Zustand vollstaendig) und die neu erzeugten Baselines ergaenzen. * fix(referral): address review findings on the landing hardening EN: Treat only a non-2xx text body as a message so a 200 with a non-JSON body (a misconfigured proxy/CDN) falls through to the safe "unavailable" state instead of a false-positive success page; add escaping tests for the image-alt and description share sinks and a browser-mirror escaping test; add an e2e regression for the 200-non-JSON case; document the deliberate escape-contract difference between the server and browser replaceTaggedAttr mirrors; drop the now-single debug fingerprint sentence from the README; and remove a stray "(orchestrator)" suffix from three test names. DE: Nur einen non-2xx-Textbody als Message behandeln, damit ein 200 mit Nicht-JSON-Body (fehlkonfigurierter Proxy/CDN) in den sicheren "unavailable"-Zustand faellt statt in eine falsch-positive Erfolgsseite; Escaping-Tests fuer die image-alt- und description-Share-Sinks sowie einen Browser-Mirror-Escaping-Test ergaenzen; eine e2e-Regression fuer den 200-Nicht-JSON-Fall hinzufuegen; die bewusste Escape-Vertragsdifferenz zwischen Server- und Browser-replaceTaggedAttr-Mirror dokumentieren; den nun einzelnen Debug-Fingerprint-Satz aus dem README entfernen; und ein verirrtes "(orchestrator)"-Suffix aus drei Testnamen loeschen. * test(referral): strengthen the landing-hardening regression tests EN: Make the escaping assertions per-sink (count the escaped code once per og/twitter/meta sink instead of a single global check) so a per-sink regression is caught; add the missing quote-escape assertion and image-alt coverage to the browser-mirror test; and assert the stubbed route was actually hit in the 200-non-JSON e2e test so it cannot pass via the generic network-error path. DE: Die Escaping-Assertions sink-spezifisch machen (den escapten Code je og/twitter/meta-Sink einmal zaehlen statt einer globalen Pruefung), damit eine Regression je Sink auffaellt; die fehlende Quote-Escape-Assertion und image-alt-Abdeckung im Browser-Mirror-Test ergaenzen; und im 200-Nicht-JSON-e2e pruefen, dass die gestubbte Route wirklich getroffen wurde, damit er nicht ueber den generischen Netzwerkfehler-Pfad besteht. * fix(invite): stop folding issued codes and foreign paths onto a code Three ways a wrong referrer could be credited: - unwrapNestedCode matched `invite/` anywhere, so an issued code containing it (`PREFIXINVITE/AB12CD`) collapsed onto its tail. Codes may legitimately contain a slash and are not character-validated, so this hit honest users. Anchored at start or slash, on both the browser and the function mirror. - codeFromReferralHostInPath accepted our host from any segment of a foreign path, so `example.com/r/realunit.app/invite/ATTACKER` yielded ATTACKER. Restricted to the marked viewer forms that need it: /amp/s/, /c/s/ and /https/. - The function-side landing matcher was unbounded, so `evilrealunit.app` contained our host as a substring and passed. Two tests asserted the first and second behaviours as intended and were corrected; the attack case is now a regression test. * test(coverage): gate the code the Pages Function actually runs coverage.include only listed public/js/lib, so the 100% badge was earned by a browser-side mirror whose two HTML transformers are referenced from tests only, while functions/lib/itunes-banner.js — the module the middleware imports — was measured not at all. It sits at 98.98% lines / 88% branches; that is now a ratchet, not a blind spot. Adds tests for three paths this uncovered (bare store link, unparseable lang, fragment-only code) and drops a dead scheme-less retry in the host-in-path unwrap. * test(invite): assert values where the suite only asserted shape Four places passed without checking the thing under test: three parser fallbacks accepted any non-empty string, the cap test accepted any 32-character output, the Play-referrer expectation was built from the same production helper it verifies, and a setAttribute stub discarded the value it was called with. * docs: describe the architecture the repo actually has Three ground rules contradicted the code they govern: public/ does not ship verbatim for invite/promo (the middleware rewrites those bytes), "no external resources of any kind (…, fetch)" sits next to the API lookup the CSP explicitly allows, and the visual matrix listed three families while VIEWS carries six across 72 views. * fix(invite): hand the app the kind the API confirmed The page switched its copy on result.state but every hand-off kept using the kind from the URL path, so /invite/ showed the promo text while the deeplink, the smart app banner and the Play install referrer still said invite= — the app then processed it as the wrong programme. The regression test fails without the fix. * fix(invite): cap the code before the final strip and never split a surrogate Capping ran after the trailing-punctuation strip, so a cut landing on a separator left it in place (…C. instead of …C). A cut through a surrogate pair left a lone surrogate, which makes encodeURIComponent throw when the code goes into the lookup URL and the deeplink. The 32-character cap itself stays: the API's sanitizeReferralCode and the app fold identically, so rejecting here instead would put the three out of step. --------- Co-authored-by: Jonny Luca <320529100+JonnyLuca@users.noreply.github.com> Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- .github/workflows/dev.yaml | 2 + .github/workflows/prd.yaml | 2 + CONTRIBUTING.md | 47 +- README.md | 50 +- functions/_middleware.js | 32 + functions/lib/itunes-banner.js | 652 ++++++ playwright.config.mjs | 3 + public/.well-known/apple-app-site-association | 18 + public/.well-known/assetlinks.json | 12 + 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 | 806 ++++++++ public/js/invite-banner.js | 15 + public/js/lib/invite-core.js | 1474 ++++++++++++++ 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 | 1802 +++++++++++++++++ test/itunes-banner-function.test.mjs | 582 ++++++ .../desktop-chromium/invite-invalid-en.png | Bin 0 -> 27083 bytes .../desktop-chromium/invite-invalid.png | Bin 0 -> 29502 bytes .../desktop-chromium/invite-loading-en.png | Bin 0 -> 40334 bytes .../desktop-chromium/invite-loading.png | Bin 0 -> 45608 bytes .../invite-missing-code-en.png | Bin 0 -> 27083 bytes .../desktop-chromium/invite-missing-code.png | Bin 0 -> 29502 bytes .../desktop-chromium/invite-ok-en.png | Bin 0 -> 45118 bytes .../invite-ok-fallback-en.png | Bin 0 -> 45582 bytes .../desktop-chromium/invite-ok-fallback.png | Bin 0 -> 49989 bytes .../desktop-chromium/invite-ok.png | Bin 0 -> 48208 bytes .../desktop-chromium/invite-spent-en.png | Bin 0 -> 27260 bytes .../desktop-chromium/invite-spent.png | Bin 0 -> 28969 bytes .../invite-unavailable-en.png | Bin 0 -> 58068 bytes .../desktop-chromium/invite-unavailable.png | Bin 0 -> 64514 bytes .../desktop-chromium/promo-invalid-en.png | Bin 0 -> 27083 bytes .../desktop-chromium/promo-invalid.png | Bin 0 -> 29502 bytes .../desktop-chromium/promo-loading-en.png | Bin 0 -> 37226 bytes .../desktop-chromium/promo-loading.png | Bin 0 -> 43390 bytes .../promo-missing-code-en.png | Bin 0 -> 27083 bytes .../desktop-chromium/promo-missing-code.png | Bin 0 -> 29502 bytes .../desktop-chromium/promo-ok-en.png | Bin 0 -> 74870 bytes .../desktop-chromium/promo-ok-fallback-en.png | Bin 0 -> 41568 bytes .../desktop-chromium/promo-ok-fallback.png | Bin 0 -> 46443 bytes .../desktop-chromium/promo-ok.png | Bin 0 -> 84508 bytes .../desktop-chromium/promo-spent-en.png | Bin 0 -> 27260 bytes .../desktop-chromium/promo-spent.png | Bin 0 -> 28969 bytes .../desktop-chromium/promo-unavailable-en.png | Bin 0 -> 54244 bytes .../desktop-chromium/promo-unavailable.png | Bin 0 -> 60582 bytes .../confirm-confirmed-mobile.png | Bin 34244 -> 60764 bytes .../mobile-safari/confirm-invalid.png | Bin 39513 -> 63782 bytes .../mobile-safari/confirm-no-registration.png | Bin 52971 -> 67971 bytes .../mobile-safari/confirm-unavailable.png | Bin 42695 -> 62280 bytes .../mobile-safari/home-android.png | Bin 85871 -> 98612 bytes .../mobile-safari/home-ios.png | Bin 85666 -> 98853 bytes .../mobile-safari/invite-invalid-android.png | Bin 0 -> 27648 bytes .../invite-invalid-en-android.png | Bin 0 -> 25121 bytes .../mobile-safari/invite-invalid-en.png | Bin 0 -> 25599 bytes .../mobile-safari/invite-invalid.png | Bin 0 -> 28111 bytes .../mobile-safari/invite-loading-android.png | Bin 0 -> 33266 bytes .../invite-loading-en-android.png | Bin 0 -> 31127 bytes .../mobile-safari/invite-loading-en.png | Bin 0 -> 42625 bytes .../mobile-safari/invite-loading.png | Bin 0 -> 47015 bytes .../invite-missing-code-android.png | Bin 0 -> 27648 bytes .../mobile-safari/invite-missing-code-en.png | Bin 0 -> 25599 bytes .../mobile-safari/invite-missing-code.png | Bin 0 -> 28111 bytes .../mobile-safari/invite-ok-android.png | Bin 0 -> 38840 bytes .../mobile-safari/invite-ok-en.png | Bin 0 -> 49289 bytes .../invite-ok-fallback-android.png | Bin 0 -> 40594 bytes .../mobile-safari/invite-ok-fallback-en.png | Bin 0 -> 49721 bytes .../mobile-safari/invite-ok-fallback.png | Bin 0 -> 53776 bytes .../mobile-safari/invite-ok-ios.png | Bin 0 -> 52059 bytes .../mobile-safari/invite-spent-android.png | Bin 0 -> 26316 bytes .../mobile-safari/invite-spent-en.png | Bin 0 -> 25564 bytes .../mobile-safari/invite-spent.png | Bin 0 -> 26794 bytes .../invite-unavailable-android.png | Bin 0 -> 52035 bytes .../invite-unavailable-en-android.png | Bin 0 -> 49606 bytes .../mobile-safari/invite-unavailable-en.png | Bin 0 -> 56632 bytes .../mobile-safari/invite-unavailable.png | Bin 0 -> 60879 bytes .../mobile-safari/merge-already-completed.png | Bin 35409 -> 60045 bytes .../mobile-safari/merge-confirmed-mobile.png | Bin 34242 -> 59109 bytes .../mobile-safari/merge-invalid.png | Bin 39712 -> 63512 bytes .../mobile-safari/merge-unavailable.png | Bin 42598 -> 61544 bytes .../mobile-safari/notfound.png | Bin 35953 -> 86183 bytes .../mobile-safari/promo-invalid-android.png | Bin 0 -> 27648 bytes .../promo-invalid-en-android.png | Bin 0 -> 25121 bytes .../mobile-safari/promo-invalid-en.png | Bin 0 -> 25599 bytes .../mobile-safari/promo-invalid.png | Bin 0 -> 28111 bytes .../mobile-safari/promo-loading-android.png | Bin 0 -> 31653 bytes .../promo-loading-en-android.png | Bin 0 -> 28343 bytes .../mobile-safari/promo-loading-en.png | Bin 0 -> 40071 bytes .../mobile-safari/promo-loading.png | Bin 0 -> 45349 bytes .../promo-missing-code-android.png | Bin 0 -> 27648 bytes .../mobile-safari/promo-missing-code-en.png | Bin 0 -> 25599 bytes .../mobile-safari/promo-missing-code.png | Bin 0 -> 28111 bytes .../mobile-safari/promo-ok-android.png | Bin 0 -> 66868 bytes .../mobile-safari/promo-ok-en.png | Bin 0 -> 68358 bytes .../promo-ok-fallback-android.png | Bin 0 -> 38104 bytes .../mobile-safari/promo-ok-fallback-en.png | Bin 0 -> 47041 bytes .../mobile-safari/promo-ok-fallback.png | Bin 0 -> 50594 bytes .../mobile-safari/promo-ok-ios.png | Bin 0 -> 75217 bytes .../mobile-safari/promo-spent-android.png | Bin 0 -> 26316 bytes .../mobile-safari/promo-spent-en.png | Bin 0 -> 25564 bytes .../mobile-safari/promo-spent.png | Bin 0 -> 26794 bytes .../promo-unavailable-android.png | Bin 0 -> 48702 bytes .../promo-unavailable-en-android.png | Bin 0 -> 46850 bytes .../mobile-safari/promo-unavailable-en.png | Bin 0 -> 57088 bytes .../mobile-safari/promo-unavailable.png | Bin 0 -> 58519 bytes .../tablet-chromium/invite-invalid-en.png | Bin 0 -> 27995 bytes .../tablet-chromium/invite-invalid.png | Bin 0 -> 30472 bytes .../tablet-chromium/invite-loading-en.png | Bin 0 -> 41163 bytes .../tablet-chromium/invite-loading.png | Bin 0 -> 46424 bytes .../invite-missing-code-en.png | Bin 0 -> 27995 bytes .../tablet-chromium/invite-missing-code.png | Bin 0 -> 30472 bytes .../tablet-chromium/invite-ok-en.png | Bin 0 -> 45901 bytes .../tablet-chromium/invite-ok-fallback-en.png | Bin 0 -> 46346 bytes .../tablet-chromium/invite-ok-fallback.png | Bin 0 -> 50939 bytes .../tablet-chromium/invite-ok.png | Bin 0 -> 49169 bytes .../tablet-chromium/invite-spent-en.png | Bin 0 -> 28171 bytes .../tablet-chromium/invite-spent.png | Bin 0 -> 29945 bytes .../tablet-chromium/invite-unavailable-en.png | Bin 0 -> 59095 bytes .../tablet-chromium/invite-unavailable.png | Bin 0 -> 65323 bytes .../tablet-chromium/promo-invalid-en.png | Bin 0 -> 27995 bytes .../tablet-chromium/promo-invalid.png | Bin 0 -> 30472 bytes .../tablet-chromium/promo-loading-en.png | Bin 0 -> 38161 bytes .../tablet-chromium/promo-loading.png | Bin 0 -> 43954 bytes .../tablet-chromium/promo-missing-code-en.png | Bin 0 -> 27995 bytes .../tablet-chromium/promo-missing-code.png | Bin 0 -> 30472 bytes .../tablet-chromium/promo-ok-en.png | Bin 0 -> 75925 bytes .../tablet-chromium/promo-ok-fallback-en.png | Bin 0 -> 42221 bytes .../tablet-chromium/promo-ok-fallback.png | Bin 0 -> 47337 bytes .../tablet-chromium/promo-ok.png | Bin 0 -> 85290 bytes .../tablet-chromium/promo-spent-en.png | Bin 0 -> 28171 bytes .../tablet-chromium/promo-spent.png | Bin 0 -> 29945 bytes .../tablet-chromium/promo-unavailable-en.png | Bin 0 -> 55384 bytes .../tablet-chromium/promo-unavailable.png | Bin 0 -> 61383 bytes tests/behavior.spec.mjs | 1445 +++++++++++++ tests/pages.mjs | 340 +++- tests/visual.spec.mjs | 45 +- vitest.config.mjs | 25 +- 142 files changed, 8422 insertions(+), 36 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-en.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-invalid.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-loading-en.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-loading.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-missing-code-en.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-missing-code.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-ok-en.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-ok-fallback-en.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-ok-fallback.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-ok.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-spent-en.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-spent.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-unavailable-en.png create mode 100644 tests/__screenshots__/desktop-chromium/invite-unavailable.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-invalid-en.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-invalid.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-loading-en.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-loading.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-missing-code-en.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-missing-code.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-ok-en.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-ok-fallback-en.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-ok-fallback.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-ok.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-spent-en.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-spent.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-unavailable-en.png create mode 100644 tests/__screenshots__/desktop-chromium/promo-unavailable.png create mode 100644 tests/__screenshots__/mobile-safari/invite-invalid-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-invalid-en-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-invalid-en.png create mode 100644 tests/__screenshots__/mobile-safari/invite-invalid.png create mode 100644 tests/__screenshots__/mobile-safari/invite-loading-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-loading-en-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-loading-en.png create mode 100644 tests/__screenshots__/mobile-safari/invite-loading.png create mode 100644 tests/__screenshots__/mobile-safari/invite-missing-code-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-missing-code-en.png create mode 100644 tests/__screenshots__/mobile-safari/invite-missing-code.png create mode 100644 tests/__screenshots__/mobile-safari/invite-ok-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-ok-en.png create mode 100644 tests/__screenshots__/mobile-safari/invite-ok-fallback-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-ok-fallback-en.png create mode 100644 tests/__screenshots__/mobile-safari/invite-ok-fallback.png create mode 100644 tests/__screenshots__/mobile-safari/invite-ok-ios.png create mode 100644 tests/__screenshots__/mobile-safari/invite-spent-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-spent-en.png create mode 100644 tests/__screenshots__/mobile-safari/invite-spent.png create mode 100644 tests/__screenshots__/mobile-safari/invite-unavailable-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-unavailable-en-android.png create mode 100644 tests/__screenshots__/mobile-safari/invite-unavailable-en.png create mode 100644 tests/__screenshots__/mobile-safari/invite-unavailable.png create mode 100644 tests/__screenshots__/mobile-safari/promo-invalid-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-invalid-en-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-invalid-en.png create mode 100644 tests/__screenshots__/mobile-safari/promo-invalid.png create mode 100644 tests/__screenshots__/mobile-safari/promo-loading-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-loading-en-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-loading-en.png create mode 100644 tests/__screenshots__/mobile-safari/promo-loading.png create mode 100644 tests/__screenshots__/mobile-safari/promo-missing-code-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-missing-code-en.png create mode 100644 tests/__screenshots__/mobile-safari/promo-missing-code.png create mode 100644 tests/__screenshots__/mobile-safari/promo-ok-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-ok-en.png create mode 100644 tests/__screenshots__/mobile-safari/promo-ok-fallback-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-ok-fallback-en.png create mode 100644 tests/__screenshots__/mobile-safari/promo-ok-fallback.png create mode 100644 tests/__screenshots__/mobile-safari/promo-ok-ios.png create mode 100644 tests/__screenshots__/mobile-safari/promo-spent-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-spent-en.png create mode 100644 tests/__screenshots__/mobile-safari/promo-spent.png create mode 100644 tests/__screenshots__/mobile-safari/promo-unavailable-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-unavailable-en-android.png create mode 100644 tests/__screenshots__/mobile-safari/promo-unavailable-en.png create mode 100644 tests/__screenshots__/mobile-safari/promo-unavailable.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-invalid-en.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-invalid.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-loading-en.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-loading.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-missing-code-en.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-missing-code.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-ok-en.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-ok-fallback-en.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-ok-fallback.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-ok.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-spent-en.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-spent.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-unavailable-en.png create mode 100644 tests/__screenshots__/tablet-chromium/invite-unavailable.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-invalid-en.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-invalid.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-loading-en.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-loading.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-missing-code-en.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-missing-code.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-ok-en.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-ok-fallback-en.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-ok-fallback.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-ok.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-spent-en.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-spent.png create mode 100644 tests/__screenshots__/tablet-chromium/promo-unavailable-en.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..74ed071 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,12 +7,42 @@ This repo is the **realunit.app** website — public, static. See the - **No build toolchain for the site.** Plain HTML/CSS/JS only. Everything in `public/` ships verbatim to Cloudflare Pages — what you commit is what gets - served. The dev dependencies exist **only** for the quality gates below + served. The one exception is the invite/promo HTML: `functions/_middleware.js` + rewrites those bytes on the way out so crawlers see the code in + `apple-itunes-app`, `og:*` and the App Links before any script runs. Nothing + else is transformed, and there is no server-side rendering. The dev dependencies exist **only** for the quality gates below (formatting, HTML validation, unit tests, screenshots); nothing compiles or bundles the site. +- **Invite/promo HTML rewrite is banner, canonical, and store handoff.** Safari, + Play, and share crawlers snapshot `apple-itunes-app`, `og:url`, + `rel=canonical`, `twitter:url`, `og:title`, `twitter:title`, + `og:description`, `twitter:description`, `og:image:alt`, `twitter:image:alt`, + `og:locale`, `og:site_name`, html `lang`, the Play + referrer, android-app / ios-app alternate links, Facebook App Links + (`al:ios:url` / `al:android:url` are `realunit-wallet://…`; `al:android:class` + is `swiss.realunit.app.MainActivity`; `al:web:url` + is the HTTPS landing), and Twitter App Card `twitter:app:url:iphone` / + `twitter:app:url:ipad` / `twitter:app:url:googleplay` (same custom scheme) + from the HTML bytes before + `/js/invite-banner.js` / `invite.js` run, so `functions/_middleware.js` + (repo-root `functions/`, picked up by `wrangler pages deploy public`) and + `scripts/dev-server.mjs` inject those from the request URL (www folded onto + the apex). The campaign code is in `og:title` / `twitter:title` / + `og:description` / `og:image:alt` / `twitter:image:alt`; `?lang=en` sets English title/description/alt and `og:locale`; + invitee names wait for lookup JS. The committed + `public/invite` and `public/promo` HTML stay generic (`app-id` only, og:url / + twitter:url `/invite/` or `/promo/`, `og:site_name` RealUnit, generic titles and descriptions, Play + href without referrer, no `al:*`, no `twitter:app:*`, `format-detection` + `telephone=no, date=no` so iOS does not turn the code or Aktionstext date + into a link, `x-apple-data-detectors="false"` on `#ok-code` / `#ok-body` + because Safari re-scans JS-inserted text, no inline + ` + + + + + + +
+
+

Einladung wird geladen…

+

Einen Moment bitte.

+
+ +
+ + + + +
+ + + diff --git a/public/invite/invite.js b/public/invite/invite.js new file mode 100644 index 0000000..db13eb6 --- /dev/null +++ b/public/invite/invite.js @@ -0,0 +1,806 @@ +(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); + // The path only tells us how the link was written; the API tells us what the + // code actually is. Until the lookup answers we go with the path, then every + // hand-off below is re-applied with the confirmed kind — otherwise + // /invite/ would send `invite=` to the app while the page + // already shows the promo copy. + var effectiveKind = parsed.kind; + var appHref = core.openInAppUrl( + effectiveKind, + 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, effectiveKind, parsed.code); + var scheme = core.appLink(effectiveKind, 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(effectiveKind, parsed.code)); + androidAlt = ensureAltLink(androidAlt, 'data-android-app'); + androidAlt.setAttribute('href', core.androidAppUrl(effectiveKind, parsed.code)); + iosAlt = ensureAltLink(iosAlt, 'data-ios-app'); + iosAlt.setAttribute('href', core.iosAppUrl(effectiveKind, parsed.code)); + document.querySelectorAll('a[data-store="play"]').forEach(function (el) { + el.setAttribute('href', core.playStoreUrl(parsed.code, effectiveKind)); + }); + 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); + } + // The API has now told us what this code is. Re-apply the hand-off so the + // deeplink, the smart app banner and the Play install referrer carry the + // confirmed kind, not the one the URL happened to use. + var confirmedKind = result.state === 'promo' ? 'promo' : 'invite'; + if (confirmedKind !== effectiveKind) { + effectiveKind = confirmedKind; + appHref = core.openInAppUrl( + effectiveKind, + parsed.code, + document.documentElement.getAttribute('data-platform'), + ); + var ctaEl = document.getElementById('ok-cta'); + if (ctaEl && ctaEl.hasAttribute('href')) ctaEl.setAttribute('href', appHref); + if (handoffActive) setInstallHandoff(); + } + 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) || ''); + var promoHasText = promoText.trim().length > 0; + setOkBody( + promoHasText ? promoText : copy['promo.body.fallback'], + promoHasText ? core.promoBodyLang(payload, lang) : lang, + promoHasText, + ); + } 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|loading|fallback). + // 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 if (mock === 'loading') { + show('state-loading'); + } else if (mock === 'fallback') { + render(core.mapResult(200, parsed.kind === 'promo' ? { kind: 'promo' } : { kind: 'invite' })); + } 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) { + // Read the body as text first: an unmounted NestJS route answers with a + // plain-text "Cannot GET ..." 404, not JSON. Wrap that text as { message } + // so mapResult treats it as 'unavailable' instead of 'invalid'. + return res.text().then(function (text) { + var body; + try { + body = text ? JSON.parse(text) : null; + } catch (e) { + // Only a non-2xx text body (an unmounted NestJS "Cannot GET" + // 404) is wrapped for mapResult to classify; a 2xx with a + // non-JSON body stays bodyless so it falls through to the safe + // 'unavailable' state rather than a false-positive success page. + var nonOk = res.status < 200 || res.status >= 300; + body = nonOk && text ? { message: text } : null; + } + return { status: res.status, body: body }; + }); + }) + .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