From 10345aaba3ae128401661a8010490e048e27cec9 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sat, 14 Mar 2026 14:20:47 +0000 Subject: [PATCH 1/3] feat: add promotional banner for iframe-embedded games When the game is loaded inside an iframe (e.g., wordlespielen.de embedding /de), show a subtle bottom banner after game completion that links to wordle.global. The banner has a 30-day dismiss cooldown and does not show for direct visitors or standalone PWA users. --- frontend/src/embed.ts | 65 ++++++++++++++++++++++++++++++++++++++ frontend/src/game.ts | 14 ++++++-- frontend/src/main.ts | 3 ++ frontend/src/style.css | 27 ++++++++++++++++ webapp/templates/game.html | 12 +++++++ 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 frontend/src/embed.ts diff --git a/frontend/src/embed.ts b/frontend/src/embed.ts new file mode 100644 index 00000000..e081ab84 --- /dev/null +++ b/frontend/src/embed.ts @@ -0,0 +1,65 @@ +/** + * Embed Module - Shows a banner when the game is loaded inside an iframe + * Promotes wordle.global to users playing via third-party iframe embeds + */ + +import { isStandalone } from './pwa'; + +const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +export const isEmbedded = (): boolean => { + try { + return window.top !== window.self; + } catch { + // Cross-origin iframe throws SecurityError — that means we're embedded + return true; + } +}; + +const isDismissed = (): boolean => { + try { + const dismissedAt = localStorage.getItem('embed_banner_dismissed_at'); + if (!dismissedAt) return false; + const elapsed = Date.now() - parseInt(dismissedAt, 10); + return elapsed < DISMISS_DURATION_MS; + } catch { + // localStorage may throw in private browsing mode + return false; + } +}; + +let dismissed = isDismissed(); + +const getBanner = (): HTMLElement | null => document.getElementById('embed-banner'); + +export const hideBanner = (): void => { + const banner = getBanner(); + if (banner) banner.style.display = 'none'; +}; + +export const showBanner = (): void => { + if (dismissed || !isEmbedded() || isStandalone()) return; + const banner = getBanner(); + if (banner) { + banner.style.display = 'flex'; + } +}; + +export const dismiss = (): void => { + dismissed = true; + try { + localStorage.setItem('embed_banner_dismissed_at', Date.now().toString()); + } catch { + // localStorage may throw in private browsing mode + } + hideBanner(); +}; + +const embed = { + isEmbedded, + showBanner, + hideBanner, + dismiss, +}; + +export default embed; diff --git a/frontend/src/game.ts b/frontend/src/game.ts index d810ac37..74053dc6 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -4,6 +4,7 @@ */ import { createApp } from 'vue'; import pwa from './pwa'; +import embed from './embed'; import { haptic, setHapticsEnabled } from './haptics'; import { sound, setSoundEnabled } from './sounds'; import { buildNormalizeMap, buildNormalizedWordMap, normalizeWord } from './diacritics'; @@ -741,8 +742,12 @@ export const createGameApp = () => { }); analytics.trackStreakMilestone(langCode, this.stats.current_streak); - // Show PWA install prompt after game completion - setTimeout(() => pwa.showBanner(), 2000); + // Show embed banner (if in iframe) or PWA install prompt after game completion + if (embed.isEmbedded()) { + setTimeout(() => embed.showBanner(), 2000); + } else { + setTimeout(() => pwa.showBanner(), 2000); + } }, gameLost(): void { @@ -783,6 +788,11 @@ export const createGameApp = () => { had_frustration: lossFrustrationState.hadFrustration, time_to_complete_seconds: lossTimeToComplete, }); + + // Show embed banner (if in iframe) after game loss + if (embed.isEmbedded()) { + setTimeout(() => embed.showBanner(), 3000); + } }, saveResult(won: boolean): void { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 27ec37ec..c6e7d82f 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,6 +4,7 @@ */ import './style.css'; import pwa from './pwa'; +import embed from './embed'; import './debug'; import createGameApp from './game'; import createIndexApp from './index-app'; @@ -14,6 +15,7 @@ declare global { triggerPwaInstall: () => void; dismissPwaInstall: () => void; showPwaInstallBanner: () => void; + dismissEmbedBanner: () => void; } } @@ -24,6 +26,7 @@ pwa.init(); window.triggerPwaInstall = pwa.install; window.dismissPwaInstall = pwa.dismiss; window.showPwaInstallBanner = pwa.showBanner; +window.dismissEmbedBanner = embed.dismiss; // Detect which page we're on and create appropriate Vue app const appEl = document.getElementById('app'); diff --git a/frontend/src/style.css b/frontend/src/style.css index 7b176bc6..eb3b0c22 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -111,6 +111,33 @@ line-height: 1; } +/* Embed Banner (for iframe embeds) */ +.embed-banner { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(135deg, #2d6a4f 0%, #1b4332 100%); + color: white; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + z-index: 1000; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); + font-family: system-ui, -apple-system, sans-serif; +} + +.embed-banner button.dismiss { + background: transparent; + color: white; + border: none; + padding: 8px; + font-size: 20px; + line-height: 1; + cursor: pointer; +} + /* Prevent text selection on game elements */ .no-select { user-select: none; diff --git a/webapp/templates/game.html b/webapp/templates/game.html index e577b364..84ace38d 100644 --- a/webapp/templates/game.html +++ b/webapp/templates/game.html @@ -649,6 +649,18 @@

× + + + {% set pwa_description = "Play Wordle in " ~ language.config.name_native %} {% include 'partials/_pwa_install.html' %} From 002590cc29134093c953f957a7c1aaf8cfa67181 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sat, 14 Mar 2026 14:26:28 +0000 Subject: [PATCH 2/3] refactor: simplify embed banner code after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache isEmbedded as a const (never changes during session) - Lazy-evaluate isDismissed() only when showing banner (avoid localStorage read for non-embedded users) - Remove redundant isEmbedded() checks in game.ts callers — let showBanner() own its own guard logic - Call both embed.showBanner() and pwa.showBanner() unconditionally (each no-ops when not applicable), fixing gameLost inconsistency - Extract shared .bottom-banner CSS base class to deduplicate styles - Move embed banner inline styles to CSS classes --- frontend/src/embed.ts | 9 +++----- frontend/src/game.ts | 18 +++++++-------- frontend/src/style.css | 46 +++++++++++++++----------------------- webapp/templates/game.html | 9 ++++---- 4 files changed, 33 insertions(+), 49 deletions(-) diff --git a/frontend/src/embed.ts b/frontend/src/embed.ts index e081ab84..27ea64ae 100644 --- a/frontend/src/embed.ts +++ b/frontend/src/embed.ts @@ -7,14 +7,14 @@ import { isStandalone } from './pwa'; const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days -export const isEmbedded = (): boolean => { +export const isEmbedded: boolean = (() => { try { return window.top !== window.self; } catch { // Cross-origin iframe throws SecurityError — that means we're embedded return true; } -}; +})(); const isDismissed = (): boolean => { try { @@ -28,8 +28,6 @@ const isDismissed = (): boolean => { } }; -let dismissed = isDismissed(); - const getBanner = (): HTMLElement | null => document.getElementById('embed-banner'); export const hideBanner = (): void => { @@ -38,7 +36,7 @@ export const hideBanner = (): void => { }; export const showBanner = (): void => { - if (dismissed || !isEmbedded() || isStandalone()) return; + if (!isEmbedded || isDismissed() || isStandalone()) return; const banner = getBanner(); if (banner) { banner.style.display = 'flex'; @@ -46,7 +44,6 @@ export const showBanner = (): void => { }; export const dismiss = (): void => { - dismissed = true; try { localStorage.setItem('embed_banner_dismissed_at', Date.now().toString()); } catch { diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 74053dc6..62e4fd5b 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -742,12 +742,12 @@ export const createGameApp = () => { }); analytics.trackStreakMilestone(langCode, this.stats.current_streak); - // Show embed banner (if in iframe) or PWA install prompt after game completion - if (embed.isEmbedded()) { - setTimeout(() => embed.showBanner(), 2000); - } else { - setTimeout(() => pwa.showBanner(), 2000); - } + // Show embed banner or PWA install prompt after game completion + // Each no-ops if not applicable (embed checks iframe, pwa checks standalone/prompt) + setTimeout(() => { + embed.showBanner(); + pwa.showBanner(); + }, 2000); }, gameLost(): void { @@ -789,10 +789,8 @@ export const createGameApp = () => { time_to_complete_seconds: lossTimeToComplete, }); - // Show embed banner (if in iframe) after game loss - if (embed.isEmbedded()) { - setTimeout(() => embed.showBanner(), 3000); - } + // Show embed banner after game loss (no-ops if not in iframe) + setTimeout(() => embed.showBanner(), 3000); }, saveResult(won: boolean): void { diff --git a/frontend/src/style.css b/frontend/src/style.css index eb3b0c22..057a329e 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -71,13 +71,12 @@ padding-bottom: env(safe-area-inset-bottom); } -/* PWA Install Banner */ -.pwa-install-banner { +/* Bottom Banners (shared base for PWA install + embed banners) */ +.bottom-banner { position: fixed; bottom: 0; left: 0; right: 0; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px 16px; display: flex; @@ -88,54 +87,45 @@ font-family: system-ui, -apple-system, sans-serif; } -.pwa-install-banner button { +.bottom-banner .action-btn { background: white; - color: #667eea; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; + text-decoration: none; transition: transform 0.1s; } -.pwa-install-banner button:active { +.bottom-banner .action-btn:active { transform: scale(0.95); } -.pwa-install-banner button.dismiss { +.bottom-banner button.dismiss { background: transparent; color: white; + border: none; padding: 8px; font-size: 20px; line-height: 1; + cursor: pointer; +} + +.pwa-install-banner { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.pwa-install-banner .action-btn { + color: #667eea; } -/* Embed Banner (for iframe embeds) */ .embed-banner { - position: fixed; - bottom: 0; - left: 0; - right: 0; background: linear-gradient(135deg, #2d6a4f 0%, #1b4332 100%); - color: white; - padding: 12px 16px; - display: flex; - align-items: center; - gap: 12px; - z-index: 1000; - box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); - font-family: system-ui, -apple-system, sans-serif; } -.embed-banner button.dismiss { - background: transparent; - color: white; - border: none; - padding: 8px; - font-size: 20px; - line-height: 1; - cursor: pointer; +.embed-banner .action-btn { + color: #2d6a4f; } /* Prevent text selection on game elements */ diff --git a/webapp/templates/game.html b/webapp/templates/game.html index 84ace38d..4670459d 100644 --- a/webapp/templates/game.html +++ b/webapp/templates/game.html @@ -640,22 +640,21 @@