diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 74ed071..d2719b1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -129,7 +129,7 @@ npm run e2e:docker:update # regenerate baselines after an intentional UI chang
The visual matrix lives in `tests/pages.mjs` (`VIEWS`), which is the single
source of truth — do not maintain a second list here. It currently covers six
families: the invite and promo landings (each in their loading, resolved,
-invalid, missing-code and platform-matched variants), the confirm-page states,
+invalid, missing-code, platform-matched and JS-less variants), the confirm-page states,
the account-merge pages, the home landing in its equal-badge and
platform-matched layouts, and the 404 page — across `desktop-chromium`,
`tablet-chromium` and `mobile-safari`. `check:visual` enforces that every view × applicable viewport
diff --git a/functions/lib/itunes-banner.js b/functions/lib/itunes-banner.js
index b18c0b1..6711830 100644
--- a/functions/lib/itunes-banner.js
+++ b/functions/lib/itunes-banner.js
@@ -426,7 +426,17 @@ export function parseLangFromUrl(urlLike) {
/** Crawlers snapshot og:title / twitter:title from the HTML bytes. Names wait for lookup JS. */
export function shareTitle(kind, code, lang) {
- if (!kind || !code) return null;
+ // Keep the kind guard first: this function is exported, and a missing kind
+ // must not silently render as an invitation.
+ if (!kind) return null;
+ // Without a code there is nothing code-specific to say, but ?lang=en still
+ // flips html lang and og:locale — leaving the German shell text under an
+ // English locale for crawlers. Fall back to a generic English title, using
+ // the same strings as I18N.en in invite-core.js.
+ if (!code) {
+ if (lang !== 'en') return null;
+ return kind === 'promo' ? 'RealUnit — Promo code' : 'RealUnit — Invitation';
+ }
if (lang === 'en') {
return kind === 'promo' ? 'RealUnit — Promo code ' + code : 'RealUnit — Invitation ' + code;
}
@@ -458,6 +468,10 @@ export function injectShareTitleHtml(html, kind, code, lang) {
/** Crawlers snapshot og:image:alt / twitter:image:alt from the HTML bytes. */
export function injectShareImageAltHtml(html, kind, code, lang) {
if (typeof html !== 'string') return html;
+ // Alt text describes the image, and without a code the image is the generic
+ // og.png the shell already labels "RealUnit". The codeless English title
+ // from shareTitle is a page title, not a picture caption, so it stops here.
+ if (!code) return html;
const alt = shareTitle(kind, code, lang);
if (!alt) return html;
const safe = htmlEscape(alt);
@@ -477,7 +491,10 @@ export function injectShareImageAltHtml(html, kind, code, lang) {
/** Crawlers snapshot og:description from the HTML bytes. Names wait for lookup JS. */
export function shareDescription(code, lang) {
- if (!code) return null;
+ // Same reason as shareTitle: an English locale must not keep German copy.
+ // Same string as I18N.en['doc.desc'] in invite-core.js, so the crawler
+ // snapshot and the JS-rendered page do not disagree.
+ if (!code) return lang === 'en' ? 'Open the RealUnit app with this code.' : null;
if (lang === 'en') return 'Open the RealUnit app with code ' + code + '.';
return 'Öffne die RealUnit-App mit dem Code ' + code + '.';
}
diff --git a/public/invite/index.html b/public/invite/index.html
index d4c8943..09d74f5 100644
--- a/public/invite/index.html
+++ b/public/invite/index.html
@@ -147,9 +147,36 @@
html[data-platform="ios"] .only-ios { display: block; }
[hidden] { display: none !important; }
+
+
Einladung wird geladen…
Einen Moment bitte.
diff --git a/public/js/lib/invite-core.js b/public/js/lib/invite-core.js
index 5f864c0..5396108 100644
--- a/public/js/lib/invite-core.js
+++ b/public/js/lib/invite-core.js
@@ -1005,7 +1005,16 @@
// Crawlers snapshot og:title / twitter:title from the HTML bytes.
// Names wait for lookup JS; the request URL can already name the code.
function shareTitle(kind, code, lang) {
- if (!kind || !code) return null;
+ // Kept byte-identical to shareTitle in functions/lib/itunes-banner.js;
+ // test/itunes-banner-function.test.mjs pins the two against each other.
+ // The codeless branch is unreachable through this file's own injectors —
+ // parseCodeFromLocation returns null without a code — but the function is
+ // exported, so it must not answer differently from the server module.
+ if (!kind) return null;
+ if (!code) {
+ if (lang !== 'en') return null;
+ return kind === 'promo' ? 'RealUnit — Promo code' : 'RealUnit — Invitation';
+ }
if (lang === 'en') {
return kind === 'promo' ? 'RealUnit — Promo code ' + code : 'RealUnit — Invitation ' + code;
}
@@ -1054,7 +1063,8 @@
}
function shareDescription(code, lang) {
- if (!code) return null;
+ // Kept byte-identical to shareDescription in functions/lib/itunes-banner.js.
+ if (!code) return lang === 'en' ? 'Open the RealUnit app with this code.' : null;
if (lang === 'en') return 'Open the RealUnit app with code ' + code + '.';
return 'Öffne die RealUnit-App mit dem Code ' + code + '.';
}
diff --git a/public/promo/index.html b/public/promo/index.html
index 1c0a5db..01d1b61 100644
--- a/public/promo/index.html
+++ b/public/promo/index.html
@@ -147,9 +147,36 @@
html[data-platform="ios"] .only-ios { display: block; }
[hidden] { display: none !important; }
+
+
Promo-Code wird geladen…
Einen Moment bitte.
diff --git a/test/invite-core.test.mjs b/test/invite-core.test.mjs
index 16b87a7..63489ab 100644
--- a/test/invite-core.test.mjs
+++ b/test/invite-core.test.mjs
@@ -745,6 +745,20 @@ describe('URLs', () => {
expect(enTitle).toContain('RealUnit — Invitation AB12CD');
});
+ test('the codeless English copy is exported but unreachable from this file', () => {
+ // functions/lib/itunes-banner.js answers a codeless ?lang=en landing with
+ // English copy. These exported helpers agree with it (pinned in
+ // test/itunes-banner-function.test.mjs), but this module's own injectors
+ // never get there: parseCodeFromLocation returns null without a code, so
+ // the browser page is unaffected either way.
+ expect(parseCodeFromLocation('/invite', '?lang=en', '')).toBeNull();
+ expect(shareTitle('invite', null, 'en')).toBe('RealUnit — Invitation');
+ expect(shareDescription(null, 'en')).toBe('Open the RealUnit app with this code.');
+ const shell = 'RealUnit — Einladung';
+ expect(injectShareTitleHtml(shell, '/invite', '?lang=en', '')).toBe(shell);
+ expect(injectShareDescriptionHtml(shell, '/invite', '?lang=en', '')).toBe(shell);
+ });
+
test('injectShareImageAltHtml writes og:image:alt from the path', () => {
const shell =
'' +
diff --git a/test/itunes-banner-function.test.mjs b/test/itunes-banner-function.test.mjs
index 9edaddb..6350db0 100644
--- a/test/itunes-banner-function.test.mjs
+++ b/test/itunes-banner-function.test.mjs
@@ -560,6 +560,116 @@ describe('referral code injection hardening', () => {
});
});
+describe('an English locale without a code keeps English copy', () => {
+ test('shareTitle falls back to a generic English title', () => {
+ expect(shareTitle('invite', null, 'en')).toBe('RealUnit — Invitation');
+ expect(shareTitle('promo', null, 'en')).toBe('RealUnit — Promo code');
+ // German is the shell's own language, so there is nothing to replace.
+ expect(shareTitle('invite', null, 'de')).toBeNull();
+ expect(shareTitle('invite', null, null)).toBeNull();
+ // A missing kind stays null even in English: it must not render as an
+ // invitation just because that is the more common case.
+ expect(shareTitle(null, null, 'en')).toBeNull();
+ });
+
+ test('shareDescription falls back to a generic English description', () => {
+ expect(shareDescription(null, 'en')).toBe('Open the RealUnit app with this code.');
+ expect(shareDescription(null, 'de')).toBeNull();
+ });
+
+ test('the fallback copy is the same string the page renders', () => {
+ // Both modules claim in a comment to reuse I18N.en. The literals above pin
+ // the wording; this pins the claim, so editing the catalogue alone — which
+ // public/invite/invite.js reads at runtime — turns the suite red instead of
+ // silently splitting the crawler snapshot from the rendered page.
+ const en = window.RealUnitInvite.I18N.en;
+ expect(shareTitle('invite', null, 'en')).toBe(en['doc.title.invite']);
+ expect(shareTitle('promo', null, 'en')).toBe(en['doc.title.promo']);
+ expect(shareDescription(null, 'en')).toBe(en['doc.desc']);
+ });
+
+ test('a codeless English landing is rewritten end to end, German is untouched', () => {
+ const shell =
+ 'RealUnit — Einladung' +
+ '' +
+ '' +
+ '' +
+ '';
+
+ // The helpers returning a string is not the point — the point is that the
+ // bytes a crawler snapshots actually change.
+ const en = injectLandingFromRequestUrl(shell, 'https://realunit.app/invite/?lang=en');
+ expect(en).toContain('');
+ expect(en).toContain('RealUnit — Invitation');
+ expect(en).toContain('property="og:title" content="RealUnit — Invitation"');
+ expect(en).toContain(
+ 'property="og:description" content="Open the RealUnit app with this code."',
+ );
+
+ const promo = injectLandingFromRequestUrl(shell, 'https://realunit.app/promo/?lang=en');
+ expect(promo).toContain('RealUnit — Promo code');
+
+ // Without ?lang=en the German shell must be left exactly as it is.
+ const de = injectLandingFromRequestUrl(shell, 'https://realunit.app/invite/');
+ expect(de).toContain('RealUnit — Einladung');
+ expect(de).toContain(
+ 'property="og:description" content="Öffne die RealUnit-App mit diesem Code."',
+ );
+ });
+
+ test('a code still wins over the fallback', () => {
+ expect(shareTitle('promo', 'EVT1', 'en')).toBe('RealUnit — Promo code EVT1');
+ expect(shareDescription('EVT1', 'en')).toBe('Open the RealUnit app with code EVT1.');
+ });
+
+ test('the codeless title does not become the image alt', () => {
+ // og:image:alt describes the picture, and without a code the picture is
+ // the generic og.png the shell already labels "RealUnit".
+ const shell = '';
+ expect(injectShareImageAltHtml(shell, 'invite', null, 'en')).toBe(shell);
+ expect(injectShareImageAltHtml(shell, 'invite', 'AB12CD', 'en')).toContain(
+ 'content="RealUnit — Invitation AB12CD"',
+ );
+ });
+});
+
+describe('the browser mirror and the function module say the same thing', () => {
+ // public/js/lib/invite-core.js carries a second copy of shareTitle and
+ // shareDescription. Nothing in production calls its HTML injectors, so a
+ // divergence would not fail any other test — this one pins the pair.
+ const mirror = window.RealUnitInvite;
+ const kinds = ['invite', 'promo', null, undefined, ''];
+ const codes = [null, undefined, '', 'AB12CD', 'EVT1'];
+ const langs = ['en', 'de', null, undefined, 'fr'];
+
+ test('shareTitle agrees across the whole matrix', () => {
+ for (const kind of kinds) {
+ for (const code of codes) {
+ for (const lang of langs) {
+ expect([kind, code, lang, mirror.shareTitle(kind, code, lang)]).toEqual([
+ kind,
+ code,
+ lang,
+ shareTitle(kind, code, lang),
+ ]);
+ }
+ }
+ }
+ });
+
+ test('shareDescription agrees across the whole matrix', () => {
+ for (const code of codes) {
+ for (const lang of langs) {
+ expect([code, lang, mirror.shareDescription(code, lang)]).toEqual([
+ code,
+ lang,
+ shareDescription(code, lang),
+ ]);
+ }
+ }
+ });
+});
+
describe('paths the 100% gate now covers on the function module', () => {
test('playStoreUrl without a code returns the bare store link', () => {
expect(playStoreUrl(null, 'invite')).toBe(playStoreUrl(null, 'promo'));
diff --git a/tests/__screenshots__/desktop-chromium/invite-noscript.png b/tests/__screenshots__/desktop-chromium/invite-noscript.png
new file mode 100644
index 0000000..cd309d6
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/invite-noscript.png differ
diff --git a/tests/__screenshots__/desktop-chromium/promo-noscript.png b/tests/__screenshots__/desktop-chromium/promo-noscript.png
new file mode 100644
index 0000000..cd309d6
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/promo-noscript.png differ
diff --git a/tests/__screenshots__/mobile-safari/invite-noscript.png b/tests/__screenshots__/mobile-safari/invite-noscript.png
new file mode 100644
index 0000000..1c5a23d
Binary files /dev/null and b/tests/__screenshots__/mobile-safari/invite-noscript.png differ
diff --git a/tests/__screenshots__/mobile-safari/promo-noscript.png b/tests/__screenshots__/mobile-safari/promo-noscript.png
new file mode 100644
index 0000000..1c5a23d
Binary files /dev/null and b/tests/__screenshots__/mobile-safari/promo-noscript.png differ
diff --git a/tests/__screenshots__/tablet-chromium/invite-noscript.png b/tests/__screenshots__/tablet-chromium/invite-noscript.png
new file mode 100644
index 0000000..96eafc4
Binary files /dev/null and b/tests/__screenshots__/tablet-chromium/invite-noscript.png differ
diff --git a/tests/__screenshots__/tablet-chromium/promo-noscript.png b/tests/__screenshots__/tablet-chromium/promo-noscript.png
new file mode 100644
index 0000000..96eafc4
Binary files /dev/null and b/tests/__screenshots__/tablet-chromium/promo-noscript.png differ
diff --git a/tests/behavior.spec.mjs b/tests/behavior.spec.mjs
index 5b71939..6cb9b4b 100644
--- a/tests/behavior.spec.mjs
+++ b/tests/behavior.spec.mjs
@@ -694,6 +694,66 @@ test.describe('account-merge flow', () => {
const REFERRAL_CODE_ENDPOINT = '**/v1/realunit/referral/code/**';
+test.describe('invite and promo landing without JavaScript', () => {
+ test.use({ javaScriptEnabled: false });
+
+ test('says why nothing resolves instead of spinning for ever', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop-only check');
+ for (const path of ['/invite/AB12CD', '/promo/EVT1']) {
+ await page.goto(path);
+ // Nothing can resolve the code, so the loading state must not be the
+ // only thing on screen.
+ await expect(page.locator('#state-loading')).toBeHidden();
+ // Assert what the visitor can see, not what the bytes contain: a rule
+ // like `noscript section { display: none }` would keep the markup and
+ // still leave the page blank. Measured on this Playwright version with
+ // javaScriptEnabled:false: role and CSS locators do reach into
+ //