diff --git a/frontend/src/embed.ts b/frontend/src/embed.ts new file mode 100644 index 00000000..27ea64ae --- /dev/null +++ b/frontend/src/embed.ts @@ -0,0 +1,62 @@ +/** + * 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; + } +}; + +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 (!isEmbedded || isDismissed() || isStandalone()) return; + const banner = getBanner(); + if (banner) { + banner.style.display = 'flex'; + } +}; + +export const dismiss = (): void => { + 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 42164fe3..4d9997fd 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 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 { @@ -783,6 +788,9 @@ export const createGameApp = () => { had_frustration: lossFrustrationState.hadFrustration, time_to_complete_seconds: lossTimeToComplete, }); + + // 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/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 5dbc5a6e..508e5377 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -79,13 +79,12 @@ } } -/* 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; @@ -96,27 +95,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 { + background: linear-gradient(135deg, #2d6a4f 0%, #1b4332 100%); +} + +.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 9bee74a4..f43a09bf 100644 --- a/webapp/templates/game.html +++ b/webapp/templates/game.html @@ -658,15 +658,26 @@