From 924742abbd5e8a06fa168b765bbec29685045171 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Tue, 23 Dec 2025 16:51:25 +0400 Subject: [PATCH 01/40] feat: Enhance Ads page with improved metadata, styles, and video player functionality; remove old favicon --- README.md | 62 ++++--- pages/ads/index.html | 16 +- pages/ads/main.ts | 373 +++++++++++++++++++++++++++++++++++++++++-- pages/ads/style.css | 284 ++++++++++++++++++++++++++++++++ public/favicon.svg | 10 -- vite.config.ts | 9 +- 6 files changed, 703 insertions(+), 51 deletions(-) delete mode 100644 public/favicon.svg diff --git a/README.md b/README.md index 7d1afb7..cedd551 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ npm run dev:tools # Tools page ``` Or open in browser: + - `http://localhost:3000/pages/index/index.html` - `http://localhost:3000/pages/tools/index.html` @@ -98,30 +99,41 @@ pages/my-new-page/ ```html - - - - - - My New Page - Doctorina - - - + + + + + + Doctorina + + + + + + + + + + + + + +
- + ``` 4. **main.ts** - page code: ```typescript -import { initPage } from '~/shared/utils/page-init'; -import './style.css'; +import { initPage } from "~/shared/utils/page-init"; +import "./style.css"; -initPage('My New Page - Doctorina'); +initPage("My New Page - Doctorina"); -const app = document.getElementById('app'); +const app = document.getElementById("app"); if (app) { app.innerHTML = `
@@ -145,24 +157,28 @@ if (app) { Ready-to-use utilities are available in `src/shared/utils/`: ```typescript -import { initPage } from '~/shared/utils/page-init'; -import { copyToClipboard } from '~/shared/utils/clipboard'; -import { formatDate, getRelativeTime } from '~/shared/utils/date'; -import { isValidEmail, isValidUrl } from '~/shared/utils/validation'; +import { initPage } from "~/shared/utils/page-init"; +import { copyToClipboard } from "~/shared/utils/clipboard"; +import { formatDate, getRelativeTime } from "~/shared/utils/date"; +import { isValidEmail, isValidUrl } from "~/shared/utils/validation"; // Initialize page -initPage('Page Title'); +initPage("Page Title"); // Clipboard operations -await copyToClipboard('Text to copy'); +await copyToClipboard("Text to copy"); // Date formatting const formatted = formatDate(new Date()); const relative = getRelativeTime(new Date()); // Validation -if (isValidEmail(email)) { /* ... */ } -if (isValidUrl(url)) { /* ... */ } +if (isValidEmail(email)) { + /* ... */ +} +if (isValidUrl(url)) { + /* ... */ +} ``` ## 🎨 Shared Styles @@ -170,7 +186,7 @@ if (isValidUrl(url)) { /* ... */ } Import shared CSS variables and utilities: ```typescript -import '~/shared/styles/common.css'; +import "~/shared/styles/common.css"; ``` Available CSS variables: diff --git a/pages/ads/index.html b/pages/ads/index.html index e198a3b..460f99b 100644 --- a/pages/ads/index.html +++ b/pages/ads/index.html @@ -3,11 +3,21 @@ + - - + Ads - Doctorina - + + + + + + + + + + + diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 89cfcc0..d81c34b 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -1,18 +1,369 @@ import { initPage } from '~/shared/utils/page-init'; import './style.css'; -initPage('Ads - Doctorina'); +initPage('Watch Ad - Doctorina'); +// Types for YouTube IFrame API +interface YTPlayer { + playVideo: () => void; + pauseVideo: () => void; + seekTo: (seconds: number, allowSeekAhead: boolean) => void; + getCurrentTime: () => number; + getDuration: () => number; + getPlayerState: () => number; + unMute: () => void; + isMuted: () => boolean; +} + +interface YTPlayerClass { + new ( + elementId: string, + config: { + videoId: string; + playerVars?: Record; + events?: { + onReady?: (event: { target: YTPlayer }) => void; + onStateChange?: (event: { target: YTPlayer; data: number }) => void; + }; + } + ): YTPlayer; +} + +interface YTNamespace { + Player: YTPlayerClass; + PlayerState: { + UNSTARTED: number; + ENDED: number; + PLAYING: number; + PAUSED: number; + BUFFERING: number; + CUED: number; + }; +} + +declare global { + interface Window { + onYouTubeIframeAPIReady: () => void; + YT: YTNamespace; + } +} + +// Configuration +const VIDEO_ID = 'Dyth2OnlD-o'; // https://www.youtube.com/watch?v=Dyth2OnlD-o +const CALLBACK_URL = 'http://localhost:3000/callback'; + +// Get session from URL +const urlParams = new URLSearchParams(window.location.search); +const sessionId = urlParams.get('session'); + +// State management +let player: YTPlayer | null = null; +let maxWatchedTime = 0; +let isVideoCompleted = false; +let seekAttempts = 0; +let pauseCount = 0; +let wasTabActive = true; +let videoStartTime = Date.now(); + +// Metadata collection +const metadata = { + userAgent: navigator.userAgent, + platform: (navigator as any).userAgentData?.platform || navigator.platform || 'unknown', + language: navigator.language, + screenResolution: `${window.screen.width}x${window.screen.height}`, + viewportSize: `${window.innerWidth}x${window.innerHeight}`, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + referrer: document.referrer || 'direct', + deviceMemory: (navigator as any).deviceMemory || 'unknown', + hardwareConcurrency: navigator.hardwareConcurrency || 'unknown', +}; + +// Initialize the page const app = document.getElementById('app'); -if (app) { - // Get session_id from URL if present - const urlParams = new URLSearchParams(window.location.search); - const sessionId = urlParams.get('session_id'); - - app.innerHTML = ` -
-

Ads

-

Welcome to the Ads page!

+if (!app) throw new Error('App element not found'); + +app.innerHTML = ` +
+
+
+
+
+
+
+
+
+ Remaining: + --:-- +
+
+
+
+ Please watch the video to the end without skipping +
+
+
+
+
+ + + + +

Thank you for watching!

+

Sending confirmation...

- `; +
+`; + +// Load YouTube IFrame API +const tag = document.createElement('script'); +tag.src = 'https://www.youtube.com/iframe_api'; +const firstScriptTag = document.getElementsByTagName('script')[0]; +firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); + +// Initialize player when API is ready +window.onYouTubeIframeAPIReady = () => { + player = new window.YT.Player('player', { + videoId: VIDEO_ID, + playerVars: { + autoplay: 1, + mute: 1, // Mute for autoplay to work in browsers + controls: 0, // Hide controls + disablekb: 1, // Disable keyboard controls from YouTube + fs: 0, // Disable fullscreen + modestbranding: 1, + rel: 0, + showinfo: 0, + iv_load_policy: 3, + }, + events: { + onReady: onPlayerReady, + onStateChange: onPlayerStateChange, + }, + }); +}; + +function onPlayerReady(event: { target: YTPlayer }) { + // Unmute after autoplay starts (was muted for autoplay policy) + event.target.unMute(); + event.target.playVideo(); + startProgressTracking(); +} + +function onPlayerStateChange(event: { target: YTPlayer; data: number }) { + const { YT } = window; + + if (event.data === YT.PlayerState.PAUSED) { + pauseCount++; + } + + if (event.data === YT.PlayerState.ENDED && !isVideoCompleted) { + isVideoCompleted = true; + onVideoComplete(); + } } + +function startProgressTracking() { + setInterval(() => { + if (!player || isVideoCompleted) return; + + const currentTime = player.getCurrentTime(); + const duration = player.getDuration(); + + // Detect seek attempts (forward seeking) + if (currentTime > maxWatchedTime + 1) { + seekAttempts++; + showWarning('Do not skip ahead! Video will restart.'); + player.seekTo(maxWatchedTime, true); + return; + } + + // Update max watched time + if (currentTime > maxWatchedTime) { + maxWatchedTime = currentTime; + } + + // Update UI + updateProgress(currentTime, duration); + + // Check if video is completed (98% threshold to account for buffering) + if (currentTime / duration > 0.98 && !isVideoCompleted) { + isVideoCompleted = true; + onVideoComplete(); + } + }, 100); +} + +function updateProgress(currentTime: number, duration: number) { + const progressFill = document.getElementById('progressFill'); + const countdown = document.getElementById('countdown'); + + if (!progressFill || !countdown) return; + + const percentage = (currentTime / duration) * 100; + progressFill.style.width = `${percentage}%`; + + const remaining = duration - currentTime; + const minutes = Math.floor(remaining / 60); + const seconds = Math.floor(remaining % 60); + countdown.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function showWarning(message: string) { + const warningMessage = document.getElementById('warningMessage'); + if (!warningMessage) return; + + warningMessage.textContent = message; + warningMessage.classList.add('show'); + + setTimeout(() => { + warningMessage.classList.remove('show'); + }, 3000); +} + +async function onVideoComplete() { + const completionScreen = document.getElementById('completionScreen'); + const overlay = document.getElementById('overlay'); + + if (completionScreen) completionScreen.classList.add('show'); + if (overlay) overlay.style.display = 'none'; + + // Send callback if session exists + if (sessionId) { + await sendCallback(); + } else { + const completionMessage = document.getElementById('completionMessage'); + if (completionMessage) { + completionMessage.textContent = 'No session ID provided. Callback not sent.'; + } + } +} + +async function sendCallback() { + const completionMessage = document.getElementById('completionMessage'); + + try { + const watchDuration = (Date.now() - videoStartTime) / 1000; // in seconds + + const payload = { + session: sessionId, + videoId: VIDEO_ID, + page: 'ads', + videoUrl: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + completedAt: new Date().toISOString(), + + // Basic metadata + userAgent: metadata.userAgent, + platform: metadata.platform, + language: metadata.language, + + // Extended metadata + screenResolution: metadata.screenResolution, + viewportSize: metadata.viewportSize, + timezone: metadata.timezone, + referrer: metadata.referrer, + deviceMemory: metadata.deviceMemory, + hardwareConcurrency: metadata.hardwareConcurrency, + + // Behavioral metadata + watchDuration: Math.round(watchDuration), + seekAttempts, + pauseCount, + wasTabActive, + maxWatchedTime: Math.round(maxWatchedTime), + }; + + const response = await fetch(CALLBACK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + if (completionMessage) { + completionMessage.textContent = 'Confirmation sent successfully!'; + } + } else { + throw new Error(`Server responded with ${response.status}`); + } + } catch (error) { + console.error('Failed to send callback:', error); + if (completionMessage) { + completionMessage.textContent = 'Failed to send confirmation. Please check your connection.'; + } + } +} + +// Manual keyboard controls +document.addEventListener('keydown', (e) => { + if (isVideoCompleted || !player) return; + + const { YT } = window; + const currentState = player.getPlayerState(); + + // Handle pause/play with Space or K + if (e.key === ' ' || e.key === 'k' || e.key === 'K') { + e.preventDefault(); + e.stopPropagation(); + + if (currentState === YT.PlayerState.PLAYING) { + player.pauseVideo(); + } else if (currentState === YT.PlayerState.PAUSED) { + player.playVideo(); + } + return false; + } + + // Block all other video control keys + const blockedKeys = [ + 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', + 'Home', 'End', + 'PageUp', 'PageDown', + 'j', 'l', 'm', 'f', 'c', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + ]; + + if (blockedKeys.includes(e.key) || blockedKeys.includes(e.code)) { + e.preventDefault(); + e.stopPropagation(); + showWarning('Keyboard controls are disabled'); + return false; + } +}); + +// Prevent context menu on video +document.getElementById('player')?.addEventListener('contextmenu', (e) => { + e.preventDefault(); + return false; +}); + +// Track tab visibility and auto pause/resume +document.addEventListener('visibilitychange', () => { + if (!player || isVideoCompleted) return; + + if (document.hidden) { + // Tab lost focus - pause video + wasTabActive = false; + player.pauseVideo(); + } else { + // Tab gained focus - resume video if it was playing + const currentState = player.getPlayerState(); + const { YT } = window; + + // Resume only if paused (not ended or unstarted) + if (currentState === YT.PlayerState.PAUSED) { + player.playVideo(); + } + } +}); + +// Warn before leaving page +window.addEventListener('beforeunload', (e) => { + if (!isVideoCompleted && maxWatchedTime > 0) { + e.preventDefault(); + const message = 'Are you sure you want to leave? The video is not finished yet.'; + e.returnValue = message; + return message; + } +}); diff --git a/pages/ads/style.css b/pages/ads/style.css index e69de29..b75ed34 100644 --- a/pages/ads/style.css +++ b/pages/ads/style.css @@ -0,0 +1,284 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: #000; + overflow: hidden; +} + +#app { + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +/* Video Container */ +.video-container { + position: relative; + width: 100%; + height: 100%; + background: #000; + display: flex; + align-items: center; + justify-content: center; +} + +#player { + width: 100%; + height: 100%; +} + +/* Overlay Controls */ +.overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%); + padding: 30px 40px; + z-index: 10; + pointer-events: none; +} + +.controls-info { + max-width: 800px; + margin: 0 auto; +} + +/* Progress Bar */ +.progress-container { + width: 100%; +} + +.progress-bar { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + overflow: hidden; + margin-bottom: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + width: 0%; + transition: width 0.3s ease; + border-radius: 3px; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); + animation: shimmer 2s infinite; +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +/* Time Info */ +.time-info { + display: flex; + align-items: center; + gap: 8px; + color: #fff; + font-size: 14px; + font-weight: 500; +} + +.time-label { + color: rgba(255, 255, 255, 0.7); +} + +.time-countdown { + font-variant-numeric: tabular-nums; + font-size: 16px; + color: #fff; + background: rgba(255, 255, 255, 0.1); + padding: 4px 12px; + border-radius: 4px; + backdrop-filter: blur(10px); +} + +/* Warning Message */ +.warning-message { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.8); + background: rgba(220, 38, 38, 0.95); + color: #fff; + padding: 20px 32px; + border-radius: 12px; + font-size: 18px; + font-weight: 600; + text-align: center; + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); + box-shadow: 0 10px 40px rgba(220, 38, 38, 0.4); + max-width: 400px; +} + +.warning-message.show { + opacity: 1; + transform: translate(-50%, -50%) scale(1); +} + +/* Completion Screen */ +.completion-screen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + opacity: 0; + pointer-events: none; + transition: opacity 0.5s ease; +} + +.completion-screen.show { + opacity: 1; + pointer-events: all; +} + +.completion-content { + text-align: center; + color: #fff; + animation: slideUp 0.6s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.completion-content h2 { + font-size: 32px; + font-weight: 700; + margin: 24px 0 12px; +} + +.completion-content p { + font-size: 16px; + opacity: 0.9; + margin: 0; +} + +/* Checkmark Animation */ +.checkmark { + width: 80px; + height: 80px; + margin: 0 auto; + display: block; +} + +.checkmark-circle { + stroke: #fff; + stroke-width: 2; + stroke-miterlimit: 10; + stroke-dasharray: 166; + stroke-dashoffset: 166; + animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards; +} + +.checkmark-check { + stroke: #fff; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 48; + stroke-dashoffset: 48; + animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.4s forwards; +} + +@keyframes stroke { + to { + stroke-dashoffset: 0; + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .overlay { + padding: 20px 24px; + } + + .time-info { + font-size: 12px; + } + + .time-countdown { + font-size: 14px; + padding: 3px 10px; + } + + .warning-message { + font-size: 16px; + padding: 16px 24px; + max-width: 300px; + } + + .completion-content h2 { + font-size: 24px; + } + + .completion-content p { + font-size: 14px; + } + + .checkmark { + width: 60px; + height: 60px; + } +} + +/* Prevent text selection */ +body { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +/* Loading state for iframe */ +#player iframe { + border: none; + width: 100%; + height: 100%; +} diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index 06d1ef3..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - D - diff --git a/vite.config.ts b/vite.config.ts index ad6a325..15b205c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -52,16 +52,17 @@ export default defineConfig(({ mode }) => { server.middlewares.use((req: any, res: any, next: any) => { const url = req.url || ''; - // Match patterns like /pagename or /pagename/ or /pagename.html - const cleanMatch = url.match(/^\/([a-zA-Z0-9-]+)\/?$/); - const htmlMatch = url.match(/^\/([a-zA-Z0-9-]+)\.html$/); + // Match patterns like /pagename or /pagename/ or /pagename.html (with optional query params) + const cleanMatch = url.match(/^\/([a-zA-Z0-9-]+)(\/?(\?.*)?)?$/); + const htmlMatch = url.match(/^\/([a-zA-Z0-9-]+)\.html(\?.*)?$/); const match = cleanMatch || htmlMatch; if (match) { const pageName = match[1]; + const queryString = match[2] || match[3] || ''; const fullPath = resolve(__dirname, 'pages', pageName, 'index.html'); if (existsSync(fullPath)) { - req.url = `/pages/${pageName}/index.html`; + req.url = `/pages/${pageName}/index.html${queryString}`; } } From 86efd3d6e4768f8115fa3e5da12059c4bc678c8b Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 24 Dec 2025 16:29:33 +0400 Subject: [PATCH 02/40] fix: Update VIDEO_ID for YouTube player to correct video link --- pages/ads/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index d81c34b..513a0d4 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -49,7 +49,7 @@ declare global { } // Configuration -const VIDEO_ID = 'Dyth2OnlD-o'; // https://www.youtube.com/watch?v=Dyth2OnlD-o +const VIDEO_ID = '8fy94RQnnzw'; // https://www.youtube.com/watch?v=8fy94RQnnzw const CALLBACK_URL = 'http://localhost:3000/callback'; // Get session from URL From 30a33e524a316e16573990598dd3297502f705eb Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 24 Dec 2025 17:22:25 +0400 Subject: [PATCH 03/40] feat: Add internationalization support and confirmation dialog for ad viewing; implement close button functionality --- pages/ads/main.ts | 320 +++++++++++++++++++++++++++++++++++++++++--- pages/ads/style.css | 194 +++++++++++++++++++++++++++ 2 files changed, 496 insertions(+), 18 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 513a0d4..f2fb37c 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -1,7 +1,129 @@ import { initPage } from '~/shared/utils/page-init'; import './style.css'; -initPage('Watch Ad - Doctorina'); +// Internationalization (i18n) - Inline translations +const translations = { + en: { + // Page + pageTitle: 'Watch Ad - Doctorina', + // Progress + remaining: 'Remaining:', + // Warnings + watchToEnd: 'Please watch the video to the end without skipping', + doNotSkip: 'Do not skip ahead! Video will restart.', + keyboardDisabled: 'Keyboard controls are disabled', + // Dialog + confirmLeaveTitle: 'Are you sure you want to leave?', + confirmLeaveText: 'The video is not finished yet. If you leave now, the ad view will not be counted.', + stayAndWatch: 'Stay and watch', + leaveAnyway: 'Leave anyway', + // Completion + thankYou: 'Thank you for watching!', + sendingConfirmation: 'Sending confirmation...', + confirmationSent: 'Confirmation sent successfully!', + confirmationFailed: 'Failed to send confirmation. Please check your connection.', + noSessionId: 'No session ID provided. Callback not sent.', + closeAndReturn: 'Close and return', + canCloseManually: 'You can now close this tab manually.', + // Tooltips & Aria + closeButtonLabel: 'Close', + closeButtonTooltip: 'Close and return to app', + }, + ru: { + // Страница + pageTitle: 'Просмотр рекламы - Doctorina', + // Прогресс + remaining: 'Осталось:', + // Предупреждения + watchToEnd: 'Пожалуйста, посмотрите видео до конца без пропусков', + doNotSkip: 'Не перематывайте! Видео начнется сначала.', + keyboardDisabled: 'Управление с клавиатуры отключено', + // Диалог + confirmLeaveTitle: 'Вы уверены, что хотите уйти?', + confirmLeaveText: 'Видео еще не закончилось. Если вы уйдете сейчас, просмотр рекламы не будет засчитан.', + stayAndWatch: 'Остаться и смотреть', + leaveAnyway: 'Все равно уйти', + // Завершение + thankYou: 'Спасибо за просмотр!', + sendingConfirmation: 'Отправка подтверждения...', + confirmationSent: 'Подтверждение успешно отправлено!', + confirmationFailed: 'Не удалось отправить подтверждение. Проверьте соединение.', + noSessionId: 'ID сессии не указан. Подтверждение не отправлено.', + closeAndReturn: 'Закрыть и вернуться', + canCloseManually: 'Вы можете закрыть эту вкладку вручную.', + // Подсказки и Aria + closeButtonLabel: 'Закрыть', + closeButtonTooltip: 'Закрыть и вернуться в приложение', + }, + es: { + // Página + pageTitle: 'Ver anuncio - Doctorina', + // Progreso + remaining: 'Restante:', + // Advertencias + watchToEnd: 'Por favor, mira el vídeo hasta el final sin saltarlo', + doNotSkip: '¡No adelantes! El vídeo se reiniciará.', + keyboardDisabled: 'Los controles del teclado están deshabilitados', + // Diálogo + confirmLeaveTitle: '¿Estás seguro de que quieres salir?', + confirmLeaveText: 'El vídeo aún no ha terminado. Si sales ahora, la visualización del anuncio no se contará.', + stayAndWatch: 'Quedarse y ver', + leaveAnyway: 'Salir de todos modos', + // Finalización + thankYou: '¡Gracias por ver!', + sendingConfirmation: 'Enviando confirmación...', + confirmationSent: '¡Confirmación enviada con éxito!', + confirmationFailed: 'Error al enviar la confirmación. Comprueba tu conexión.', + noSessionId: 'No se proporcionó ID de sesión. Confirmación no enviada.', + closeAndReturn: 'Cerrar y volver', + canCloseManually: 'Ahora puedes cerrar esta pestaña manualmente.', + // Tooltips y Aria + closeButtonLabel: 'Cerrar', + closeButtonTooltip: 'Cerrar y volver a la aplicación', + }, + de: { + // Seite + pageTitle: 'Werbung ansehen - Doctorina', + // Fortschritt + remaining: 'Verbleibend:', + // Warnungen + watchToEnd: 'Bitte schauen Sie das Video bis zum Ende ohne zu überspringen', + doNotSkip: 'Nicht vorspulen! Video wird neu gestartet.', + keyboardDisabled: 'Tastatursteuerung ist deaktiviert', + // Dialog + confirmLeaveTitle: 'Bist du sicher, dass du gehen möchtest?', + confirmLeaveText: 'Das Video ist noch nicht zu Ende. Wenn Sie jetzt gehen, wird die Anzeige nicht gezählt.', + stayAndWatch: 'Bleiben und ansehen', + leaveAnyway: 'Trotzdem verlassen', + // Abschluss + thankYou: 'Vielen Dank fürs Ansehen!', + sendingConfirmation: 'Bestätigung wird gesendet...', + confirmationSent: 'Bestätigung erfolgreich gesendet!', + confirmationFailed: 'Bestätigung konnte nicht gesendet werden. Überprüfen Sie Ihre Verbindung.', + noSessionId: 'Keine Sitzungs-ID angegeben. Bestätigung nicht gesendet.', + closeAndReturn: 'Schließen und zurückkehren', + canCloseManually: 'Sie können diesen Tab jetzt manuell schließen.', + // Tooltips und Aria + closeButtonLabel: 'Schließen', + closeButtonTooltip: 'Schließen und zur App zurückkehren', + }, +}; + +// Detect browser language with fallback to English +function detectLanguage(): keyof typeof translations { + const browserLang = navigator.language.split('-')[0].toLowerCase(); + const supportedLanguages: (keyof typeof translations)[] = ['en', 'ru', 'es', 'de']; + return supportedLanguages.includes(browserLang as any) ? (browserLang as keyof typeof translations) : 'en'; +} + +// Get current language and translations +const currentLang = detectLanguage(); +const t = translations[currentLang]; + +// Update HTML lang attribute +document.documentElement.lang = currentLang; + +initPage(t.pageTitle); // Types for YouTube IFrame API interface YTPlayer { @@ -52,9 +174,10 @@ declare global { const VIDEO_ID = '8fy94RQnnzw'; // https://www.youtube.com/watch?v=8fy94RQnnzw const CALLBACK_URL = 'http://localhost:3000/callback'; -// Get session from URL +// Get parameters from URL const urlParams = new URLSearchParams(window.location.search); const sessionId = urlParams.get('session'); +const referrer = urlParams.get('referrer'); // 'web' or 'app' // State management let player: YTPlayer | null = null; @@ -84,6 +207,12 @@ if (!app) throw new Error('App element not found'); app.innerHTML = `
+
@@ -92,13 +221,23 @@ app.innerHTML = `
- Remaining: + ${t.remaining} --:--
- Please watch the video to the end without skipping + ${t.watchToEnd} +
+
+
+
+
+

${t.confirmLeaveTitle}

+

${t.confirmLeaveText}

+
+ +
@@ -108,8 +247,9 @@ app.innerHTML = ` -

Thank you for watching!

-

Sending confirmation...

+

${t.thankYou}

+

${t.sendingConfirmation}

+ `; @@ -172,7 +312,7 @@ function startProgressTracking() { // Detect seek attempts (forward seeking) if (currentTime > maxWatchedTime + 1) { seekAttempts++; - showWarning('Do not skip ahead! Video will restart.'); + showWarning(t.doNotSkip); player.seekTo(maxWatchedTime, true); return; } @@ -221,21 +361,34 @@ function showWarning(message: string) { } async function onVideoComplete() { + // Wait 1.5 seconds before showing completion screen for smoother UX + await new Promise(resolve => setTimeout(resolve, 1500)); + const completionScreen = document.getElementById('completionScreen'); const overlay = document.getElementById('overlay'); + const closeFinalButton = document.getElementById('closeFinalButton'); + const closeButton = document.getElementById('closeButton'); if (completionScreen) completionScreen.classList.add('show'); if (overlay) overlay.style.display = 'none'; + // Hide the X button in top-left corner + if (closeButton) closeButton.classList.add('hidden'); + // Send callback if session exists if (sessionId) { await sendCallback(); } else { const completionMessage = document.getElementById('completionMessage'); if (completionMessage) { - completionMessage.textContent = 'No session ID provided. Callback not sent.'; + completionMessage.textContent = t.noSessionId; } } + + // Show close button after completion + if (closeFinalButton) { + closeFinalButton.style.display = 'block'; + } } async function sendCallback() { @@ -282,7 +435,7 @@ async function sendCallback() { if (response.ok) { if (completionMessage) { - completionMessage.textContent = 'Confirmation sent successfully!'; + completionMessage.textContent = t.confirmationSent; } } else { throw new Error(`Server responded with ${response.status}`); @@ -290,7 +443,7 @@ async function sendCallback() { } catch (error) { console.error('Failed to send callback:', error); if (completionMessage) { - completionMessage.textContent = 'Failed to send confirmation. Please check your connection.'; + completionMessage.textContent = t.confirmationFailed; } } } @@ -327,7 +480,7 @@ document.addEventListener('keydown', (e) => { if (blockedKeys.includes(e.key) || blockedKeys.includes(e.code)) { e.preventDefault(); e.stopPropagation(); - showWarning('Keyboard controls are disabled'); + showWarning(t.keyboardDisabled); return false; } }); @@ -358,12 +511,143 @@ document.addEventListener('visibilitychange', () => { } }); -// Warn before leaving page -window.addEventListener('beforeunload', (e) => { - if (!isVideoCompleted && maxWatchedTime > 0) { - e.preventDefault(); - const message = 'Are you sure you want to leave? The video is not finished yet.'; - e.returnValue = message; - return message; +// No beforeunload warning - we handle closing with custom dialog + +// Close button handler +const closeButton = document.getElementById('closeButton'); +closeButton?.addEventListener('click', handleCloseAttempt); + +function handleCloseAttempt() { + if (isVideoCompleted) { + // Video completed - close immediately + closeAndReturn(); + } else { + // Video not completed - show confirmation dialog + const confirmationDialog = document.getElementById('confirmationDialog'); + if (confirmationDialog) { + confirmationDialog.classList.add('show'); + } + } +} + +// Dialog button handlers +const dialogStay = document.getElementById('dialogStay'); +const dialogLeave = document.getElementById('dialogLeave'); + +dialogStay?.addEventListener('click', () => { + const confirmationDialog = document.getElementById('confirmationDialog'); + if (confirmationDialog) { + confirmationDialog.classList.remove('show'); } }); + +dialogLeave?.addEventListener('click', () => { + closeAndReturn(); +}); + +// Close and return logic +async function closeAndReturn() { + try { + if (referrer === 'web') { + // Try to switch to web app tab + await redirectToWebApp(); + } else if (referrer === 'app') { + // Try to open mobile app + await redirectToMobileApp(); + } + } catch (error) { + console.error('Failed to redirect:', error); + } + + // Try to close the tab + tryCloseTab(); +} + +async function redirectToWebApp() { + try { + // Try to focus existing app.doctorina.com tab + // This is limited by browser security, but we can try opening it + const webAppUrl = 'https://app.doctorina.com'; + + // Open in same window to give focus + window.location.href = webAppUrl; + } catch (error) { + console.error('Failed to redirect to web app:', error); + } +} + +async function redirectToMobileApp() { + try { + const userAgent = navigator.userAgent.toLowerCase(); + const isIOS = /iphone|ipad|ipod/.test(userAgent); + const isAndroid = /android/.test(userAgent); + + if (isIOS) { + // iOS Universal Link / Custom URL Scheme + const universalLink = 'doctorina://'; + window.location.href = universalLink; + + // Fallback to App Store if app not installed (after timeout) + setTimeout(() => { + // If still here, app might not be installed + console.log('iOS app might not be installed'); + }, 2000); + } else if (isAndroid) { + // Android App Link / Intent + const intentUrl = 'intent://callback#Intent;' + + 'scheme=doctorina;' + + 'package=com.doctorina.app.android.production;' + + 'S.browser_fallback_url=https://play.google.com/store/apps/details?id=com.doctorina.app.android.production;' + + 'end'; + + window.location.href = intentUrl; + + // Fallback + setTimeout(() => { + console.log('Android app might not be installed'); + }, 2000); + } else { + // Desktop or unknown platform - just try generic link + window.location.href = 'doctorina://'; + } + } catch (error) { + console.error('Failed to redirect to mobile app:', error); + } +} + +function tryCloseTab() { + try { + // Try to close the window (will only work if opened via window.open) + window.close(); + + // Check if window is still open after close attempt + setTimeout(() => { + // If we're still here, window.close() didn't work + // Only show message if video was completed + if (isVideoCompleted) { + const completionScreen = document.getElementById('completionScreen'); + const completionMessage = document.getElementById('completionMessage'); + + if (completionScreen && !completionScreen.classList.contains('show')) { + completionScreen.classList.add('show'); + } + + if (completionMessage) { + completionMessage.textContent = t.canCloseManually; + } + + // Hide the close button since we're showing the message + const closeFinalButton = document.getElementById('closeFinalButton'); + if (closeFinalButton) { + closeFinalButton.style.display = 'none'; + } + } + }, 100); + } catch (error) { + console.error('Failed to close tab:', error); + } +} + +// Final close button handler (after completion) +const closeFinalButton = document.getElementById('closeFinalButton'); +closeFinalButton?.addEventListener('click', closeAndReturn); diff --git a/pages/ads/style.css b/pages/ads/style.css index b75ed34..6501a57 100644 --- a/pages/ads/style.css +++ b/pages/ads/style.css @@ -282,3 +282,197 @@ body { width: 100%; height: 100%; } + +/* Close Button */ +.close-button { + position: fixed; + top: 20px; + left: 20px; + width: 44px; + height: 44px; + background: rgba(0, 0, 0, 0.6); + border: none; + border-radius: 50%; + color: #fff; + cursor: pointer; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.close-button.hidden { + opacity: 0; + pointer-events: none; +} + +.close-button:hover { + background: rgba(0, 0, 0, 0.8); + transform: scale(1.1); +} + +.close-button:active { + transform: scale(0.95); +} + +.close-button svg { + width: 20px; + height: 20px; +} + +/* Confirmation Dialog */ +.confirmation-dialog { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.confirmation-dialog.show { + opacity: 1; + pointer-events: all; +} + +.dialog-content { + background: #fff; + border-radius: 16px; + padding: 32px; + max-width: 400px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: dialogSlideUp 0.3s ease-out; +} + +@keyframes dialogSlideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dialog-content h3 { + margin: 0 0 12px; + font-size: 22px; + font-weight: 700; + color: #1a1a1a; +} + +.dialog-content p { + margin: 0 0 24px; + font-size: 15px; + line-height: 1.6; + color: #666; +} + +.dialog-buttons { + display: flex; + gap: 12px; + flex-direction: column; +} + +.dialog-button { + padding: 14px 24px; + border: none; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.dialog-button:active { + transform: scale(0.98); +} + +.dialog-button-secondary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; +} + +.dialog-button-secondary:hover { + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + transform: translateY(-2px); +} + +.dialog-button-danger { + background: #fff; + color: #dc2626; + border: 2px solid #dc2626; +} + +.dialog-button-danger:hover { + background: #dc2626; + color: #fff; +} + +/* Close Final Button */ +.close-final-button { + margin: 24px auto 0; + padding: 14px 32px; + background: #fff; + color: #667eea; + border: none; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + display: block; +} + +.close-final-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); +} + +.close-final-button:active { + transform: translateY(0); +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .close-button { + top: 16px; + left: 16px; + width: 40px; + height: 40px; + } + + .close-button svg { + width: 18px; + height: 18px; + } + + .dialog-content { + padding: 24px; + } + + .dialog-content h3 { + font-size: 20px; + } + + .dialog-content p { + font-size: 14px; + } + + .dialog-button { + padding: 12px 20px; + font-size: 15px; + } +} From d060837a94957f3db0874d286a199750e71046c5 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 24 Dec 2025 17:45:08 +0400 Subject: [PATCH 04/40] feat: Add loading screen and error handling for video player; enhance accessibility and progress tracking --- pages/ads/main.ts | 305 +++++++++++++++++++++++++++++++++++--------- pages/ads/style.css | 57 +++++++++ 2 files changed, 299 insertions(+), 63 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index f2fb37c..efa3ef5 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -4,6 +4,8 @@ import './style.css'; // Internationalization (i18n) - Inline translations const translations = { en: { + // Language: English + lang: 'en', // Page pageTitle: 'Watch Ad - Doctorina', // Progress @@ -25,11 +27,18 @@ const translations = { noSessionId: 'No session ID provided. Callback not sent.', closeAndReturn: 'Close and return', canCloseManually: 'You can now close this tab manually.', + // Loading + loadingVideo: 'Loading video...', + videoLoadError: 'Failed to load video. Please refresh the page.', // Tooltips & Aria closeButtonLabel: 'Close', closeButtonTooltip: 'Close and return to app', + videoPlayerLabel: 'Advertisement video player', + progressBarLabel: 'Video progress', }, ru: { + // Язык: Русский + lang: 'ru', // Страница pageTitle: 'Просмотр рекламы - Doctorina', // Прогресс @@ -51,11 +60,18 @@ const translations = { noSessionId: 'ID сессии не указан. Подтверждение не отправлено.', closeAndReturn: 'Закрыть и вернуться', canCloseManually: 'Вы можете закрыть эту вкладку вручную.', + // Загрузка + loadingVideo: 'Загрузка видео...', + videoLoadError: 'Не удалось загрузить видео. Пожалуйста, обновите страницу.', // Подсказки и Aria closeButtonLabel: 'Закрыть', closeButtonTooltip: 'Закрыть и вернуться в приложение', + videoPlayerLabel: 'Видеоплеер рекламы', + progressBarLabel: 'Прогресс видео', }, es: { + // Idioma: Español + lang: 'es', // Página pageTitle: 'Ver anuncio - Doctorina', // Progreso @@ -77,11 +93,18 @@ const translations = { noSessionId: 'No se proporcionó ID de sesión. Confirmación no enviada.', closeAndReturn: 'Cerrar y volver', canCloseManually: 'Ahora puedes cerrar esta pestaña manualmente.', + // Carga + loadingVideo: 'Cargando vídeo...', + videoLoadError: 'Error al cargar el vídeo. Por favor, actualiza la página.', // Tooltips y Aria closeButtonLabel: 'Cerrar', closeButtonTooltip: 'Cerrar y volver a la aplicación', + videoPlayerLabel: 'Reproductor de vídeo publicitario', + progressBarLabel: 'Progreso del vídeo', }, de: { + // Sprache: Deutsch + lang: 'de', // Seite pageTitle: 'Werbung ansehen - Doctorina', // Fortschritt @@ -103,9 +126,14 @@ const translations = { noSessionId: 'Keine Sitzungs-ID angegeben. Bestätigung nicht gesendet.', closeAndReturn: 'Schließen und zurückkehren', canCloseManually: 'Sie können diesen Tab jetzt manuell schließen.', + // Laden + loadingVideo: 'Video wird geladen...', + videoLoadError: 'Video konnte nicht geladen werden. Bitte aktualisieren Sie die Seite.', // Tooltips und Aria closeButtonLabel: 'Schließen', closeButtonTooltip: 'Schließen und zur App zurückkehren', + videoPlayerLabel: 'Werbevideoplayer', + progressBarLabel: 'Videofortschritt', }, }; @@ -135,6 +163,8 @@ interface YTPlayer { getPlayerState: () => number; unMute: () => void; isMuted: () => boolean; + setVolume: (volume: number) => void; + getVolume: () => number; } interface YTPlayerClass { @@ -146,6 +176,7 @@ interface YTPlayerClass { events?: { onReady?: (event: { target: YTPlayer }) => void; onStateChange?: (event: { target: YTPlayer; data: number }) => void; + onError?: (event: { target: YTPlayer; data: number }) => void; }; } ): YTPlayer; @@ -170,12 +201,14 @@ declare global { } } -// Configuration -const VIDEO_ID = '8fy94RQnnzw'; // https://www.youtube.com/watch?v=8fy94RQnnzw -const CALLBACK_URL = 'http://localhost:3000/callback'; +// Configuration from ENV and URL params +const urlParams = new URLSearchParams(window.location.search); +const VIDEO_ID = urlParams.get('video') || import.meta.env.VITE_DEFAULT_VIDEO_ID || '8fy94RQnnzw'; +const CALLBACK_URL = import.meta.env.VITE_CALLBACK_URL || 'http://localhost:3000/callback'; +const MAX_CALLBACK_RETRIES = 3; +const RETRY_DELAY_MS = 2000; // Get parameters from URL -const urlParams = new URLSearchParams(window.location.search); const sessionId = urlParams.get('session'); const referrer = urlParams.get('referrer'); // 'web' or 'app' @@ -187,6 +220,12 @@ let seekAttempts = 0; let pauseCount = 0; let wasTabActive = true; let videoStartTime = Date.now(); +let fullscreenCount = 0; +let callbackSent = false; + +// Analytics milestones +const milestones = [0.25, 0.5, 0.75]; +const reachedMilestones = new Set(); // Metadata collection const metadata = { @@ -206,9 +245,13 @@ const app = document.getElementById('app'); if (!app) throw new Error('App element not found'); app.innerHTML = ` -
+
+
+

${t.loadingVideo}

+
+
@@ -241,9 +285,9 @@ app.innerHTML = `
-
+
- + @@ -278,11 +322,21 @@ window.onYouTubeIframeAPIReady = () => { events: { onReady: onPlayerReady, onStateChange: onPlayerStateChange, + onError: onPlayerError, }, }); }; function onPlayerReady(event: { target: YTPlayer }) { + // Hide loading screen + const loadingScreen = document.getElementById('loadingScreen'); + if (loadingScreen) { + loadingScreen.style.opacity = '0'; + setTimeout(() => { + loadingScreen.style.display = 'none'; + }, 300); + } + // Unmute after autoplay starts (was muted for autoplay policy) event.target.unMute(); event.target.playVideo(); @@ -302,6 +356,17 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { } } +function onPlayerError(event: { target: YTPlayer; data: number }) { + console.error('YouTube Player Error:', event.data); + const loadingScreen = document.getElementById('loadingScreen'); + if (loadingScreen) { + loadingScreen.innerHTML = ` +
⚠️
+

${t.videoLoadError}

+ `; + } +} + function startProgressTracking() { setInterval(() => { if (!player || isVideoCompleted) return; @@ -336,16 +401,37 @@ function startProgressTracking() { function updateProgress(currentTime: number, duration: number) { const progressFill = document.getElementById('progressFill'); const countdown = document.getElementById('countdown'); + const progressPercentage = document.getElementById('progressPercentage'); + const progressBar = document.querySelector('.progress-bar'); if (!progressFill || !countdown) return; const percentage = (currentTime / duration) * 100; progressFill.style.width = `${percentage}%`; + // Update ARIA attributes + if (progressBar) { + progressBar.setAttribute('aria-valuenow', Math.round(percentage).toString()); + } + const remaining = duration - currentTime; const minutes = Math.floor(remaining / 60); const seconds = Math.floor(remaining % 60); countdown.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + // Update percentage display + if (progressPercentage) { + progressPercentage.textContent = `${Math.round(percentage)}%`; + } + + // Track analytics milestones + const progress = currentTime / duration; + milestones.forEach((milestone) => { + if (progress >= milestone && !reachedMilestones.has(milestone)) { + reachedMilestones.add(milestone); + console.log(`Milestone reached: ${milestone * 100}%`); + } + }); } function showWarning(message: string) { @@ -392,58 +478,83 @@ async function onVideoComplete() { } async function sendCallback() { + // Rate limiting: prevent duplicate sends + if (callbackSent) { + console.warn('Callback already sent, skipping duplicate request'); + return; + } + const completionMessage = document.getElementById('completionMessage'); - try { - const watchDuration = (Date.now() - videoStartTime) / 1000; // in seconds - - const payload = { - session: sessionId, - videoId: VIDEO_ID, - page: 'ads', - videoUrl: `https://www.youtube.com/watch?v=${VIDEO_ID}`, - completedAt: new Date().toISOString(), - - // Basic metadata - userAgent: metadata.userAgent, - platform: metadata.platform, - language: metadata.language, - - // Extended metadata - screenResolution: metadata.screenResolution, - viewportSize: metadata.viewportSize, - timezone: metadata.timezone, - referrer: metadata.referrer, - deviceMemory: metadata.deviceMemory, - hardwareConcurrency: metadata.hardwareConcurrency, - - // Behavioral metadata - watchDuration: Math.round(watchDuration), - seekAttempts, - pauseCount, - wasTabActive, - maxWatchedTime: Math.round(maxWatchedTime), - }; - - const response = await fetch(CALLBACK_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); + // Retry mechanism with exponential backoff + for (let attempt = 1; attempt <= MAX_CALLBACK_RETRIES; attempt++) { + try { + const watchDuration = (Date.now() - videoStartTime) / 1000; // in seconds + + const payload = { + session: sessionId, + videoId: VIDEO_ID, + page: 'ads', + videoUrl: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + completedAt: new Date().toISOString(), + + // Basic metadata + userAgent: metadata.userAgent, + platform: metadata.platform, + language: metadata.language, + + // Extended metadata + screenResolution: metadata.screenResolution, + viewportSize: metadata.viewportSize, + timezone: metadata.timezone, + referrer: metadata.referrer, + deviceMemory: metadata.deviceMemory, + hardwareConcurrency: metadata.hardwareConcurrency, + + // Behavioral metadata + watchDuration: Math.round(watchDuration), + seekAttempts, + pauseCount, + wasTabActive, + maxWatchedTime: Math.round(maxWatchedTime), + fullscreenCount, + + // Analytics milestones + milestonesReached: Array.from(reachedMilestones), + }; + + const response = await fetch(CALLBACK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); - if (response.ok) { - if (completionMessage) { - completionMessage.textContent = t.confirmationSent; + if (response.ok) { + callbackSent = true; + if (completionMessage) { + completionMessage.textContent = t.confirmationSent; + } + console.log('Callback sent successfully'); + return; // Success, exit retry loop + } else { + throw new Error(`Server responded with ${response.status}`); + } + } catch (error) { + console.error(`Callback attempt ${attempt}/${MAX_CALLBACK_RETRIES} failed:`, error); + + if (attempt === MAX_CALLBACK_RETRIES) { + // Final attempt failed + if (completionMessage) { + completionMessage.textContent = t.confirmationFailed; + } + } else { + // Wait before retrying (exponential backoff) + const delay = RETRY_DELAY_MS * attempt; + console.log(`Retrying in ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); } - } else { - throw new Error(`Server responded with ${response.status}`); - } - } catch (error) { - console.error('Failed to send callback:', error); - if (completionMessage) { - completionMessage.textContent = t.confirmationFailed; } } } @@ -468,9 +579,30 @@ document.addEventListener('keydown', (e) => { return false; } + // Volume control with arrow up/down + if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + const currentVolume = player.getVolume(); + const newVolume = Math.min(100, currentVolume + 10); + player.setVolume(newVolume); + console.log(`Volume: ${newVolume}%`); + return false; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + const currentVolume = player.getVolume(); + const newVolume = Math.max(0, currentVolume - 10); + player.setVolume(newVolume); + console.log(`Volume: ${newVolume}%`); + return false; + } + // Block all other video control keys const blockedKeys = [ - 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', + 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'j', 'l', 'm', 'f', 'c', @@ -491,6 +623,24 @@ document.getElementById('player')?.addEventListener('contextmenu', (e) => { return false; }); +// Block Picture-in-Picture +document.addEventListener('enterpictureinpicture', (e) => { + e.preventDefault(); + if (document.pictureInPictureElement) { + document.exitPictureInPicture().catch((err) => { + console.error('Failed to exit PiP:', err); + }); + } +}); + +// Track fullscreen changes +document.addEventListener('fullscreenchange', () => { + if (document.fullscreenElement) { + fullscreenCount++; + console.log(`Fullscreen entered (count: ${fullscreenCount})`); + } +}); + // Track tab visibility and auto pause/resume document.addEventListener('visibilitychange', () => { if (!player || isVideoCompleted) return; @@ -526,6 +676,11 @@ function handleCloseAttempt() { const confirmationDialog = document.getElementById('confirmationDialog'); if (confirmationDialog) { confirmationDialog.classList.add('show'); + // Focus first button for accessibility + const dialogStay = document.getElementById('dialogStay'); + if (dialogStay) { + setTimeout(() => dialogStay.focus(), 100); + } } } } @@ -545,6 +700,30 @@ dialogLeave?.addEventListener('click', () => { closeAndReturn(); }); +// Keyboard navigation for dialog +document.addEventListener('keydown', (e) => { + const confirmationDialog = document.getElementById('confirmationDialog'); + if (confirmationDialog && confirmationDialog.classList.contains('show')) { + if (e.key === 'Escape') { + confirmationDialog.classList.remove('show'); + } + // Tab trap within dialog + if (e.key === 'Tab') { + const focusableElements = confirmationDialog.querySelectorAll('button'); + const firstElement = focusableElements[0] as HTMLElement; + const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; + + if (e.shiftKey && document.activeElement === firstElement) { + e.preventDefault(); + lastElement.focus(); + } else if (!e.shiftKey && document.activeElement === lastElement) { + e.preventDefault(); + firstElement.focus(); + } + } + } +}); + // Close and return logic async function closeAndReturn() { try { diff --git a/pages/ads/style.css b/pages/ads/style.css index 6501a57..7bda083 100644 --- a/pages/ads/style.css +++ b/pages/ads/style.css @@ -276,6 +276,63 @@ body { -ms-user-select: none; } +/* Loading Screen */ +.loading-screen { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10000; + transition: opacity 0.3s ease; +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 4px solid rgba(255, 255, 255, 0.2); + border-top-color: #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 20px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-screen p { + color: #fff; + font-size: 16px; + margin: 0; +} + +.loading-screen .error-icon { + font-size: 48px; + margin-bottom: 16px; +} + +/* Progress Percentage */ +.progress-percentage { + font-variant-numeric: tabular-nums; + font-size: 14px; + color: rgba(255, 255, 255, 0.9); + margin-left: 8px; + background: rgba(255, 255, 255, 0.1); + padding: 2px 8px; + border-radius: 4px; + backdrop-filter: blur(10px); + min-width: 42px; + text-align: center; +} + /* Loading state for iframe */ #player iframe { border: none; From d176526ffe9508c2f94fcf697faeed14f0056f8f Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 25 Dec 2025 20:07:30 +0400 Subject: [PATCH 05/40] feat: Enhance URL parameter handling for video and callback; add validation for callback URL --- pages/ads/main.ts | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index efa3ef5..accbdb0 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -203,14 +203,44 @@ declare global { // Configuration from ENV and URL params const urlParams = new URLSearchParams(window.location.search); -const VIDEO_ID = urlParams.get('video') || import.meta.env.VITE_DEFAULT_VIDEO_ID || '8fy94RQnnzw'; -const CALLBACK_URL = import.meta.env.VITE_CALLBACK_URL || 'http://localhost:3000/callback'; +const VIDEO_ID = urlParams.get('v') || urlParams.get('video') || import.meta.env.VITE_DEFAULT_VIDEO_ID || '8fy94RQnnzw'; + +// Normalize and validate callback URL +function normalizeCallbackUrl(url: string | null): string { + if (!url) { + return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; + } + + // Decode URL if encoded + try { + url = decodeURIComponent(url); + } catch (e) { + console.error('Failed to decode callback URL:', e); + } + + // Validate URL starts with http:// or https:// + if (!url.startsWith('http://') && !url.startsWith('https://')) { + console.error('Invalid callback URL (must start with http:// or https://):', url); + return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; + } + + // Additional validation: check if it's a valid URL + try { + new URL(url); + return url; + } catch (e) { + console.error('Invalid callback URL format:', e); + return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; + } +} + +const CALLBACK_URL = normalizeCallbackUrl(urlParams.get('c') || urlParams.get('callback')); const MAX_CALLBACK_RETRIES = 3; const RETRY_DELAY_MS = 2000; // Get parameters from URL -const sessionId = urlParams.get('session'); -const referrer = urlParams.get('referrer'); // 'web' or 'app' +const sessionId = urlParams.get('s') || urlParams.get('session'); +const referrer = urlParams.get('r') || urlParams.get('referrer'); // 'web' or 'app' // State management let player: YTPlayer | null = null; From bb1d15914695c194d75d7657d6a728758c39ec65 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 25 Dec 2025 20:25:32 +0400 Subject: [PATCH 06/40] feat: Implement advanced analytics tracking for video engagement and quality metrics --- pages/ads/main.ts | 89 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index accbdb0..6e00d11 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -253,6 +253,17 @@ let videoStartTime = Date.now(); let fullscreenCount = 0; let callbackSent = false; +// Advanced analytics tracking +let totalPauseDuration = 0; +let lastPauseTime = 0; +let volumeChanges = 0; +let lastVolume = 100; +let tabSwitchCount = 0; +let playerErrorCount = 0; +let bufferingEvents = 0; +let lastBufferingTime = 0; +let totalBufferingDuration = 0; + // Analytics milestones const milestones = [0.25, 0.5, 0.75]; const reachedMilestones = new Set(); @@ -378,6 +389,25 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { if (event.data === YT.PlayerState.PAUSED) { pauseCount++; + lastPauseTime = Date.now(); + } + + if (event.data === YT.PlayerState.PLAYING) { + // Calculate pause duration if coming from pause + if (lastPauseTime > 0) { + totalPauseDuration += (Date.now() - lastPauseTime) / 1000; + lastPauseTime = 0; + } + // End buffering tracking if was buffering + if (lastBufferingTime > 0) { + totalBufferingDuration += (Date.now() - lastBufferingTime) / 1000; + lastBufferingTime = 0; + } + } + + if (event.data === YT.PlayerState.BUFFERING) { + bufferingEvents++; + lastBufferingTime = Date.now(); } if (event.data === YT.PlayerState.ENDED && !isVideoCompleted) { @@ -387,6 +417,7 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { } function onPlayerError(event: { target: YTPlayer; data: number }) { + playerErrorCount++; console.error('YouTube Player Error:', event.data); const loadingScreen = document.getElementById('loadingScreen'); if (loadingScreen) { @@ -523,34 +554,53 @@ async function sendCallback() { const payload = { session: sessionId, - videoId: VIDEO_ID, + video_id: VIDEO_ID, page: 'ads', - videoUrl: `https://www.youtube.com/watch?v=${VIDEO_ID}`, - completedAt: new Date().toISOString(), + video_url: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + completed_at: new Date().toISOString(), // Basic metadata - userAgent: metadata.userAgent, + user_agent: metadata.userAgent, platform: metadata.platform, language: metadata.language, // Extended metadata - screenResolution: metadata.screenResolution, - viewportSize: metadata.viewportSize, + screen_resolution: metadata.screenResolution, + viewport_size: metadata.viewportSize, timezone: metadata.timezone, referrer: metadata.referrer, - deviceMemory: metadata.deviceMemory, - hardwareConcurrency: metadata.hardwareConcurrency, + device_memory: metadata.deviceMemory, + hardware_concurrency: metadata.hardwareConcurrency, // Behavioral metadata - watchDuration: Math.round(watchDuration), - seekAttempts, - pauseCount, - wasTabActive, - maxWatchedTime: Math.round(maxWatchedTime), - fullscreenCount, + watch_duration: Math.round(watchDuration), + seek_attempts: seekAttempts, + pause_count: pauseCount, + was_tab_active: wasTabActive, + max_watched_time: Math.round(maxWatchedTime), + fullscreen_count: fullscreenCount, + + // Advanced engagement metrics + total_pause_duration: Math.round(totalPauseDuration), + average_pause_duration: pauseCount > 0 ? Math.round(totalPauseDuration / pauseCount) : 0, + volume_changes: volumeChanges, + tab_switch_count: tabSwitchCount, + player_error_count: playerErrorCount, + buffering_events: bufferingEvents, + total_buffering_duration: Math.round(totalBufferingDuration), + + // Quality metrics + engagement_rate: Math.round((maxWatchedTime / (player?.getDuration() || 1)) * 100), + completion_quality: seekAttempts === 0 && pauseCount <= 2 ? 'high' : pauseCount <= 5 ? 'medium' : 'low', + viewer_behavior: tabSwitchCount === 0 ? 'focused' : tabSwitchCount <= 2 ? 'normal' : 'distracted', + + // Network quality indicators + connection_quality: bufferingEvents === 0 ? 'excellent' : bufferingEvents <= 2 ? 'good' : bufferingEvents <= 5 ? 'fair' : 'poor', + buffering_ratio: Math.round((totalBufferingDuration / watchDuration) * 100), // Analytics milestones - milestonesReached: Array.from(reachedMilestones), + milestones_reached: Array.from(reachedMilestones), + milestones_completion_rate: (reachedMilestones.size / milestones.length) * 100, }; const response = await fetch(CALLBACK_URL, { @@ -616,6 +666,10 @@ document.addEventListener('keydown', (e) => { const currentVolume = player.getVolume(); const newVolume = Math.min(100, currentVolume + 10); player.setVolume(newVolume); + if (Math.abs(newVolume - lastVolume) > 5) { + volumeChanges++; + lastVolume = newVolume; + } console.log(`Volume: ${newVolume}%`); return false; } @@ -626,6 +680,10 @@ document.addEventListener('keydown', (e) => { const currentVolume = player.getVolume(); const newVolume = Math.max(0, currentVolume - 10); player.setVolume(newVolume); + if (Math.abs(newVolume - lastVolume) > 5) { + volumeChanges++; + lastVolume = newVolume; + } console.log(`Volume: ${newVolume}%`); return false; } @@ -677,6 +735,7 @@ document.addEventListener('visibilitychange', () => { if (document.hidden) { // Tab lost focus - pause video + tabSwitchCount++; wasTabActive = false; player.pauseVideo(); } else { From 8019882be213f04d15e316dcf763160789a340f2 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 25 Dec 2025 20:27:42 +0400 Subject: [PATCH 07/40] feat: Implement retry mechanism for video autoplay to ensure playback starts successfully --- pages/ads/main.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 6e00d11..d10bfbd 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -378,10 +378,33 @@ function onPlayerReady(event: { target: YTPlayer }) { }, 300); } - // Unmute after autoplay starts (was muted for autoplay policy) + // Unmute and start playing event.target.unMute(); event.target.playVideo(); startProgressTracking(); + + // Ensure video actually starts playing with retry mechanism + const ensurePlayback = (retryCount = 0, maxRetries = 3) => { + setTimeout(() => { + if (!player || isVideoCompleted) return; + + const { YT } = window; + const currentState = player.getPlayerState(); + + // If video is not playing, try to start it + if (currentState !== YT.PlayerState.PLAYING && currentState !== YT.PlayerState.BUFFERING) { + console.log(`Retry autoplay attempt ${retryCount + 1}/${maxRetries}`); + player.playVideo(); + + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries - 1) { + ensurePlayback(retryCount + 1, maxRetries); + } + } + }, 500 + retryCount * 500); // Increasing delay: 500ms, 1000ms, 1500ms + }; + + ensurePlayback(); } function onPlayerStateChange(event: { target: YTPlayer; data: number }) { From d083448e6372239df004390e6c965d69833663e6 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 31 Dec 2025 17:47:43 +0400 Subject: [PATCH 08/40] feat: Implement postMessage communication for video events and interactions --- pages/ads/main.ts | 120 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index d10bfbd..3b7f68b 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -268,6 +268,30 @@ let totalBufferingDuration = 0; const milestones = [0.25, 0.5, 0.75]; const reachedMilestones = new Set(); +// PostMessage helper for iframe/WebView communication +function sendPostMessage(event: string, data: Record = {}) { + const message = { + event, + timestamp: new Date().toISOString(), + ...data, + }; + + // Send to parent window (for iframe) + if (window.parent && window.parent !== window) { + window.parent.postMessage(message, '*'); + } + + // Send to opener (for popup/new tab) + if (window.opener) { + window.opener.postMessage(message, '*'); + } + + // For WebView - also post to current window + window.postMessage(message, window.location.origin); + + console.log('PostMessage:', event, data); +} + // Metadata collection const metadata = { userAgent: navigator.userAgent, @@ -281,6 +305,14 @@ const metadata = { hardwareConcurrency: navigator.hardwareConcurrency || 'unknown', }; +// Send page loaded event +sendPostMessage('page-loaded', { + videoId: VIDEO_ID, + sessionId, + referrer, + language: currentLang, +}); + // Initialize the page const app = document.getElementById('app'); if (!app) throw new Error('App element not found'); @@ -378,6 +410,12 @@ function onPlayerReady(event: { target: YTPlayer }) { }, 300); } + // Send player ready event + sendPostMessage('player-ready', { + videoId: VIDEO_ID, + duration: event.target.getDuration(), + }); + // Unmute and start playing event.target.unMute(); event.target.playVideo(); @@ -413,6 +451,10 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { if (event.data === YT.PlayerState.PAUSED) { pauseCount++; lastPauseTime = Date.now(); + sendPostMessage('video-paused', { + currentTime: event.target.getCurrentTime(), + pauseCount, + }); } if (event.data === YT.PlayerState.PLAYING) { @@ -426,15 +468,26 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { totalBufferingDuration += (Date.now() - lastBufferingTime) / 1000; lastBufferingTime = 0; } + sendPostMessage('video-playing', { + currentTime: event.target.getCurrentTime(), + }); } if (event.data === YT.PlayerState.BUFFERING) { bufferingEvents++; lastBufferingTime = Date.now(); + sendPostMessage('video-buffering', { + currentTime: event.target.getCurrentTime(), + bufferingEvents, + }); } if (event.data === YT.PlayerState.ENDED && !isVideoCompleted) { isVideoCompleted = true; + sendPostMessage('video-ended', { + duration: event.target.getDuration(), + watchDuration: (Date.now() - videoStartTime) / 1000, + }); onVideoComplete(); } } @@ -442,6 +495,12 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { function onPlayerError(event: { target: YTPlayer; data: number }) { playerErrorCount++; console.error('YouTube Player Error:', event.data); + + sendPostMessage('video-error', { + errorCode: event.data, + playerErrorCount, + }); + const loadingScreen = document.getElementById('loadingScreen'); if (loadingScreen) { loadingScreen.innerHTML = ` @@ -461,6 +520,11 @@ function startProgressTracking() { // Detect seek attempts (forward seeking) if (currentTime > maxWatchedTime + 1) { seekAttempts++; + sendPostMessage('seek-attempt-blocked', { + attemptedTime: currentTime, + maxWatchedTime, + seekAttempts, + }); showWarning(t.doNotSkip); player.seekTo(maxWatchedTime, true); return; @@ -514,6 +578,11 @@ function updateProgress(currentTime: number, duration: number) { if (progress >= milestone && !reachedMilestones.has(milestone)) { reachedMilestones.add(milestone); console.log(`Milestone reached: ${milestone * 100}%`); + sendPostMessage('milestone-reached', { + milestone: milestone * 100, + currentTime, + duration, + }); } }); } @@ -531,6 +600,15 @@ function showWarning(message: string) { } async function onVideoComplete() { + sendPostMessage('video-complete', { + sessionId, + videoId: VIDEO_ID, + watchDuration: (Date.now() - videoStartTime) / 1000, + maxWatchedTime, + seekAttempts, + pauseCount, + }); + // Wait 1.5 seconds before showing completion screen for smoother UX await new Promise(resolve => setTimeout(resolve, 1500)); @@ -640,6 +718,10 @@ async function sendCallback() { completionMessage.textContent = t.confirmationSent; } console.log('Callback sent successfully'); + sendPostMessage('callback-success', { + sessionId, + attempt, + }); return; // Success, exit retry loop } else { throw new Error(`Server responded with ${response.status}`); @@ -652,10 +734,20 @@ async function sendCallback() { if (completionMessage) { completionMessage.textContent = t.confirmationFailed; } + sendPostMessage('callback-failed', { + sessionId, + error: error instanceof Error ? error.message : 'Unknown error', + attempts: MAX_CALLBACK_RETRIES, + }); } else { // Wait before retrying (exponential backoff) const delay = RETRY_DELAY_MS * attempt; console.log(`Retrying in ${delay}ms...`); + sendPostMessage('callback-retry', { + sessionId, + attempt, + nextDelay: delay, + }); await new Promise(resolve => setTimeout(resolve, delay)); } } @@ -761,11 +853,19 @@ document.addEventListener('visibilitychange', () => { tabSwitchCount++; wasTabActive = false; player.pauseVideo(); + sendPostMessage('tab-hidden', { + tabSwitchCount, + currentTime: player.getCurrentTime(), + }); } else { // Tab gained focus - resume video if it was playing const currentState = player.getPlayerState(); const { YT } = window; + sendPostMessage('tab-visible', { + currentTime: player.getCurrentTime(), + }); + // Resume only if paused (not ended or unstarted) if (currentState === YT.PlayerState.PAUSED) { player.playVideo(); @@ -782,9 +882,17 @@ closeButton?.addEventListener('click', handleCloseAttempt); function handleCloseAttempt() { if (isVideoCompleted) { // Video completed - close immediately + sendPostMessage('close-attempt', { + isCompleted: true, + }); closeAndReturn(); } else { // Video not completed - show confirmation dialog + sendPostMessage('close-attempt', { + isCompleted: false, + currentTime: player?.getCurrentTime(), + duration: player?.getDuration(), + }); const confirmationDialog = document.getElementById('confirmationDialog'); if (confirmationDialog) { confirmationDialog.classList.add('show'); @@ -802,6 +910,9 @@ const dialogStay = document.getElementById('dialogStay'); const dialogLeave = document.getElementById('dialogLeave'); dialogStay?.addEventListener('click', () => { + sendPostMessage('dialog-stay', { + currentTime: player?.getCurrentTime(), + }); const confirmationDialog = document.getElementById('confirmationDialog'); if (confirmationDialog) { confirmationDialog.classList.remove('show'); @@ -809,6 +920,10 @@ dialogStay?.addEventListener('click', () => { }); dialogLeave?.addEventListener('click', () => { + sendPostMessage('dialog-leave', { + currentTime: player?.getCurrentTime(), + duration: player?.getDuration(), + }); closeAndReturn(); }); @@ -838,6 +953,11 @@ document.addEventListener('keydown', (e) => { // Close and return logic async function closeAndReturn() { + sendPostMessage('closing', { + referrer, + isCompleted: isVideoCompleted, + }); + try { if (referrer === 'web') { // Try to switch to web app tab From aeea2a7c9ce86fff2783cacd48579c665244d32b Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 31 Dec 2025 17:56:53 +0400 Subject: [PATCH 09/40] feat: Update time info layout and styles for improved readability and accessibility --- pages/ads/main.ts | 1 + pages/ads/style.css | 35 +++++++++++++++++++++-------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 3b7f68b..35db242 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -339,6 +339,7 @@ app.innerHTML = `
${t.remaining} --:-- + · 0%
diff --git a/pages/ads/style.css b/pages/ads/style.css index 7bda083..4de55f4 100644 --- a/pages/ads/style.css +++ b/pages/ads/style.css @@ -103,7 +103,7 @@ body { .time-info { display: flex; align-items: center; - gap: 8px; + gap: 10px; color: #fff; font-size: 14px; font-weight: 500; @@ -117,10 +117,13 @@ body { font-variant-numeric: tabular-nums; font-size: 16px; color: #fff; - background: rgba(255, 255, 255, 0.1); - padding: 4px 12px; - border-radius: 4px; - backdrop-filter: blur(10px); + font-weight: 600; +} + +.time-separator { + color: rgba(255, 255, 255, 0.4); + font-size: 16px; + font-weight: 300; } /* Warning Message */ @@ -241,11 +244,19 @@ body { .time-info { font-size: 12px; + gap: 8px; } .time-countdown { font-size: 14px; - padding: 3px 10px; + } + + .time-separator { + font-size: 14px; + } + + .progress-percentage { + font-size: 12px; } .warning-message { @@ -323,14 +334,10 @@ body { .progress-percentage { font-variant-numeric: tabular-nums; font-size: 14px; - color: rgba(255, 255, 255, 0.9); - margin-left: 8px; - background: rgba(255, 255, 255, 0.1); - padding: 2px 8px; - border-radius: 4px; - backdrop-filter: blur(10px); - min-width: 42px; - text-align: center; + color: rgba(255, 255, 255, 0.8); + font-weight: 500; + min-width: 36px; + text-align: left; } /* Loading state for iframe */ From 6b57a028f5317ea9ad4f1ee4e0f2d4a315503dff Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 31 Dec 2025 18:01:42 +0400 Subject: [PATCH 10/40] feat: Add locale parameter handling for improved language support in callbacks --- pages/ads/main.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 35db242..998948a 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -241,6 +241,7 @@ const RETRY_DELAY_MS = 2000; // Get parameters from URL const sessionId = urlParams.get('s') || urlParams.get('session'); const referrer = urlParams.get('r') || urlParams.get('referrer'); // 'web' or 'app' +const locale = urlParams.get('l') || urlParams.get('lang') || navigator.language?.split('-')[0].toLowerCase() || 'en'; // State management let player: YTPlayer | null = null; @@ -709,6 +710,7 @@ async function sendCallback() { method: 'POST', headers: { 'Content-Type': 'application/json', + 'Accept-Language': locale, }, body: JSON.stringify(payload), }); From 9f76a9d2c88104e01a9a88c84990208f9ae6b291 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 31 Dec 2025 18:54:00 +0400 Subject: [PATCH 11/40] feat: Refactor callback payload construction for enhanced video analytics tracking --- pages/ads/main.ts | 110 ++++++++++++++++++++++++---------------------- 1 file changed, 57 insertions(+), 53 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 998948a..e74457a 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -489,6 +489,7 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { sendPostMessage('video-ended', { duration: event.target.getDuration(), watchDuration: (Date.now() - videoStartTime) / 1000, + ...buildCallbackPayload(), }); onVideoComplete(); } @@ -641,6 +642,61 @@ async function onVideoComplete() { } } +// Build callback payload +function buildCallbackPayload() { + const watchDuration = (Date.now() - videoStartTime) / 1000; // in seconds + return { + session: sessionId, + video_id: VIDEO_ID, + page: 'ads', + video_url: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + completed_at: new Date().toISOString(), + + // Basic metadata + user_agent: metadata.userAgent, + platform: metadata.platform, + language: metadata.language, + + // Extended metadata + screen_resolution: metadata.screenResolution, + viewport_size: metadata.viewportSize, + timezone: metadata.timezone, + referrer: metadata.referrer, + device_memory: metadata.deviceMemory, + hardware_concurrency: metadata.hardwareConcurrency, + + // Behavioral metadata + watch_duration: Math.round(watchDuration), + seek_attempts: seekAttempts, + pause_count: pauseCount, + was_tab_active: wasTabActive, + max_watched_time: Math.round(maxWatchedTime), + fullscreen_count: fullscreenCount, + + // Advanced engagement metrics + total_pause_duration: Math.round(totalPauseDuration), + average_pause_duration: pauseCount > 0 ? Math.round(totalPauseDuration / pauseCount) : 0, + volume_changes: volumeChanges, + tab_switch_count: tabSwitchCount, + player_error_count: playerErrorCount, + buffering_events: bufferingEvents, + total_buffering_duration: Math.round(totalBufferingDuration), + + // Quality metrics + engagement_rate: Math.round((maxWatchedTime / (player?.getDuration() || 1)) * 100), + completion_quality: seekAttempts === 0 && pauseCount <= 2 ? 'high' : pauseCount <= 5 ? 'medium' : 'low', + viewer_behavior: tabSwitchCount === 0 ? 'focused' : tabSwitchCount <= 2 ? 'normal' : 'distracted', + + // Network quality indicators + connection_quality: bufferingEvents === 0 ? 'excellent' : bufferingEvents <= 2 ? 'good' : bufferingEvents <= 5 ? 'fair' : 'poor', + buffering_ratio: Math.round((totalBufferingDuration / watchDuration) * 100), + + // Analytics milestones + milestones_reached: Array.from(reachedMilestones), + milestones_completion_rate: (reachedMilestones.size / milestones.length) * 100, + }; +} + async function sendCallback() { // Rate limiting: prevent duplicate sends if (callbackSent) { @@ -653,59 +709,7 @@ async function sendCallback() { // Retry mechanism with exponential backoff for (let attempt = 1; attempt <= MAX_CALLBACK_RETRIES; attempt++) { try { - const watchDuration = (Date.now() - videoStartTime) / 1000; // in seconds - - const payload = { - session: sessionId, - video_id: VIDEO_ID, - page: 'ads', - video_url: `https://www.youtube.com/watch?v=${VIDEO_ID}`, - completed_at: new Date().toISOString(), - - // Basic metadata - user_agent: metadata.userAgent, - platform: metadata.platform, - language: metadata.language, - - // Extended metadata - screen_resolution: metadata.screenResolution, - viewport_size: metadata.viewportSize, - timezone: metadata.timezone, - referrer: metadata.referrer, - device_memory: metadata.deviceMemory, - hardware_concurrency: metadata.hardwareConcurrency, - - // Behavioral metadata - watch_duration: Math.round(watchDuration), - seek_attempts: seekAttempts, - pause_count: pauseCount, - was_tab_active: wasTabActive, - max_watched_time: Math.round(maxWatchedTime), - fullscreen_count: fullscreenCount, - - // Advanced engagement metrics - total_pause_duration: Math.round(totalPauseDuration), - average_pause_duration: pauseCount > 0 ? Math.round(totalPauseDuration / pauseCount) : 0, - volume_changes: volumeChanges, - tab_switch_count: tabSwitchCount, - player_error_count: playerErrorCount, - buffering_events: bufferingEvents, - total_buffering_duration: Math.round(totalBufferingDuration), - - // Quality metrics - engagement_rate: Math.round((maxWatchedTime / (player?.getDuration() || 1)) * 100), - completion_quality: seekAttempts === 0 && pauseCount <= 2 ? 'high' : pauseCount <= 5 ? 'medium' : 'low', - viewer_behavior: tabSwitchCount === 0 ? 'focused' : tabSwitchCount <= 2 ? 'normal' : 'distracted', - - // Network quality indicators - connection_quality: bufferingEvents === 0 ? 'excellent' : bufferingEvents <= 2 ? 'good' : bufferingEvents <= 5 ? 'fair' : 'poor', - buffering_ratio: Math.round((totalBufferingDuration / watchDuration) * 100), - - // Analytics milestones - milestones_reached: Array.from(reachedMilestones), - milestones_completion_rate: (reachedMilestones.size / milestones.length) * 100, - }; - + const payload = buildCallbackPayload(); const response = await fetch(CALLBACK_URL, { method: 'POST', headers: { From 82cfed3a164c112d57503a30d233dc474be60822 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 31 Dec 2025 19:02:48 +0400 Subject: [PATCH 12/40] feat: Integrate callback payload into video state change and completion handling for enhanced analytics --- pages/ads/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index e74457a..e4ff02b 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -489,7 +489,6 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { sendPostMessage('video-ended', { duration: event.target.getDuration(), watchDuration: (Date.now() - videoStartTime) / 1000, - ...buildCallbackPayload(), }); onVideoComplete(); } @@ -610,6 +609,7 @@ async function onVideoComplete() { maxWatchedTime, seekAttempts, pauseCount, + ...buildCallbackPayload(), }); // Wait 1.5 seconds before showing completion screen for smoother UX From 3cb1fcd001afe4d5860f70e5224c03db27147ac7 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Mon, 5 Jan 2026 22:16:46 +0400 Subject: [PATCH 13/40] feat: Remove redundant video metrics from completion payload for cleaner analytics --- pages/ads/main.ts | 64 +++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index e4ff02b..d5a6fa9 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -308,8 +308,8 @@ const metadata = { // Send page loaded event sendPostMessage('page-loaded', { - videoId: VIDEO_ID, - sessionId, + video_id: VIDEO_ID, + session: sessionId, referrer, language: currentLang, }); @@ -414,7 +414,7 @@ function onPlayerReady(event: { target: YTPlayer }) { // Send player ready event sendPostMessage('player-ready', { - videoId: VIDEO_ID, + video_id: VIDEO_ID, duration: event.target.getDuration(), }); @@ -454,8 +454,8 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { pauseCount++; lastPauseTime = Date.now(); sendPostMessage('video-paused', { - currentTime: event.target.getCurrentTime(), - pauseCount, + current_time: event.target.getCurrentTime(), + pause_count: pauseCount, }); } @@ -471,7 +471,7 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { lastBufferingTime = 0; } sendPostMessage('video-playing', { - currentTime: event.target.getCurrentTime(), + current_time: event.target.getCurrentTime(), }); } @@ -479,8 +479,8 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { bufferingEvents++; lastBufferingTime = Date.now(); sendPostMessage('video-buffering', { - currentTime: event.target.getCurrentTime(), - bufferingEvents, + current_time: event.target.getCurrentTime(), + buffering_events: bufferingEvents, }); } @@ -488,7 +488,7 @@ function onPlayerStateChange(event: { target: YTPlayer; data: number }) { isVideoCompleted = true; sendPostMessage('video-ended', { duration: event.target.getDuration(), - watchDuration: (Date.now() - videoStartTime) / 1000, + watch_duration: (Date.now() - videoStartTime) / 1000, }); onVideoComplete(); } @@ -499,8 +499,8 @@ function onPlayerError(event: { target: YTPlayer; data: number }) { console.error('YouTube Player Error:', event.data); sendPostMessage('video-error', { - errorCode: event.data, - playerErrorCount, + error_code: event.data, + player_error_count: playerErrorCount, }); const loadingScreen = document.getElementById('loadingScreen'); @@ -523,9 +523,9 @@ function startProgressTracking() { if (currentTime > maxWatchedTime + 1) { seekAttempts++; sendPostMessage('seek-attempt-blocked', { - attemptedTime: currentTime, - maxWatchedTime, - seekAttempts, + attempted_time: currentTime, + max_watched_time: maxWatchedTime, + seek_attempts: seekAttempts, }); showWarning(t.doNotSkip); player.seekTo(maxWatchedTime, true); @@ -582,7 +582,7 @@ function updateProgress(currentTime: number, duration: number) { console.log(`Milestone reached: ${milestone * 100}%`); sendPostMessage('milestone-reached', { milestone: milestone * 100, - currentTime, + current_time: currentTime, duration, }); } @@ -603,12 +603,6 @@ function showWarning(message: string) { async function onVideoComplete() { sendPostMessage('video-complete', { - sessionId, - videoId: VIDEO_ID, - watchDuration: (Date.now() - videoStartTime) / 1000, - maxWatchedTime, - seekAttempts, - pauseCount, ...buildCallbackPayload(), }); @@ -726,8 +720,8 @@ async function sendCallback() { } console.log('Callback sent successfully'); sendPostMessage('callback-success', { - sessionId, - attempt, + session: sessionId, + attempt, }); return; // Success, exit retry loop } else { @@ -742,7 +736,7 @@ async function sendCallback() { completionMessage.textContent = t.confirmationFailed; } sendPostMessage('callback-failed', { - sessionId, + session: sessionId, error: error instanceof Error ? error.message : 'Unknown error', attempts: MAX_CALLBACK_RETRIES, }); @@ -751,9 +745,9 @@ async function sendCallback() { const delay = RETRY_DELAY_MS * attempt; console.log(`Retrying in ${delay}ms...`); sendPostMessage('callback-retry', { - sessionId, + session: sessionId, attempt, - nextDelay: delay, + next_delay: delay, }); await new Promise(resolve => setTimeout(resolve, delay)); } @@ -861,8 +855,8 @@ document.addEventListener('visibilitychange', () => { wasTabActive = false; player.pauseVideo(); sendPostMessage('tab-hidden', { - tabSwitchCount, - currentTime: player.getCurrentTime(), + tab_switch_count: tabSwitchCount, + current_time: player.getCurrentTime(), }); } else { // Tab gained focus - resume video if it was playing @@ -870,7 +864,7 @@ document.addEventListener('visibilitychange', () => { const { YT } = window; sendPostMessage('tab-visible', { - currentTime: player.getCurrentTime(), + current_time: player.getCurrentTime(), }); // Resume only if paused (not ended or unstarted) @@ -890,14 +884,14 @@ function handleCloseAttempt() { if (isVideoCompleted) { // Video completed - close immediately sendPostMessage('close-attempt', { - isCompleted: true, + is_completed: true, }); closeAndReturn(); } else { // Video not completed - show confirmation dialog sendPostMessage('close-attempt', { - isCompleted: false, - currentTime: player?.getCurrentTime(), + is_completed: false, + current_time: player?.getCurrentTime(), duration: player?.getDuration(), }); const confirmationDialog = document.getElementById('confirmationDialog'); @@ -918,7 +912,7 @@ const dialogLeave = document.getElementById('dialogLeave'); dialogStay?.addEventListener('click', () => { sendPostMessage('dialog-stay', { - currentTime: player?.getCurrentTime(), + current_time: player?.getCurrentTime(), }); const confirmationDialog = document.getElementById('confirmationDialog'); if (confirmationDialog) { @@ -928,7 +922,7 @@ dialogStay?.addEventListener('click', () => { dialogLeave?.addEventListener('click', () => { sendPostMessage('dialog-leave', { - currentTime: player?.getCurrentTime(), + current_time: player?.getCurrentTime(), duration: player?.getDuration(), }); closeAndReturn(); @@ -962,7 +956,7 @@ document.addEventListener('keydown', (e) => { async function closeAndReturn() { sendPostMessage('closing', { referrer, - isCompleted: isVideoCompleted, + is_completed: isVideoCompleted, }); try { From a01c0f8351e1274a5310a849677504dd856b8a74 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Tue, 6 Jan 2026 14:17:26 +0400 Subject: [PATCH 14/40] feat: Comment out close button in video player for future reference --- pages/ads/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index d5a6fa9..aeaf5ad 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -324,12 +324,12 @@ app.innerHTML = `

${t.loadingVideo}

- + */
From b4467a0a106dae40f52bd15a7a571401d391c5f7 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Tue, 6 Jan 2026 17:16:09 +0400 Subject: [PATCH 15/40] feat: Comment out session ID check in video completion handler for future reference --- pages/ads/main.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index aeaf5ad..79d5147 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -623,12 +623,14 @@ async function onVideoComplete() { // Send callback if session exists if (sessionId) { await sendCallback(); - } else { + } + + /* if (!sessionId) { const completionMessage = document.getElementById('completionMessage'); if (completionMessage) { completionMessage.textContent = t.noSessionId; } - } + } */ // Show close button after completion if (closeFinalButton) { From 551a30e29a74890cac538e9bd027e2891860ed97 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Tue, 6 Jan 2026 21:52:37 +0400 Subject: [PATCH 16/40] Update main.ts --- pages/ads/main.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 79d5147..f15d9ee 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -324,12 +324,6 @@ app.innerHTML = `

${t.loadingVideo}

- /* */
From 17539a366e504382bcee3fe1e09e74d35d339ed4 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 7 Jan 2026 18:32:19 +0400 Subject: [PATCH 17/40] feat: Add TODOs for displaying ads from a video list and iframe closure after video completion --- pages/ads/main.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index f15d9ee..30f3ba5 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -1,3 +1,7 @@ +// TODO(plugfox): Display ads from list of videos instead of single video ID, e.g. multiple youtube shorts +// TODO(plugfox): Close this iframe / webview automatically after all videos are watched rather than showing "You can close manually" message +// TODO(plugfox): Fix Firebase Hosting cache issues, when index.html tries to load old script-abc123.ts.js files after new deploys + import { initPage } from '~/shared/utils/page-init'; import './style.css'; From 80b59458eb1e282f3dfc9a7327e41f53f6d8dd7c Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 7 Jan 2026 18:40:38 +0400 Subject: [PATCH 18/40] feat: Add TODO for auto starting YouTube video --- pages/ads/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 30f3ba5..71fde7b 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -1,6 +1,7 @@ // TODO(plugfox): Display ads from list of videos instead of single video ID, e.g. multiple youtube shorts // TODO(plugfox): Close this iframe / webview automatically after all videos are watched rather than showing "You can close manually" message // TODO(plugfox): Fix Firebase Hosting cache issues, when index.html tries to load old script-abc123.ts.js files after new deploys +// TODO(plugfox): Auto start youTube video import { initPage } from '~/shared/utils/page-init'; import './style.css'; From 570bb39d650757373d5becdc64df99e423b97cf7 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 8 Jan 2026 20:44:47 +0400 Subject: [PATCH 19/40] fix: Update Cache-Control headers for better caching strategy --- firebase.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/firebase.json b/firebase.json index 78a4a46..86395ba 100644 --- a/firebase.json +++ b/firebase.json @@ -21,29 +21,29 @@ ], "headers": [ { - "source": "**/*.@(js|css|png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|eot|ico)", + "source": "**/*.@(html|json)", "headers": [ { "key": "Cache-Control", - "value": "public, max-age=31536000, immutable" + "value": "no-cache, no-store, must-revalidate" } ] }, { - "source": "**/*.@(html|json)", + "source": "**/*.@(css|png|jpg|jpeg|gif|svg|webp|woff|woff2|ttf|eot|ico)", "headers": [ { "key": "Cache-Control", - "value": "no-store" + "value": "public, max-age=31536000, immutable" } ] }, { - "source": "**/*.@(wasm|mjs|js)", + "source": "**/*.@(js|mjs)", "headers": [ { "key": "Cache-Control", - "value": "no-cache, max-age=0, must-revalidate" + "value": "public, max-age=31536000, immutable" } ] } From 4c46ff4c9bee9df36f7c37c06147d79d1d82a2ab Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 8 Jan 2026 21:42:11 +0400 Subject: [PATCH 20/40] feat: Update TODOs for displaying ads and iframe closure functionality --- pages/ads/main.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 71fde7b..571154e 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -1,7 +1,8 @@ -// TODO(plugfox): Display ads from list of videos instead of single video ID, e.g. multiple youtube shorts -// TODO(plugfox): Close this iframe / webview automatically after all videos are watched rather than showing "You can close manually" message -// TODO(plugfox): Fix Firebase Hosting cache issues, when index.html tries to load old script-abc123.ts.js files after new deploys -// TODO(plugfox): Auto start youTube video +// TODO(plugfox): +// [ ] Display ads from list of videos instead of single video ID, e.g. multiple youtube shorts +// [ ] Close this iframe / webview automatically after all videos are watched rather than showing "You can close manually" message +// [x] Fix Firebase Hosting cache issues, when index.html tries to load old script-abc123.ts.js files after new deploys +// [ ] Auto start youTube video import { initPage } from '~/shared/utils/page-init'; import './style.css'; From 7efea87a4daec6bfbe874fedb94e3ab731d942ad Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 8 Jan 2026 21:53:37 +0400 Subject: [PATCH 21/40] feat: Mark iframe closure as complete and implement auto-closing after video completion --- pages/ads/main.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 571154e..cd7da9a 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -1,6 +1,6 @@ // TODO(plugfox): // [ ] Display ads from list of videos instead of single video ID, e.g. multiple youtube shorts -// [ ] Close this iframe / webview automatically after all videos are watched rather than showing "You can close manually" message +// [x] Close this iframe / webview automatically after all videos are watched rather than showing "You can close manually" message // [x] Fix Firebase Hosting cache issues, when index.html tries to load old script-abc123.ts.js files after new deploys // [ ] Auto start youTube video @@ -611,7 +611,6 @@ async function onVideoComplete() { const completionScreen = document.getElementById('completionScreen'); const overlay = document.getElementById('overlay'); - const closeFinalButton = document.getElementById('closeFinalButton'); const closeButton = document.getElementById('closeButton'); if (completionScreen) completionScreen.classList.add('show'); @@ -632,10 +631,11 @@ async function onVideoComplete() { } } */ - // Show close button after completion - if (closeFinalButton) { - closeFinalButton.style.display = 'block'; - } + // Wait a short pause (1 seconds) before auto-closing + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Automatically close and return without user interaction + closeAndReturn(); } // Build callback payload From 27513e755b31e0c7a011b5c0f0ea659ef5b80d83 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 8 Jan 2026 22:02:12 +0400 Subject: [PATCH 22/40] feat: Remove confirmation dialog and related logic for closing video iframe --- pages/ads/main.ts | 219 +--------------------------------------------- 1 file changed, 3 insertions(+), 216 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index cd7da9a..f2d1110 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -20,25 +20,10 @@ const translations = { watchToEnd: 'Please watch the video to the end without skipping', doNotSkip: 'Do not skip ahead! Video will restart.', keyboardDisabled: 'Keyboard controls are disabled', - // Dialog - confirmLeaveTitle: 'Are you sure you want to leave?', - confirmLeaveText: 'The video is not finished yet. If you leave now, the ad view will not be counted.', - stayAndWatch: 'Stay and watch', - leaveAnyway: 'Leave anyway', - // Completion - thankYou: 'Thank you for watching!', - sendingConfirmation: 'Sending confirmation...', - confirmationSent: 'Confirmation sent successfully!', - confirmationFailed: 'Failed to send confirmation. Please check your connection.', - noSessionId: 'No session ID provided. Callback not sent.', - closeAndReturn: 'Close and return', - canCloseManually: 'You can now close this tab manually.', // Loading loadingVideo: 'Loading video...', videoLoadError: 'Failed to load video. Please refresh the page.', // Tooltips & Aria - closeButtonLabel: 'Close', - closeButtonTooltip: 'Close and return to app', videoPlayerLabel: 'Advertisement video player', progressBarLabel: 'Video progress', }, @@ -53,25 +38,10 @@ const translations = { watchToEnd: 'Пожалуйста, посмотрите видео до конца без пропусков', doNotSkip: 'Не перематывайте! Видео начнется сначала.', keyboardDisabled: 'Управление с клавиатуры отключено', - // Диалог - confirmLeaveTitle: 'Вы уверены, что хотите уйти?', - confirmLeaveText: 'Видео еще не закончилось. Если вы уйдете сейчас, просмотр рекламы не будет засчитан.', - stayAndWatch: 'Остаться и смотреть', - leaveAnyway: 'Все равно уйти', - // Завершение - thankYou: 'Спасибо за просмотр!', - sendingConfirmation: 'Отправка подтверждения...', - confirmationSent: 'Подтверждение успешно отправлено!', - confirmationFailed: 'Не удалось отправить подтверждение. Проверьте соединение.', - noSessionId: 'ID сессии не указан. Подтверждение не отправлено.', - closeAndReturn: 'Закрыть и вернуться', - canCloseManually: 'Вы можете закрыть эту вкладку вручную.', // Загрузка loadingVideo: 'Загрузка видео...', videoLoadError: 'Не удалось загрузить видео. Пожалуйста, обновите страницу.', // Подсказки и Aria - closeButtonLabel: 'Закрыть', - closeButtonTooltip: 'Закрыть и вернуться в приложение', videoPlayerLabel: 'Видеоплеер рекламы', progressBarLabel: 'Прогресс видео', }, @@ -86,25 +56,10 @@ const translations = { watchToEnd: 'Por favor, mira el vídeo hasta el final sin saltarlo', doNotSkip: '¡No adelantes! El vídeo se reiniciará.', keyboardDisabled: 'Los controles del teclado están deshabilitados', - // Diálogo - confirmLeaveTitle: '¿Estás seguro de que quieres salir?', - confirmLeaveText: 'El vídeo aún no ha terminado. Si sales ahora, la visualización del anuncio no se contará.', - stayAndWatch: 'Quedarse y ver', - leaveAnyway: 'Salir de todos modos', - // Finalización - thankYou: '¡Gracias por ver!', - sendingConfirmation: 'Enviando confirmación...', - confirmationSent: '¡Confirmación enviada con éxito!', - confirmationFailed: 'Error al enviar la confirmación. Comprueba tu conexión.', - noSessionId: 'No se proporcionó ID de sesión. Confirmación no enviada.', - closeAndReturn: 'Cerrar y volver', - canCloseManually: 'Ahora puedes cerrar esta pestaña manualmente.', // Carga loadingVideo: 'Cargando vídeo...', videoLoadError: 'Error al cargar el vídeo. Por favor, actualiza la página.', // Tooltips y Aria - closeButtonLabel: 'Cerrar', - closeButtonTooltip: 'Cerrar y volver a la aplicación', videoPlayerLabel: 'Reproductor de vídeo publicitario', progressBarLabel: 'Progreso del vídeo', }, @@ -119,25 +74,10 @@ const translations = { watchToEnd: 'Bitte schauen Sie das Video bis zum Ende ohne zu überspringen', doNotSkip: 'Nicht vorspulen! Video wird neu gestartet.', keyboardDisabled: 'Tastatursteuerung ist deaktiviert', - // Dialog - confirmLeaveTitle: 'Bist du sicher, dass du gehen möchtest?', - confirmLeaveText: 'Das Video ist noch nicht zu Ende. Wenn Sie jetzt gehen, wird die Anzeige nicht gezählt.', - stayAndWatch: 'Bleiben und ansehen', - leaveAnyway: 'Trotzdem verlassen', - // Abschluss - thankYou: 'Vielen Dank fürs Ansehen!', - sendingConfirmation: 'Bestätigung wird gesendet...', - confirmationSent: 'Bestätigung erfolgreich gesendet!', - confirmationFailed: 'Bestätigung konnte nicht gesendet werden. Überprüfen Sie Ihre Verbindung.', - noSessionId: 'Keine Sitzungs-ID angegeben. Bestätigung nicht gesendet.', - closeAndReturn: 'Schließen und zurückkehren', - canCloseManually: 'Sie können diesen Tab jetzt manuell schließen.', // Laden loadingVideo: 'Video wird geladen...', videoLoadError: 'Video konnte nicht geladen werden. Bitte aktualisieren Sie die Seite.', // Tooltips und Aria - closeButtonLabel: 'Schließen', - closeButtonTooltip: 'Schließen und zur App zurückkehren', videoPlayerLabel: 'Werbevideoplayer', progressBarLabel: 'Videofortschritt', }, @@ -350,27 +290,6 @@ app.innerHTML = `
- -
-
- -

${t.thankYou}

-

${t.sendingConfirmation}

- -
-
`; // Load YouTube IFrame API @@ -606,33 +525,13 @@ async function onVideoComplete() { ...buildCallbackPayload(), }); - // Wait 1.5 seconds before showing completion screen for smoother UX - await new Promise(resolve => setTimeout(resolve, 1500)); - - const completionScreen = document.getElementById('completionScreen'); - const overlay = document.getElementById('overlay'); - const closeButton = document.getElementById('closeButton'); - - if (completionScreen) completionScreen.classList.add('show'); - if (overlay) overlay.style.display = 'none'; - - // Hide the X button in top-left corner - if (closeButton) closeButton.classList.add('hidden'); - // Send callback if session exists if (sessionId) { await sendCallback(); } - /* if (!sessionId) { - const completionMessage = document.getElementById('completionMessage'); - if (completionMessage) { - completionMessage.textContent = t.noSessionId; - } - } */ - - // Wait a short pause (1 seconds) before auto-closing - await new Promise(resolve => setTimeout(resolve, 1000)); + // Wait a short pause before auto-closing + await new Promise(resolve => setTimeout(resolve, 500)); // Automatically close and return without user interaction closeAndReturn(); @@ -700,8 +599,6 @@ async function sendCallback() { return; } - const completionMessage = document.getElementById('completionMessage'); - // Retry mechanism with exponential backoff for (let attempt = 1; attempt <= MAX_CALLBACK_RETRIES; attempt++) { try { @@ -717,9 +614,6 @@ async function sendCallback() { if (response.ok) { callbackSent = true; - if (completionMessage) { - completionMessage.textContent = t.confirmationSent; - } console.log('Callback sent successfully'); sendPostMessage('callback-success', { session: sessionId, @@ -734,9 +628,6 @@ async function sendCallback() { if (attempt === MAX_CALLBACK_RETRIES) { // Final attempt failed - if (completionMessage) { - completionMessage.textContent = t.confirmationFailed; - } sendPostMessage('callback-failed', { session: sessionId, error: error instanceof Error ? error.message : 'Unknown error', @@ -876,83 +767,7 @@ document.addEventListener('visibilitychange', () => { } }); -// No beforeunload warning - we handle closing with custom dialog - -// Close button handler -const closeButton = document.getElementById('closeButton'); -closeButton?.addEventListener('click', handleCloseAttempt); - -function handleCloseAttempt() { - if (isVideoCompleted) { - // Video completed - close immediately - sendPostMessage('close-attempt', { - is_completed: true, - }); - closeAndReturn(); - } else { - // Video not completed - show confirmation dialog - sendPostMessage('close-attempt', { - is_completed: false, - current_time: player?.getCurrentTime(), - duration: player?.getDuration(), - }); - const confirmationDialog = document.getElementById('confirmationDialog'); - if (confirmationDialog) { - confirmationDialog.classList.add('show'); - // Focus first button for accessibility - const dialogStay = document.getElementById('dialogStay'); - if (dialogStay) { - setTimeout(() => dialogStay.focus(), 100); - } - } - } -} - -// Dialog button handlers -const dialogStay = document.getElementById('dialogStay'); -const dialogLeave = document.getElementById('dialogLeave'); - -dialogStay?.addEventListener('click', () => { - sendPostMessage('dialog-stay', { - current_time: player?.getCurrentTime(), - }); - const confirmationDialog = document.getElementById('confirmationDialog'); - if (confirmationDialog) { - confirmationDialog.classList.remove('show'); - } -}); - -dialogLeave?.addEventListener('click', () => { - sendPostMessage('dialog-leave', { - current_time: player?.getCurrentTime(), - duration: player?.getDuration(), - }); - closeAndReturn(); -}); - -// Keyboard navigation for dialog -document.addEventListener('keydown', (e) => { - const confirmationDialog = document.getElementById('confirmationDialog'); - if (confirmationDialog && confirmationDialog.classList.contains('show')) { - if (e.key === 'Escape') { - confirmationDialog.classList.remove('show'); - } - // Tab trap within dialog - if (e.key === 'Tab') { - const focusableElements = confirmationDialog.querySelectorAll('button'); - const firstElement = focusableElements[0] as HTMLElement; - const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; - - if (e.shiftKey && document.activeElement === firstElement) { - e.preventDefault(); - lastElement.focus(); - } else if (!e.shiftKey && document.activeElement === lastElement) { - e.preventDefault(); - firstElement.focus(); - } - } - } -}); +// No beforeunload warning and no manual close button // Close and return logic async function closeAndReturn() { @@ -1033,35 +848,7 @@ function tryCloseTab() { try { // Try to close the window (will only work if opened via window.open) window.close(); - - // Check if window is still open after close attempt - setTimeout(() => { - // If we're still here, window.close() didn't work - // Only show message if video was completed - if (isVideoCompleted) { - const completionScreen = document.getElementById('completionScreen'); - const completionMessage = document.getElementById('completionMessage'); - - if (completionScreen && !completionScreen.classList.contains('show')) { - completionScreen.classList.add('show'); - } - - if (completionMessage) { - completionMessage.textContent = t.canCloseManually; - } - - // Hide the close button since we're showing the message - const closeFinalButton = document.getElementById('closeFinalButton'); - if (closeFinalButton) { - closeFinalButton.style.display = 'none'; - } - } - }, 100); } catch (error) { console.error('Failed to close tab:', error); } } - -// Final close button handler (after completion) -const closeFinalButton = document.getElementById('closeFinalButton'); -closeFinalButton?.addEventListener('click', closeAndReturn); From 677c4f694c779dd661b75d36cdd6559b70534165 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 8 Jan 2026 22:02:38 +0400 Subject: [PATCH 23/40] feat: Comment out tryCloseTab function to prevent tab closure during testing --- pages/ads/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index f2d1110..630cc8f 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -789,7 +789,7 @@ async function closeAndReturn() { } // Try to close the tab - tryCloseTab(); + //tryCloseTab(); } async function redirectToWebApp() { From 5029d62a6cbecc20758721afdb681808dceedc4c Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 8 Jan 2026 22:04:00 +0400 Subject: [PATCH 24/40] feat: Remove unused completion screen and related styles --- pages/ads/style.css | 290 -------------------------------------------- 1 file changed, 290 deletions(-) diff --git a/pages/ads/style.css b/pages/ads/style.css index 4de55f4..9cf5604 100644 --- a/pages/ads/style.css +++ b/pages/ads/style.css @@ -152,90 +152,6 @@ body { transform: translate(-50%, -50%) scale(1); } -/* Completion Screen */ -.completion-screen { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - opacity: 0; - pointer-events: none; - transition: opacity 0.5s ease; -} - -.completion-screen.show { - opacity: 1; - pointer-events: all; -} - -.completion-content { - text-align: center; - color: #fff; - animation: slideUp 0.6s ease-out; -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(30px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.completion-content h2 { - font-size: 32px; - font-weight: 700; - margin: 24px 0 12px; -} - -.completion-content p { - font-size: 16px; - opacity: 0.9; - margin: 0; -} - -/* Checkmark Animation */ -.checkmark { - width: 80px; - height: 80px; - margin: 0 auto; - display: block; -} - -.checkmark-circle { - stroke: #fff; - stroke-width: 2; - stroke-miterlimit: 10; - stroke-dasharray: 166; - stroke-dashoffset: 166; - animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards; -} - -.checkmark-check { - stroke: #fff; - stroke-width: 3; - stroke-linecap: round; - stroke-linejoin: round; - stroke-dasharray: 48; - stroke-dashoffset: 48; - animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.4s forwards; -} - -@keyframes stroke { - to { - stroke-dashoffset: 0; - } -} - /* Responsive Design */ @media (max-width: 768px) { .overlay { @@ -264,19 +180,6 @@ body { padding: 16px 24px; max-width: 300px; } - - .completion-content h2 { - font-size: 24px; - } - - .completion-content p { - font-size: 14px; - } - - .checkmark { - width: 60px; - height: 60px; - } } /* Prevent text selection */ @@ -347,196 +250,3 @@ body { height: 100%; } -/* Close Button */ -.close-button { - position: fixed; - top: 20px; - left: 20px; - width: 44px; - height: 44px; - background: rgba(0, 0, 0, 0.6); - border: none; - border-radius: 50%; - color: #fff; - cursor: pointer; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.3s ease; - backdrop-filter: blur(10px); -} - -.close-button.hidden { - opacity: 0; - pointer-events: none; -} - -.close-button:hover { - background: rgba(0, 0, 0, 0.8); - transform: scale(1.1); -} - -.close-button:active { - transform: scale(0.95); -} - -.close-button svg { - width: 20px; - height: 20px; -} - -/* Confirmation Dialog */ -.confirmation-dialog { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; - opacity: 0; - pointer-events: none; - transition: opacity 0.3s ease; -} - -.confirmation-dialog.show { - opacity: 1; - pointer-events: all; -} - -.dialog-content { - background: #fff; - border-radius: 16px; - padding: 32px; - max-width: 400px; - width: 90%; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); - animation: dialogSlideUp 0.3s ease-out; -} - -@keyframes dialogSlideUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.dialog-content h3 { - margin: 0 0 12px; - font-size: 22px; - font-weight: 700; - color: #1a1a1a; -} - -.dialog-content p { - margin: 0 0 24px; - font-size: 15px; - line-height: 1.6; - color: #666; -} - -.dialog-buttons { - display: flex; - gap: 12px; - flex-direction: column; -} - -.dialog-button { - padding: 14px 24px; - border: none; - border-radius: 10px; - font-size: 16px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; -} - -.dialog-button:active { - transform: scale(0.98); -} - -.dialog-button-secondary { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: #fff; -} - -.dialog-button-secondary:hover { - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); - transform: translateY(-2px); -} - -.dialog-button-danger { - background: #fff; - color: #dc2626; - border: 2px solid #dc2626; -} - -.dialog-button-danger:hover { - background: #dc2626; - color: #fff; -} - -/* Close Final Button */ -.close-final-button { - margin: 24px auto 0; - padding: 14px 32px; - background: #fff; - color: #667eea; - border: none; - border-radius: 10px; - font-size: 16px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - display: block; -} - -.close-final-button:hover { - transform: translateY(-2px); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); -} - -.close-final-button:active { - transform: translateY(0); -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .close-button { - top: 16px; - left: 16px; - width: 40px; - height: 40px; - } - - .close-button svg { - width: 18px; - height: 18px; - } - - .dialog-content { - padding: 24px; - } - - .dialog-content h3 { - font-size: 20px; - } - - .dialog-content p { - font-size: 14px; - } - - .dialog-button { - padding: 12px 20px; - font-size: 15px; - } -} From 428c76f933c46ff78238e0cf319f03bd3f7157d2 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 8 Jan 2026 22:18:28 +0400 Subject: [PATCH 25/40] feat: Update TODOs for auto-starting YouTube video and adjust playback comments --- pages/ads/main.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 630cc8f..d9eba8a 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -2,7 +2,7 @@ // [ ] Display ads from list of videos instead of single video ID, e.g. multiple youtube shorts // [x] Close this iframe / webview automatically after all videos are watched rather than showing "You can close manually" message // [x] Fix Firebase Hosting cache issues, when index.html tries to load old script-abc123.ts.js files after new deploys -// [ ] Auto start youTube video +// [x] Auto start YouTube video (muted for reliable autoplay) import { initPage } from '~/shared/utils/page-init'; import './style.css'; @@ -337,8 +337,7 @@ function onPlayerReady(event: { target: YTPlayer }) { duration: event.target.getDuration(), }); - // Unmute and start playing - event.target.unMute(); + // Start playing (muted for reliable autoplay) event.target.playVideo(); startProgressTracking(); From 7034c14e3f6eeafd869919a25dbf98962a39e936 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Thu, 8 Jan 2026 22:43:28 +0400 Subject: [PATCH 26/40] feat: Implement internationalization for payment success page and enhance UI elements --- pages/ads/index.html | 5 +- pages/stripe-success/index.html | 21 ++- pages/stripe-success/main.ts | 268 +++++++++++++++++++++++++++++++- pages/stripe-success/style.css | 245 ++++++++++++++++++++++++----- 4 files changed, 488 insertions(+), 51 deletions(-) diff --git a/pages/ads/index.html b/pages/ads/index.html index 460f99b..23151b7 100644 --- a/pages/ads/index.html +++ b/pages/ads/index.html @@ -5,9 +5,10 @@ + - Ads - Doctorina - + Doctorina | Ads + diff --git a/pages/stripe-success/index.html b/pages/stripe-success/index.html index 01d1ab7..8857234 100644 --- a/pages/stripe-success/index.html +++ b/pages/stripe-success/index.html @@ -3,15 +3,22 @@ + - - - Payment Successful - Doctorina - - - - + + Doctorina | Payment Successful + + + + + + + + + + + diff --git a/pages/stripe-success/main.ts b/pages/stripe-success/main.ts index c24fa3b..8f27762 100644 --- a/pages/stripe-success/main.ts +++ b/pages/stripe-success/main.ts @@ -1,23 +1,277 @@ import { initPage } from '~/shared/utils/page-init'; import './style.css'; -initPage('Payment Successful - Doctorina'); +// Internationalization (i18n) - Inline translations +const translations = { + en: { + // Language: English + lang: 'en', + // Page + pageTitle: 'Doctorina | Payment Successful', + // Success message + successTitle: 'Payment Successful!', + successMessage: 'Thank you for your payment. Your transaction has been completed successfully.', + // Redirect + redirectingIn: 'Redirecting in', + seconds: 'seconds...', + continueNow: 'Continue Now', + // Session + sessionId: 'Session ID:', + }, + ru: { + // Язык: Русский + lang: 'ru', + // Страница + pageTitle: 'Doctorina | Оплата успешна', + // Сообщение об успехе + successTitle: 'Оплата успешна!', + successMessage: 'Спасибо за оплату. Ваша транзакция успешно завершена.', + // Редирект + redirectingIn: 'Перенаправление через', + seconds: 'секунд...', + continueNow: 'Продолжить', + // Сессия + sessionId: 'ID сессии:', + }, + es: { + // Idioma: Español + lang: 'es', + // Página + pageTitle: 'Doctorina | Pago exitoso', + // Mensaje de éxito + successTitle: '¡Pago exitoso!', + successMessage: 'Gracias por su pago. Su transacción se ha completado con éxito.', + // Redirección + redirectingIn: 'Redirigiendo en', + seconds: 'segundos...', + continueNow: 'Continuar ahora', + // Sesión + sessionId: 'ID de sesión:', + }, + de: { + // Sprache: Deutsch + lang: 'de', + // Seite + pageTitle: 'Doctorina | Zahlung erfolgreich', + // Erfolgsmeldung + successTitle: 'Zahlung erfolgreich!', + successMessage: 'Vielen Dank für Ihre Zahlung. Ihre Transaktion wurde erfolgreich abgeschlossen.', + // Weiterleitung + redirectingIn: 'Weiterleitung in', + seconds: 'Sekunden...', + continueNow: 'Jetzt fortfahren', + // Sitzung + sessionId: 'Sitzungs-ID:', + }, + fr: { + // Langue: Français + lang: 'fr', + // Page + pageTitle: 'Doctorina | Paiement réussi', + // Message de succès + successTitle: 'Paiement réussi !', + successMessage: 'Merci pour votre paiement. Votre transaction a été complétée avec succès.', + // Redirection + redirectingIn: 'Redirection dans', + seconds: 'secondes...', + continueNow: 'Continuer maintenant', + // Session + sessionId: 'ID de session :', + }, + pt: { + // Idioma: Português + lang: 'pt', + // Página + pageTitle: 'Doctorina | Pagamento bem-sucedido', + // Mensagem de sucesso + successTitle: 'Pagamento bem-sucedido!', + successMessage: 'Obrigado pelo seu pagamento. Sua transação foi concluída com sucesso.', + // Redirecionamento + redirectingIn: 'Redirecionando em', + seconds: 'segundos...', + continueNow: 'Continuar agora', + // Sessão + sessionId: 'ID da sessão:', + }, + it: { + // Lingua: Italiano + lang: 'it', + // Pagina + pageTitle: 'Doctorina | Pagamento riuscito', + // Messaggio di successo + successTitle: 'Pagamento riuscito!', + successMessage: 'Grazie per il pagamento. La transazione è stata completata con successo.', + // Reindirizzamento + redirectingIn: 'Reindirizzamento tra', + seconds: 'secondi...', + continueNow: 'Continua ora', + // Sessione + sessionId: 'ID sessione:', + }, + zh: { + // 语言:中文 + lang: 'zh', + // 页面 + pageTitle: 'Doctorina | 支付成功', + // 成功消息 + successTitle: '支付成功!', + successMessage: '感谢您的付款。您的交易已成功完成。', + // 重定向 + redirectingIn: '将在', + seconds: '秒后重定向...', + continueNow: '立即继续', + // 会话 + sessionId: '会话ID:', + }, + ja: { + // 言語:日本語 + lang: 'ja', + // ページ + pageTitle: 'Doctorina | 支払い成功', + // 成功メッセージ + successTitle: '支払い成功!', + successMessage: 'お支払いありがとうございます。取引が正常に完了しました。', + // リダイレクト + redirectingIn: '', + seconds: '秒後にリダイレクトします...', + continueNow: '今すぐ続ける', + // セッション + sessionId: 'セッションID:', + }, + ar: { + // اللغة: العربية + lang: 'ar', + // الصفحة + pageTitle: 'Doctorina | الدفع ناجح', + // رسالة النجاح + successTitle: 'الدفع ناجح!', + successMessage: 'شكراً لك على الدفع. تمت عملية الدفع بنجاح.', + // إعادة التوجيه + redirectingIn: 'إعادة التوجيه خلال', + seconds: 'ثواني...', + continueNow: 'المتابعة الآن', + // الجلسة + sessionId: 'معرّف الجلسة:', + }, +}; + +// Detect browser language with fallback to English +function detectLanguage(): keyof typeof translations { + const browserLang = navigator.language.split('-')[0].toLowerCase(); + const supportedLanguages: (keyof typeof translations)[] = ['en', 'ru', 'es', 'de', 'fr', 'pt', 'it', 'zh', 'ja', 'ar']; + return supportedLanguages.includes(browserLang as any) ? (browserLang as keyof typeof translations) : 'en'; +} + +// Get current language and translations +const currentLang = detectLanguage(); +const t = translations[currentLang]; + +// Update HTML lang attribute +document.documentElement.lang = currentLang; + +// Set text direction for RTL languages +if (currentLang === 'ar') { + document.documentElement.dir = 'rtl'; +} + +initPage(t.pageTitle); const app = document.getElementById('app'); if (app) { // Get session_id from URL if present const urlParams = new URLSearchParams(window.location.search); - const sessionId = urlParams.get('session_id'); + const sessionId = urlParams.get('s') || urlParams.get('session_id'); + + // Safely decode and validate redirect URL + const redirectParam = urlParams.get('r') || urlParams.get('redirect'); + let redirectUrl = 'https://app.doctorina.com'; + + if (redirectParam) { + try { + // Decode URL-encoded parameter + const decodedUrl = decodeURIComponent(redirectParam); + + // Validate that URL is safe (same origin or whitelisted domain) + const url = new URL(decodedUrl, window.location.origin); + + // Whitelist of allowed domains for redirect + const allowedDomains = [ + 'doctorina.com', + 'app.doctorina.com', + 'localhost' + ]; + + const hostname = url.hostname.toLowerCase(); + const isAllowed = allowedDomains.some(domain => + hostname === domain || hostname.endsWith('.' + domain) + ); + + if (isAllowed) { + redirectUrl = decodedUrl; + } else { + console.warn('Redirect URL not in whitelist:', hostname); + } + } catch (error) { + console.error('Invalid redirect URL:', error); + } + } app.innerHTML = `
-
-

Payment Successful!

-

Thank you for your payment. Your transaction has been completed successfully.

- ${sessionId ? `

Session ID: ${sessionId}

` : ''} +
+ + + + +
+

${t.successTitle}

+

${t.successMessage}

+ ${sessionId ? `
${t.sessionId} ${sessionId}
` : ''} +
+

${t.redirectingIn} 15 ${t.seconds}

+
+
+
+
`; + + // Auto-redirect with countdown + let countdown = 15; + const countdownElement = document.getElementById('countdown'); + const progressElement = document.getElementById('progress'); + const redirectBtn = document.getElementById('redirect-btn') as HTMLAnchorElement | null; + + // Store redirect URL for the button + if (redirectBtn) { + redirectBtn.href = redirectUrl; + } + + const countdownInterval = setInterval(() => { + countdown--; + if (countdownElement) { + countdownElement.textContent = countdown.toString(); + } + + // Update progress bar + if (progressElement) { + const progress = ((15 - countdown) / 15) * 100; + progressElement.style.width = `${progress}%`; + } + + if (countdown <= 0) { + clearInterval(countdownInterval); + window.location.href = redirectUrl; + } + }, 1000); + + // Cancel auto-redirect if user clicks the button + if (redirectBtn) { + redirectBtn.addEventListener('click', () => { + clearInterval(countdownInterval); + }); + } } diff --git a/pages/stripe-success/style.css b/pages/stripe-success/style.css index ea0d47c..e0056aa 100644 --- a/pages/stripe-success/style.css +++ b/pages/stripe-success/style.css @@ -1,85 +1,260 @@ +* { + box-sizing: border-box; +} + body { margin: 0; padding: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; - background: linear-gradient(135deg, #28a745 0%, #20c997 100%); + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); min-height: 100vh; + height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; + overflow-x: hidden; + overflow-y: auto; } .container { background: white; - border-radius: 12px; - padding: 3rem 2rem; - max-width: 500px; + border-radius: 16px; + padding: 2.5rem 2rem; + max-width: 480px; width: 100%; - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); text-align: center; + animation: slideUp 0.5s ease-out; + margin: auto; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.success-icon { +/* Animated checkmark */ +.success-animation { + margin: 0 auto 1.5rem; +} + +.checkmark { width: 80px; height: 80px; - background: #28a745; - color: white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 3rem; - font-weight: bold; - margin: 0 auto 1.5rem; + margin: 0 auto; + display: block; +} + +.checkmark-circle { + stroke-dasharray: 166; + stroke-dashoffset: 166; + stroke-width: 2; + stroke-miterlimit: 10; + stroke: #4caf50; + fill: none; + animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards; +} + +.checkmark-check { + transform-origin: 50% 50%; + stroke-dasharray: 48; + stroke-dashoffset: 48; + stroke-width: 3; + stroke: #4caf50; + animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards; +} + +@keyframes stroke { + 100% { + stroke-dashoffset: 0; + } } h1 { - margin: 0 0 1rem; - font-size: 2rem; - color: #28a745; + margin: 0 0 0.75rem; + font-size: 1.75rem; + color: #212121; + font-weight: 500; + animation: fadeIn 0.6s ease-out 0.3s both; + letter-spacing: 0.25px; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .message { - margin: 0 0 1.5rem; - color: #666; - font-size: 1.1rem; - line-height: 1.6; + margin: 0 0 1.25rem; + color: #5f6368; + font-size: 1rem; + line-height: 1.5; + animation: fadeIn 0.6s ease-out 0.5s both; + font-weight: 400; } .session-id { margin: 1rem 0; - padding: 0.75rem; + padding: 0.75rem 1rem; background: #f8f9fa; border-radius: 8px; - font-size: 0.875rem; - color: #666; + font-size: 0.8125rem; + color: #5f6368; word-break: break-all; + font-family: 'Roboto Mono', 'Courier New', monospace; + border: 1px solid #e8eaed; +} + +/* Redirect countdown section */ +.redirect-info { + margin: 1.5rem 0 1.25rem; + animation: fadeIn 0.6s ease-out 0.7s both; +} + +.redirect-text { + margin: 0 0 0.875rem; + color: #5f6368; + font-size: 0.875rem; + font-weight: 400; +} + +#countdown { + font-weight: 600; + color: #1976d2; + font-size: 1rem; +} + +.progress-bar { + width: 100%; + height: 4px; + background: #e8eaed; + border-radius: 4px; + overflow: hidden; + margin: 0 auto; + max-width: 280px; +} + +.progress-fill { + height: 100%; + background: #1976d2; + border-radius: 4px; + width: 0%; + transition: width 1s linear; } .actions { - margin-top: 2rem; + margin-top: 1.5rem; + animation: fadeIn 0.6s ease-out 0.9s both; } .btn { display: inline-block; padding: 0.75rem 2rem; - border-radius: 8px; + border-radius: 4px; text-decoration: none; - font-weight: 600; - font-size: 1rem; - transition: all 0.2s; + font-weight: 500; + font-size: 0.9375rem; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + text-transform: uppercase; + letter-spacing: 0.5px; } .btn-primary { - background: #28a745; + background: #1976d2; color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 3px 4px rgba(0, 0, 0, 0.12), 0 1px 5px rgba(0, 0, 0, 0.2); } .btn-primary:hover { - background: #218838; - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3); + background: #1565c0; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.16), 0 6px 10px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.22); +} + +.btn-primary:active { + background: #0d47a1; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.14), 0 3px 4px rgba(0, 0, 0, 0.12), 0 1px 5px rgba(0, 0, 0, 0.2); +} + +/* Responsive design */ +@media (max-width: 640px) { + body { + padding: 0.75rem; + } + + .container { + padding: 2rem 1.25rem; + border-radius: 12px; + max-width: 100%; + } + + h1 { + font-size: 1.5rem; + } + + .message { + font-size: 0.9375rem; + } + + .checkmark { + width: 64px; + height: 64px; + } + + .success-animation { + margin: 0 auto 1.25rem; + } + + .btn { + padding: 0.6875rem 1.75rem; + font-size: 0.875rem; + width: 100%; + max-width: 280px; + } + + .redirect-info { + margin: 1.25rem 0 1rem; + } + + .progress-bar { + max-width: 100%; + } + + .session-id { + font-size: 0.75rem; + padding: 0.625rem 0.875rem; + } +} + +@media (max-width: 360px) { + .container { + padding: 1.5rem 1rem; + } + + h1 { + font-size: 1.375rem; + } + + .message { + font-size: 0.875rem; + } + + .checkmark { + width: 56px; + height: 56px; + } } \ No newline at end of file From 55d88849ca7f8b60e703b1dc55a977916c5474a6 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 12:45:53 +0400 Subject: [PATCH 27/40] feat: Enhance URL validation by supporting wildcard domains for redirects --- pages/stripe-success/main.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/pages/stripe-success/main.ts b/pages/stripe-success/main.ts index 8f27762..f2801b5 100644 --- a/pages/stripe-success/main.ts +++ b/pages/stripe-success/main.ts @@ -189,22 +189,36 @@ if (app) { if (redirectParam) { try { // Decode URL-encoded parameter - const decodedUrl = decodeURIComponent(redirectParam); + let decodedUrl = decodeURIComponent(redirectParam); + + // Auto-prepend https:// if no scheme is specified + if (!/^https?:\/\//i.test(decodedUrl)) { + decodedUrl = 'https://' + decodedUrl; + } // Validate that URL is safe (same origin or whitelisted domain) const url = new URL(decodedUrl, window.location.origin); - // Whitelist of allowed domains for redirect + // Whitelist of allowed domains for redirect (supports wildcards) + // E.g. + // http://localhost:3000/stripe-success?r=doctorina-development.web.app const allowedDomains = [ - 'doctorina.com', - 'app.doctorina.com', + '*.doctorina.com', + '*.web.app', 'localhost' ]; const hostname = url.hostname.toLowerCase(); - const isAllowed = allowedDomains.some(domain => - hostname === domain || hostname.endsWith('.' + domain) - ); + const isAllowed = allowedDomains.some(pattern => { + if (pattern.startsWith('*.')) { + // Wildcard pattern: *.example.com matches subdomain.example.com and example.com + const domain = pattern.slice(2); + return hostname === domain || hostname.endsWith('.' + domain); + } else { + // Exact match + return hostname === pattern; + } + }); if (isAllowed) { redirectUrl = decodedUrl; From e3a3b9188bf5abc33af812352a43f00f38b9141a Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 13:17:06 +0400 Subject: [PATCH 28/40] feat: Refactor translations and improve redirect URL validation logic --- pages/stripe-success/main.ts | 468 ++++++++++++++++++----------------- 1 file changed, 239 insertions(+), 229 deletions(-) diff --git a/pages/stripe-success/main.ts b/pages/stripe-success/main.ts index f2801b5..f3f5f69 100644 --- a/pages/stripe-success/main.ts +++ b/pages/stripe-success/main.ts @@ -3,163 +3,163 @@ import './style.css'; // Internationalization (i18n) - Inline translations const translations = { - en: { - // Language: English - lang: 'en', - // Page - pageTitle: 'Doctorina | Payment Successful', - // Success message - successTitle: 'Payment Successful!', - successMessage: 'Thank you for your payment. Your transaction has been completed successfully.', - // Redirect - redirectingIn: 'Redirecting in', - seconds: 'seconds...', - continueNow: 'Continue Now', - // Session - sessionId: 'Session ID:', - }, - ru: { - // Язык: Русский - lang: 'ru', - // Страница - pageTitle: 'Doctorina | Оплата успешна', - // Сообщение об успехе - successTitle: 'Оплата успешна!', - successMessage: 'Спасибо за оплату. Ваша транзакция успешно завершена.', - // Редирект - redirectingIn: 'Перенаправление через', - seconds: 'секунд...', - continueNow: 'Продолжить', - // Сессия - sessionId: 'ID сессии:', - }, - es: { - // Idioma: Español - lang: 'es', - // Página - pageTitle: 'Doctorina | Pago exitoso', - // Mensaje de éxito - successTitle: '¡Pago exitoso!', - successMessage: 'Gracias por su pago. Su transacción se ha completado con éxito.', - // Redirección - redirectingIn: 'Redirigiendo en', - seconds: 'segundos...', - continueNow: 'Continuar ahora', - // Sesión - sessionId: 'ID de sesión:', - }, - de: { - // Sprache: Deutsch - lang: 'de', - // Seite - pageTitle: 'Doctorina | Zahlung erfolgreich', - // Erfolgsmeldung - successTitle: 'Zahlung erfolgreich!', - successMessage: 'Vielen Dank für Ihre Zahlung. Ihre Transaktion wurde erfolgreich abgeschlossen.', - // Weiterleitung - redirectingIn: 'Weiterleitung in', - seconds: 'Sekunden...', - continueNow: 'Jetzt fortfahren', - // Sitzung - sessionId: 'Sitzungs-ID:', - }, - fr: { - // Langue: Français - lang: 'fr', - // Page - pageTitle: 'Doctorina | Paiement réussi', - // Message de succès - successTitle: 'Paiement réussi !', - successMessage: 'Merci pour votre paiement. Votre transaction a été complétée avec succès.', - // Redirection - redirectingIn: 'Redirection dans', - seconds: 'secondes...', - continueNow: 'Continuer maintenant', - // Session - sessionId: 'ID de session :', - }, - pt: { - // Idioma: Português - lang: 'pt', - // Página - pageTitle: 'Doctorina | Pagamento bem-sucedido', - // Mensagem de sucesso - successTitle: 'Pagamento bem-sucedido!', - successMessage: 'Obrigado pelo seu pagamento. Sua transação foi concluída com sucesso.', - // Redirecionamento - redirectingIn: 'Redirecionando em', - seconds: 'segundos...', - continueNow: 'Continuar agora', - // Sessão - sessionId: 'ID da sessão:', - }, - it: { - // Lingua: Italiano - lang: 'it', - // Pagina - pageTitle: 'Doctorina | Pagamento riuscito', - // Messaggio di successo - successTitle: 'Pagamento riuscito!', - successMessage: 'Grazie per il pagamento. La transazione è stata completata con successo.', - // Reindirizzamento - redirectingIn: 'Reindirizzamento tra', - seconds: 'secondi...', - continueNow: 'Continua ora', - // Sessione - sessionId: 'ID sessione:', - }, - zh: { - // 语言:中文 - lang: 'zh', - // 页面 - pageTitle: 'Doctorina | 支付成功', - // 成功消息 - successTitle: '支付成功!', - successMessage: '感谢您的付款。您的交易已成功完成。', - // 重定向 - redirectingIn: '将在', - seconds: '秒后重定向...', - continueNow: '立即继续', - // 会话 - sessionId: '会话ID:', - }, - ja: { - // 言語:日本語 - lang: 'ja', - // ページ - pageTitle: 'Doctorina | 支払い成功', - // 成功メッセージ - successTitle: '支払い成功!', - successMessage: 'お支払いありがとうございます。取引が正常に完了しました。', - // リダイレクト - redirectingIn: '', - seconds: '秒後にリダイレクトします...', - continueNow: '今すぐ続ける', - // セッション - sessionId: 'セッションID:', - }, - ar: { - // اللغة: العربية - lang: 'ar', - // الصفحة - pageTitle: 'Doctorina | الدفع ناجح', - // رسالة النجاح - successTitle: 'الدفع ناجح!', - successMessage: 'شكراً لك على الدفع. تمت عملية الدفع بنجاح.', - // إعادة التوجيه - redirectingIn: 'إعادة التوجيه خلال', - seconds: 'ثواني...', - continueNow: 'المتابعة الآن', - // الجلسة - sessionId: 'معرّف الجلسة:', - }, + en: { + // Language: English + lang: 'en', + // Page + pageTitle: 'Doctorina | Payment Successful', + // Success message + successTitle: 'Payment Successful!', + successMessage: 'Thank you for your payment. Your transaction has been completed successfully.', + // Redirect + redirectingIn: 'Redirecting in', + seconds: 'seconds...', + continueNow: 'Continue Now', + // Session + sessionId: 'Session ID:', + }, + ru: { + // Язык: Русский + lang: 'ru', + // Страница + pageTitle: 'Doctorina | Оплата успешна', + // Сообщение об успехе + successTitle: 'Оплата успешна!', + successMessage: 'Спасибо за оплату. Ваша транзакция успешно завершена.', + // Редирект + redirectingIn: 'Перенаправление через', + seconds: 'секунд...', + continueNow: 'Продолжить', + // Сессия + sessionId: 'ID сессии:', + }, + es: { + // Idioma: Español + lang: 'es', + // Página + pageTitle: 'Doctorina | Pago exitoso', + // Mensaje de éxito + successTitle: '¡Pago exitoso!', + successMessage: 'Gracias por su pago. Su transacción se ha completado con éxito.', + // Redirección + redirectingIn: 'Redirigiendo en', + seconds: 'segundos...', + continueNow: 'Continuar ahora', + // Sesión + sessionId: 'ID de sesión:', + }, + de: { + // Sprache: Deutsch + lang: 'de', + // Seite + pageTitle: 'Doctorina | Zahlung erfolgreich', + // Erfolgsmeldung + successTitle: 'Zahlung erfolgreich!', + successMessage: 'Vielen Dank für Ihre Zahlung. Ihre Transaktion wurde erfolgreich abgeschlossen.', + // Weiterleitung + redirectingIn: 'Weiterleitung in', + seconds: 'Sekunden...', + continueNow: 'Jetzt fortfahren', + // Sitzung + sessionId: 'Sitzungs-ID:', + }, + fr: { + // Langue: Français + lang: 'fr', + // Page + pageTitle: 'Doctorina | Paiement réussi', + // Message de succès + successTitle: 'Paiement réussi !', + successMessage: 'Merci pour votre paiement. Votre transaction a été complétée avec succès.', + // Redirection + redirectingIn: 'Redirection dans', + seconds: 'secondes...', + continueNow: 'Continuer maintenant', + // Session + sessionId: 'ID de session :', + }, + pt: { + // Idioma: Português + lang: 'pt', + // Página + pageTitle: 'Doctorina | Pagamento bem-sucedido', + // Mensagem de sucesso + successTitle: 'Pagamento bem-sucedido!', + successMessage: 'Obrigado pelo seu pagamento. Sua transação foi concluída com sucesso.', + // Redirecionamento + redirectingIn: 'Redirecionando em', + seconds: 'segundos...', + continueNow: 'Continuar agora', + // Sessão + sessionId: 'ID da sessão:', + }, + it: { + // Lingua: Italiano + lang: 'it', + // Pagina + pageTitle: 'Doctorina | Pagamento riuscito', + // Messaggio di successo + successTitle: 'Pagamento riuscito!', + successMessage: 'Grazie per il pagamento. La transazione è stata completata con successo.', + // Reindirizzamento + redirectingIn: 'Reindirizzamento tra', + seconds: 'secondi...', + continueNow: 'Continua ora', + // Sessione + sessionId: 'ID sessione:', + }, + zh: { + // 语言:中文 + lang: 'zh', + // 页面 + pageTitle: 'Doctorina | 支付成功', + // 成功消息 + successTitle: '支付成功!', + successMessage: '感谢您的付款。您的交易已成功完成。', + // 重定向 + redirectingIn: '将在', + seconds: '秒后重定向...', + continueNow: '立即继续', + // 会话 + sessionId: '会话ID:', + }, + ja: { + // 言語:日本語 + lang: 'ja', + // ページ + pageTitle: 'Doctorina | 支払い成功', + // 成功メッセージ + successTitle: '支払い成功!', + successMessage: 'お支払いありがとうございます。取引が正常に完了しました。', + // リダイレクト + redirectingIn: '', + seconds: '秒後にリダイレクトします...', + continueNow: '今すぐ続ける', + // セッション + sessionId: 'セッションID:', + }, + ar: { + // اللغة: العربية + lang: 'ar', + // الصفحة + pageTitle: 'Doctorina | الدفع ناجح', + // رسالة النجاح + successTitle: 'الدفع ناجح!', + successMessage: 'شكراً لك على الدفع. تمت عملية الدفع بنجاح.', + // إعادة التوجيه + redirectingIn: 'إعادة التوجيه خلال', + seconds: 'ثواني...', + continueNow: 'المتابعة الآن', + // الجلسة + sessionId: 'معرّف الجلسة:', + }, }; // Detect browser language with fallback to English function detectLanguage(): keyof typeof translations { - const browserLang = navigator.language.split('-')[0].toLowerCase(); - const supportedLanguages: (keyof typeof translations)[] = ['en', 'ru', 'es', 'de', 'fr', 'pt', 'it', 'zh', 'ja', 'ar']; - return supportedLanguages.includes(browserLang as any) ? (browserLang as keyof typeof translations) : 'en'; + const browserLang = navigator.language.split('-')[0].toLowerCase(); + const supportedLanguages: (keyof typeof translations)[] = ['en', 'ru', 'es', 'de', 'fr', 'pt', 'it', 'zh', 'ja', 'ar']; + return supportedLanguages.includes(browserLang as any) ? (browserLang as keyof typeof translations) : 'en'; } // Get current language and translations @@ -171,66 +171,77 @@ document.documentElement.lang = currentLang; // Set text direction for RTL languages if (currentLang === 'ar') { - document.documentElement.dir = 'rtl'; + document.documentElement.dir = 'rtl'; } initPage(t.pageTitle); const app = document.getElementById('app'); if (app) { - // Get session_id from URL if present - const urlParams = new URLSearchParams(window.location.search); - const sessionId = urlParams.get('s') || urlParams.get('session_id'); + // Get session from URL if present + const urlParams = new URLSearchParams(window.location.search); + // Checkout Stripe Session ID, stripe add it as a `{CHECKOUT_SESSION_ID}` + const checkoutId = urlParams.get('c') || urlParams.get('checkout_id'); + // Purchase ID, e.g. for subscriptions or one-time payments + const purchaseId = urlParams.get('p') || urlParams.get('purchase_id'); + // e.g. "subscription" or "one-time" + const type = urlParams.get('t') || urlParams.get('type'); + console.log( + `Stripe Success Page\n`, + `Type: ${type || 'N/A'}\n`, + `Checkout ID: ${checkoutId || 'N/A'}\n`, + `Purchase ID: ${purchaseId || 'N/A'}` + ); - // Safely decode and validate redirect URL - const redirectParam = urlParams.get('r') || urlParams.get('redirect'); - let redirectUrl = 'https://app.doctorina.com'; + // Safely decode and validate redirect URL + const redirectParam = urlParams.get('r') || urlParams.get('redirect'); + let redirectUrl = 'https://app.doctorina.com'; - if (redirectParam) { - try { - // Decode URL-encoded parameter - let decodedUrl = decodeURIComponent(redirectParam); + if (redirectParam) { + try { + // Decode URL-encoded parameter + let decodedUrl = decodeURIComponent(redirectParam); - // Auto-prepend https:// if no scheme is specified - if (!/^https?:\/\//i.test(decodedUrl)) { - decodedUrl = 'https://' + decodedUrl; - } + // Auto-prepend https:// if no scheme is specified + if (!/^https?:\/\//i.test(decodedUrl)) { + decodedUrl = 'https://' + decodedUrl; + } - // Validate that URL is safe (same origin or whitelisted domain) - const url = new URL(decodedUrl, window.location.origin); + // Validate that URL is safe (same origin or whitelisted domain) + const url = new URL(decodedUrl, window.location.origin); - // Whitelist of allowed domains for redirect (supports wildcards) - // E.g. - // http://localhost:3000/stripe-success?r=doctorina-development.web.app - const allowedDomains = [ - '*.doctorina.com', - '*.web.app', - 'localhost' - ]; + // Whitelist of allowed domains for redirect (supports wildcards) + // E.g. + // http://localhost:3000/stripe-success?r=doctorina-development.web.app + const allowedDomains = [ + '*.doctorina.com', + '*.web.app', + 'localhost' + ]; - const hostname = url.hostname.toLowerCase(); - const isAllowed = allowedDomains.some(pattern => { - if (pattern.startsWith('*.')) { - // Wildcard pattern: *.example.com matches subdomain.example.com and example.com - const domain = pattern.slice(2); - return hostname === domain || hostname.endsWith('.' + domain); - } else { - // Exact match - return hostname === pattern; - } - }); + const hostname = url.hostname.toLowerCase(); + const isAllowed = allowedDomains.some(pattern => { + if (pattern.startsWith('*.')) { + // Wildcard pattern: *.example.com matches subdomain.example.com and example.com + const domain = pattern.slice(2); + return hostname === domain || hostname.endsWith('.' + domain); + } else { + // Exact match + return hostname === pattern; + } + }); - if (isAllowed) { - redirectUrl = decodedUrl; - } else { - console.warn('Redirect URL not in whitelist:', hostname); - } - } catch (error) { - console.error('Invalid redirect URL:', error); + if (isAllowed) { + redirectUrl = decodedUrl; + } else { + console.warn('Redirect URL not in whitelist:', hostname); + } + } catch (error) { + console.error('Invalid redirect URL:', error); + } } - } - app.innerHTML = ` + app.innerHTML = `
@@ -240,7 +251,6 @@ if (app) {

${t.successTitle}

${t.successMessage}

- ${sessionId ? `
${t.sessionId} ${sessionId}
` : ''}

${t.redirectingIn} 15 ${t.seconds}

@@ -253,39 +263,39 @@ if (app) {
`; - // Auto-redirect with countdown - let countdown = 15; - const countdownElement = document.getElementById('countdown'); - const progressElement = document.getElementById('progress'); - const redirectBtn = document.getElementById('redirect-btn') as HTMLAnchorElement | null; - - // Store redirect URL for the button - if (redirectBtn) { - redirectBtn.href = redirectUrl; - } + // Auto-redirect with countdown + let countdown = 15; + const countdownElement = document.getElementById('countdown'); + const progressElement = document.getElementById('progress'); + const redirectBtn = document.getElementById('redirect-btn') as HTMLAnchorElement | null; - const countdownInterval = setInterval(() => { - countdown--; - if (countdownElement) { - countdownElement.textContent = countdown.toString(); + // Store redirect URL for the button + if (redirectBtn) { + redirectBtn.href = redirectUrl; } - // Update progress bar - if (progressElement) { - const progress = ((15 - countdown) / 15) * 100; - progressElement.style.width = `${progress}%`; - } + const countdownInterval = setInterval(() => { + countdown--; + if (countdownElement) { + countdownElement.textContent = countdown.toString(); + } - if (countdown <= 0) { - clearInterval(countdownInterval); - window.location.href = redirectUrl; - } - }, 1000); + // Update progress bar + if (progressElement) { + const progress = ((15 - countdown) / 15) * 100; + progressElement.style.width = `${progress}%`; + } - // Cancel auto-redirect if user clicks the button - if (redirectBtn) { - redirectBtn.addEventListener('click', () => { - clearInterval(countdownInterval); - }); - } + if (countdown <= 0) { + clearInterval(countdownInterval); + window.location.href = redirectUrl; + } + }, 1000); + + // Cancel auto-redirect if user clicks the button + if (redirectBtn) { + redirectBtn.addEventListener('click', () => { + clearInterval(countdownInterval); + }); + } } From 0874885705c5a29c1d727b1c0590203c97bf9dcd Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 14:13:46 +0400 Subject: [PATCH 29/40] feat(stripe-success): extract and log Stripe success parameters - Added utility functions `extractStripeSuccessParams` and `formatStripeSuccessLog` to handle extraction and logging of parameters from the Stripe success page URL. - Updated `main.ts` to utilize the new utility functions for cleaner code and improved readability. - Introduced comprehensive tests for parameter extraction and logging in `utils.test.ts`. - Created a new configuration file for Vitest to facilitate testing in a jsdom environment. --- package-lock.json | 1028 ++++++++++++++++++++++- package.json | 10 +- pages/ads/main.ts | 1228 ++++++++++++++-------------- pages/stripe-success/main.ts | 19 +- pages/stripe-success/utils.test.ts | 230 ++++++ pages/stripe-success/utils.ts | 61 ++ vitest.config.ts | 14 + 7 files changed, 1960 insertions(+), 630 deletions(-) create mode 100644 pages/stripe-success/utils.test.ts create mode 100644 pages/stripe-success/utils.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 9c27624..2151e6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,24 @@ "license": "MIT", "devDependencies": { "@types/node": "25.0.3", + "@vitest/ui": "^4.0.16", "firebase-tools": "15.1.0", + "jsdom": "^27.4.0", "rollup-plugin-visualizer": "6.0.5", "sharp": "0.34.5", "terser": "5.44.1", "typescript": "5.9.3", - "vite": "7.3.0" + "vite": "7.3.0", + "vitest": "^4.0.16" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "license": "MIT" + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", @@ -93,6 +103,61 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -128,6 +193,141 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.23.tgz", + "integrity": "sha512-YEmgyklR6l/oKUltidNVYdjSmLSW88vMsKx0pmiS3r71s8ZZRpd8A0Yf0U+6p/RzElmMnPBv27hNWjDQMSZRtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", @@ -610,6 +810,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", + "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@google-cloud/cloud-sql-connector": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@google-cloud/cloud-sql-connector/-/cloud-sql-connector-1.8.5.tgz", @@ -2466,6 +2684,13 @@ "node": ">=12" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2872,6 +3097,13 @@ "text-hex": "1.0.x" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -2917,6 +3149,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2948,6 +3198,139 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.16.tgz", + "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.16", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.16" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -3232,6 +3615,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -3364,6 +3757,16 @@ "node": ">=10.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -3772,6 +4175,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4499,6 +4912,46 @@ "node": ">=8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csv-parse": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", @@ -4516,6 +4969,57 @@ "node": ">= 14" } }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -4526,6 +5030,13 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-equal-in-any-order": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/deep-equal-in-any-order/-/deep-equal-in-any-order-2.1.0.tgz", @@ -4803,6 +5314,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4855,6 +5379,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4999,6 +5530,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5169,6 +5710,16 @@ "node": ">= 0.8" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -5309,6 +5860,13 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/filesize": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.4.0.tgz", @@ -5441,6 +5999,13 @@ "node": ">=20.0.0 || >=22.0.0 || >=24.0.0" } }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -6222,6 +6787,19 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6618,6 +7196,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -6788,6 +7373,118 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -7176,6 +7873,16 @@ "through2": "^2.0.1" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -7300,6 +8007,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -7601,6 +8315,16 @@ "node": ">= 0.8" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -7945,6 +8669,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8278,6 +9013,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -8722,6 +9464,16 @@ "dev": true, "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pupa": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", @@ -9258,6 +10010,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -9506,6 +10271,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9519,6 +10291,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -9701,6 +10488,13 @@ "node": "*" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9711,6 +10505,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-chain": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", @@ -10019,6 +10820,13 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", @@ -10307,6 +11115,23 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -10355,6 +11180,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -10398,6 +11253,29 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/toxic": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toxic/-/toxic-1.0.1.tgz", @@ -10879,6 +11757,110 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -10913,6 +11895,16 @@ "dev": true, "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -10940,6 +11932,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", @@ -11170,6 +12179,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index fab6388..152494a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "build": "vite build", "build:analyze": "ANALYZE=true vite build", "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", "type-check": "tsc --noEmit", "lint": "eslint . --ext .ts,.tsx,.js,.jsx", "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix", @@ -25,11 +28,14 @@ "license": "MIT", "devDependencies": { "@types/node": "25.0.3", + "@vitest/ui": "^4.0.16", "firebase-tools": "15.1.0", + "jsdom": "^27.4.0", "rollup-plugin-visualizer": "6.0.5", "sharp": "0.34.5", "terser": "5.44.1", "typescript": "5.9.3", - "vite": "7.3.0" + "vite": "7.3.0", + "vitest": "^4.0.16" } -} \ No newline at end of file +} diff --git a/pages/ads/main.ts b/pages/ads/main.ts index d9eba8a..5b8fdfe 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -9,85 +9,85 @@ import './style.css'; // Internationalization (i18n) - Inline translations const translations = { - en: { - // Language: English - lang: 'en', - // Page - pageTitle: 'Watch Ad - Doctorina', - // Progress - remaining: 'Remaining:', - // Warnings - watchToEnd: 'Please watch the video to the end without skipping', - doNotSkip: 'Do not skip ahead! Video will restart.', - keyboardDisabled: 'Keyboard controls are disabled', - // Loading - loadingVideo: 'Loading video...', - videoLoadError: 'Failed to load video. Please refresh the page.', - // Tooltips & Aria - videoPlayerLabel: 'Advertisement video player', - progressBarLabel: 'Video progress', - }, - ru: { - // Язык: Русский - lang: 'ru', - // Страница - pageTitle: 'Просмотр рекламы - Doctorina', - // Прогресс - remaining: 'Осталось:', - // Предупреждения - watchToEnd: 'Пожалуйста, посмотрите видео до конца без пропусков', - doNotSkip: 'Не перематывайте! Видео начнется сначала.', - keyboardDisabled: 'Управление с клавиатуры отключено', - // Загрузка - loadingVideo: 'Загрузка видео...', - videoLoadError: 'Не удалось загрузить видео. Пожалуйста, обновите страницу.', - // Подсказки и Aria - videoPlayerLabel: 'Видеоплеер рекламы', - progressBarLabel: 'Прогресс видео', - }, - es: { - // Idioma: Español - lang: 'es', - // Página - pageTitle: 'Ver anuncio - Doctorina', - // Progreso - remaining: 'Restante:', - // Advertencias - watchToEnd: 'Por favor, mira el vídeo hasta el final sin saltarlo', - doNotSkip: '¡No adelantes! El vídeo se reiniciará.', - keyboardDisabled: 'Los controles del teclado están deshabilitados', - // Carga - loadingVideo: 'Cargando vídeo...', - videoLoadError: 'Error al cargar el vídeo. Por favor, actualiza la página.', - // Tooltips y Aria - videoPlayerLabel: 'Reproductor de vídeo publicitario', - progressBarLabel: 'Progreso del vídeo', - }, - de: { - // Sprache: Deutsch - lang: 'de', - // Seite - pageTitle: 'Werbung ansehen - Doctorina', - // Fortschritt - remaining: 'Verbleibend:', - // Warnungen - watchToEnd: 'Bitte schauen Sie das Video bis zum Ende ohne zu überspringen', - doNotSkip: 'Nicht vorspulen! Video wird neu gestartet.', - keyboardDisabled: 'Tastatursteuerung ist deaktiviert', - // Laden - loadingVideo: 'Video wird geladen...', - videoLoadError: 'Video konnte nicht geladen werden. Bitte aktualisieren Sie die Seite.', - // Tooltips und Aria - videoPlayerLabel: 'Werbevideoplayer', - progressBarLabel: 'Videofortschritt', - }, + en: { + // Language: English + lang: 'en', + // Page + pageTitle: 'Watch Ad - Doctorina', + // Progress + remaining: 'Remaining:', + // Warnings + watchToEnd: 'Please watch the video to the end without skipping', + doNotSkip: 'Do not skip ahead! Video will restart.', + keyboardDisabled: 'Keyboard controls are disabled', + // Loading + loadingVideo: 'Loading video...', + videoLoadError: 'Failed to load video. Please refresh the page.', + // Tooltips & Aria + videoPlayerLabel: 'Advertisement video player', + progressBarLabel: 'Video progress', + }, + ru: { + // Язык: Русский + lang: 'ru', + // Страница + pageTitle: 'Просмотр рекламы - Doctorina', + // Прогресс + remaining: 'Осталось:', + // Предупреждения + watchToEnd: 'Пожалуйста, посмотрите видео до конца без пропусков', + doNotSkip: 'Не перематывайте! Видео начнется сначала.', + keyboardDisabled: 'Управление с клавиатуры отключено', + // Загрузка + loadingVideo: 'Загрузка видео...', + videoLoadError: 'Не удалось загрузить видео. Пожалуйста, обновите страницу.', + // Подсказки и Aria + videoPlayerLabel: 'Видеоплеер рекламы', + progressBarLabel: 'Прогресс видео', + }, + es: { + // Idioma: Español + lang: 'es', + // Página + pageTitle: 'Ver anuncio - Doctorina', + // Progreso + remaining: 'Restante:', + // Advertencias + watchToEnd: 'Por favor, mira el vídeo hasta el final sin saltarlo', + doNotSkip: '¡No adelantes! El vídeo se reiniciará.', + keyboardDisabled: 'Los controles del teclado están deshabilitados', + // Carga + loadingVideo: 'Cargando vídeo...', + videoLoadError: 'Error al cargar el vídeo. Por favor, actualiza la página.', + // Tooltips y Aria + videoPlayerLabel: 'Reproductor de vídeo publicitario', + progressBarLabel: 'Progreso del vídeo', + }, + de: { + // Sprache: Deutsch + lang: 'de', + // Seite + pageTitle: 'Werbung ansehen - Doctorina', + // Fortschritt + remaining: 'Verbleibend:', + // Warnungen + watchToEnd: 'Bitte schauen Sie das Video bis zum Ende ohne zu überspringen', + doNotSkip: 'Nicht vorspulen! Video wird neu gestartet.', + keyboardDisabled: 'Tastatursteuerung ist deaktiviert', + // Laden + loadingVideo: 'Video wird geladen...', + videoLoadError: 'Video konnte nicht geladen werden. Bitte aktualisieren Sie die Seite.', + // Tooltips und Aria + videoPlayerLabel: 'Werbevideoplayer', + progressBarLabel: 'Videofortschritt', + }, }; // Detect browser language with fallback to English function detectLanguage(): keyof typeof translations { - const browserLang = navigator.language.split('-')[0].toLowerCase(); - const supportedLanguages: (keyof typeof translations)[] = ['en', 'ru', 'es', 'de']; - return supportedLanguages.includes(browserLang as any) ? (browserLang as keyof typeof translations) : 'en'; + const browserLang = navigator.language.split('-')[0].toLowerCase(); + const supportedLanguages: (keyof typeof translations)[] = ['en', 'ru', 'es', 'de']; + return supportedLanguages.includes(browserLang as any) ? (browserLang as keyof typeof translations) : 'en'; } // Get current language and translations @@ -101,50 +101,50 @@ initPage(t.pageTitle); // Types for YouTube IFrame API interface YTPlayer { - playVideo: () => void; - pauseVideo: () => void; - seekTo: (seconds: number, allowSeekAhead: boolean) => void; - getCurrentTime: () => number; - getDuration: () => number; - getPlayerState: () => number; - unMute: () => void; - isMuted: () => boolean; - setVolume: (volume: number) => void; - getVolume: () => number; + playVideo: () => void; + pauseVideo: () => void; + seekTo: (seconds: number, allowSeekAhead: boolean) => void; + getCurrentTime: () => number; + getDuration: () => number; + getPlayerState: () => number; + unMute: () => void; + isMuted: () => boolean; + setVolume: (volume: number) => void; + getVolume: () => number; } interface YTPlayerClass { - new ( - elementId: string, - config: { - videoId: string; - playerVars?: Record; - events?: { - onReady?: (event: { target: YTPlayer }) => void; - onStateChange?: (event: { target: YTPlayer; data: number }) => void; - onError?: (event: { target: YTPlayer; data: number }) => void; - }; - } - ): YTPlayer; + new( + elementId: string, + config: { + videoId: string; + playerVars?: Record; + events?: { + onReady?: (event: { target: YTPlayer }) => void; + onStateChange?: (event: { target: YTPlayer; data: number }) => void; + onError?: (event: { target: YTPlayer; data: number }) => void; + }; + } + ): YTPlayer; } interface YTNamespace { - Player: YTPlayerClass; - PlayerState: { - UNSTARTED: number; - ENDED: number; - PLAYING: number; - PAUSED: number; - BUFFERING: number; - CUED: number; - }; + Player: YTPlayerClass; + PlayerState: { + UNSTARTED: number; + ENDED: number; + PLAYING: number; + PAUSED: number; + BUFFERING: number; + CUED: number; + }; } declare global { - interface Window { - onYouTubeIframeAPIReady: () => void; - YT: YTNamespace; - } + interface Window { + onYouTubeIframeAPIReady: () => void; + YT: YTNamespace; + } } // Configuration from ENV and URL params @@ -153,31 +153,31 @@ const VIDEO_ID = urlParams.get('v') || urlParams.get('video') || import.meta.env // Normalize and validate callback URL function normalizeCallbackUrl(url: string | null): string { - if (!url) { - return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; - } - - // Decode URL if encoded - try { - url = decodeURIComponent(url); - } catch (e) { - console.error('Failed to decode callback URL:', e); - } - - // Validate URL starts with http:// or https:// - if (!url.startsWith('http://') && !url.startsWith('https://')) { - console.error('Invalid callback URL (must start with http:// or https://):', url); - return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; - } - - // Additional validation: check if it's a valid URL - try { - new URL(url); - return url; - } catch (e) { - console.error('Invalid callback URL format:', e); - return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; - } + if (!url) { + return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; + } + + // Decode URL if encoded + try { + url = decodeURIComponent(url); + } catch (e) { + console.error('Failed to decode callback URL:', e); + } + + // Validate URL starts with http:// or https:// + if (!url.startsWith('http://') && !url.startsWith('https://')) { + console.error('Invalid callback URL (must start with http:// or https://):', url); + return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; + } + + // Additional validation: check if it's a valid URL + try { + new URL(url); + return url; + } catch (e) { + console.error('Invalid callback URL format:', e); + return import.meta.env.VITE_CALLBACK_URL || 'https://live.api.doctorina.com/v1/chats/events/ads-completed'; + } } const CALLBACK_URL = normalizeCallbackUrl(urlParams.get('c') || urlParams.get('callback')); @@ -217,47 +217,47 @@ const reachedMilestones = new Set(); // PostMessage helper for iframe/WebView communication function sendPostMessage(event: string, data: Record = {}) { - const message = { - event, - timestamp: new Date().toISOString(), - ...data, - }; - - // Send to parent window (for iframe) - if (window.parent && window.parent !== window) { - window.parent.postMessage(message, '*'); - } - - // Send to opener (for popup/new tab) - if (window.opener) { - window.opener.postMessage(message, '*'); - } - - // For WebView - also post to current window - window.postMessage(message, window.location.origin); - - console.log('PostMessage:', event, data); + const message = { + event, + timestamp: new Date().toISOString(), + ...data, + }; + + // Send to parent window (for iframe) + if (window.parent && window.parent !== window) { + window.parent.postMessage(message, '*'); + } + + // Send to opener (for popup/new tab) + if (window.opener) { + window.opener.postMessage(message, '*'); + } + + // For WebView - also post to current window + window.postMessage(message, window.location.origin); + + console.log('PostMessage:', event, data); } // Metadata collection const metadata = { - userAgent: navigator.userAgent, - platform: (navigator as any).userAgentData?.platform || navigator.platform || 'unknown', - language: navigator.language, - screenResolution: `${window.screen.width}x${window.screen.height}`, - viewportSize: `${window.innerWidth}x${window.innerHeight}`, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - referrer: document.referrer || 'direct', - deviceMemory: (navigator as any).deviceMemory || 'unknown', - hardwareConcurrency: navigator.hardwareConcurrency || 'unknown', + userAgent: navigator.userAgent, + platform: (navigator as any).userAgentData?.platform || navigator.platform || 'unknown', + language: navigator.language, + screenResolution: `${window.screen.width}x${window.screen.height}`, + viewportSize: `${window.innerWidth}x${window.innerHeight}`, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + referrer: document.referrer || 'direct', + deviceMemory: (navigator as any).deviceMemory || 'unknown', + hardwareConcurrency: navigator.hardwareConcurrency || 'unknown', }; // Send page loaded event sendPostMessage('page-loaded', { - video_id: VIDEO_ID, - session: sessionId, - referrer, - language: currentLang, + video_id: VIDEO_ID, + session: sessionId, + referrer, + language: currentLang, }); // Initialize the page @@ -300,554 +300,554 @@ firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); // Initialize player when API is ready window.onYouTubeIframeAPIReady = () => { - player = new window.YT.Player('player', { - videoId: VIDEO_ID, - playerVars: { - autoplay: 1, - mute: 1, // Mute for autoplay to work in browsers - controls: 0, // Hide controls - disablekb: 1, // Disable keyboard controls from YouTube - fs: 0, // Disable fullscreen - modestbranding: 1, - rel: 0, - showinfo: 0, - iv_load_policy: 3, - }, - events: { - onReady: onPlayerReady, - onStateChange: onPlayerStateChange, - onError: onPlayerError, - }, - }); + player = new window.YT.Player('player', { + videoId: VIDEO_ID, + playerVars: { + autoplay: 1, + mute: 1, // Mute for autoplay to work in browsers + controls: 0, // Hide controls + disablekb: 1, // Disable keyboard controls from YouTube + fs: 0, // Disable fullscreen + modestbranding: 1, + rel: 0, + showinfo: 0, + iv_load_policy: 3, + }, + events: { + onReady: onPlayerReady, + onStateChange: onPlayerStateChange, + onError: onPlayerError, + }, + }); }; function onPlayerReady(event: { target: YTPlayer }) { - // Hide loading screen - const loadingScreen = document.getElementById('loadingScreen'); - if (loadingScreen) { - loadingScreen.style.opacity = '0'; - setTimeout(() => { - loadingScreen.style.display = 'none'; - }, 300); - } + // Hide loading screen + const loadingScreen = document.getElementById('loadingScreen'); + if (loadingScreen) { + loadingScreen.style.opacity = '0'; + setTimeout(() => { + loadingScreen.style.display = 'none'; + }, 300); + } - // Send player ready event - sendPostMessage('player-ready', { - video_id: VIDEO_ID, - duration: event.target.getDuration(), - }); + // Send player ready event + sendPostMessage('player-ready', { + video_id: VIDEO_ID, + duration: event.target.getDuration(), + }); - // Start playing (muted for reliable autoplay) - event.target.playVideo(); - startProgressTracking(); + // Start playing (muted for reliable autoplay) + event.target.playVideo(); + startProgressTracking(); - // Ensure video actually starts playing with retry mechanism - const ensurePlayback = (retryCount = 0, maxRetries = 3) => { - setTimeout(() => { - if (!player || isVideoCompleted) return; + // Ensure video actually starts playing with retry mechanism + const ensurePlayback = (retryCount = 0, maxRetries = 3) => { + setTimeout(() => { + if (!player || isVideoCompleted) return; - const { YT } = window; - const currentState = player.getPlayerState(); + const { YT } = window; + const currentState = player.getPlayerState(); - // If video is not playing, try to start it - if (currentState !== YT.PlayerState.PLAYING && currentState !== YT.PlayerState.BUFFERING) { - console.log(`Retry autoplay attempt ${retryCount + 1}/${maxRetries}`); - player.playVideo(); + // If video is not playing, try to start it + if (currentState !== YT.PlayerState.PLAYING && currentState !== YT.PlayerState.BUFFERING) { + console.log(`Retry autoplay attempt ${retryCount + 1}/${maxRetries}`); + player.playVideo(); - // Retry if we haven't exceeded max retries - if (retryCount < maxRetries - 1) { - ensurePlayback(retryCount + 1, maxRetries); - } - } - }, 500 + retryCount * 500); // Increasing delay: 500ms, 1000ms, 1500ms - }; + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries - 1) { + ensurePlayback(retryCount + 1, maxRetries); + } + } + }, 500 + retryCount * 500); // Increasing delay: 500ms, 1000ms, 1500ms + }; - ensurePlayback(); + ensurePlayback(); } function onPlayerStateChange(event: { target: YTPlayer; data: number }) { - const { YT } = window; - - if (event.data === YT.PlayerState.PAUSED) { - pauseCount++; - lastPauseTime = Date.now(); - sendPostMessage('video-paused', { - current_time: event.target.getCurrentTime(), - pause_count: pauseCount, - }); - } + const { YT } = window; - if (event.data === YT.PlayerState.PLAYING) { - // Calculate pause duration if coming from pause - if (lastPauseTime > 0) { - totalPauseDuration += (Date.now() - lastPauseTime) / 1000; - lastPauseTime = 0; + if (event.data === YT.PlayerState.PAUSED) { + pauseCount++; + lastPauseTime = Date.now(); + sendPostMessage('video-paused', { + current_time: event.target.getCurrentTime(), + pause_count: pauseCount, + }); } - // End buffering tracking if was buffering - if (lastBufferingTime > 0) { - totalBufferingDuration += (Date.now() - lastBufferingTime) / 1000; - lastBufferingTime = 0; + + if (event.data === YT.PlayerState.PLAYING) { + // Calculate pause duration if coming from pause + if (lastPauseTime > 0) { + totalPauseDuration += (Date.now() - lastPauseTime) / 1000; + lastPauseTime = 0; + } + // End buffering tracking if was buffering + if (lastBufferingTime > 0) { + totalBufferingDuration += (Date.now() - lastBufferingTime) / 1000; + lastBufferingTime = 0; + } + sendPostMessage('video-playing', { + current_time: event.target.getCurrentTime(), + }); } - sendPostMessage('video-playing', { - current_time: event.target.getCurrentTime(), - }); - } - - if (event.data === YT.PlayerState.BUFFERING) { - bufferingEvents++; - lastBufferingTime = Date.now(); - sendPostMessage('video-buffering', { - current_time: event.target.getCurrentTime(), - buffering_events: bufferingEvents, - }); - } - if (event.data === YT.PlayerState.ENDED && !isVideoCompleted) { - isVideoCompleted = true; - sendPostMessage('video-ended', { - duration: event.target.getDuration(), - watch_duration: (Date.now() - videoStartTime) / 1000, - }); - onVideoComplete(); - } + if (event.data === YT.PlayerState.BUFFERING) { + bufferingEvents++; + lastBufferingTime = Date.now(); + sendPostMessage('video-buffering', { + current_time: event.target.getCurrentTime(), + buffering_events: bufferingEvents, + }); + } + + if (event.data === YT.PlayerState.ENDED && !isVideoCompleted) { + isVideoCompleted = true; + sendPostMessage('video-ended', { + duration: event.target.getDuration(), + watch_duration: (Date.now() - videoStartTime) / 1000, + }); + onVideoComplete(); + } } function onPlayerError(event: { target: YTPlayer; data: number }) { - playerErrorCount++; - console.error('YouTube Player Error:', event.data); + playerErrorCount++; + console.error('YouTube Player Error:', event.data); - sendPostMessage('video-error', { - error_code: event.data, - player_error_count: playerErrorCount, - }); + sendPostMessage('video-error', { + error_code: event.data, + player_error_count: playerErrorCount, + }); - const loadingScreen = document.getElementById('loadingScreen'); - if (loadingScreen) { - loadingScreen.innerHTML = ` + const loadingScreen = document.getElementById('loadingScreen'); + if (loadingScreen) { + loadingScreen.innerHTML = `
⚠️

${t.videoLoadError}

`; - } + } } function startProgressTracking() { - setInterval(() => { - if (!player || isVideoCompleted) return; + setInterval(() => { + if (!player || isVideoCompleted) return; + + const currentTime = player.getCurrentTime(); + const duration = player.getDuration(); + + // Detect seek attempts (forward seeking) + if (currentTime > maxWatchedTime + 1) { + seekAttempts++; + sendPostMessage('seek-attempt-blocked', { + attempted_time: currentTime, + max_watched_time: maxWatchedTime, + seek_attempts: seekAttempts, + }); + showWarning(t.doNotSkip); + player.seekTo(maxWatchedTime, true); + return; + } - const currentTime = player.getCurrentTime(); - const duration = player.getDuration(); + // Update max watched time + if (currentTime > maxWatchedTime) { + maxWatchedTime = currentTime; + } - // Detect seek attempts (forward seeking) - if (currentTime > maxWatchedTime + 1) { - seekAttempts++; - sendPostMessage('seek-attempt-blocked', { - attempted_time: currentTime, - max_watched_time: maxWatchedTime, - seek_attempts: seekAttempts, - }); - showWarning(t.doNotSkip); - player.seekTo(maxWatchedTime, true); - return; - } + // Update UI + updateProgress(currentTime, duration); - // Update max watched time - if (currentTime > maxWatchedTime) { - maxWatchedTime = currentTime; - } + // Check if video is completed (98% threshold to account for buffering) + if (currentTime / duration > 0.98 && !isVideoCompleted) { + isVideoCompleted = true; + onVideoComplete(); + } + }, 100); +} + +function updateProgress(currentTime: number, duration: number) { + const progressFill = document.getElementById('progressFill'); + const countdown = document.getElementById('countdown'); + const progressPercentage = document.getElementById('progressPercentage'); + const progressBar = document.querySelector('.progress-bar'); + + if (!progressFill || !countdown) return; - // Update UI - updateProgress(currentTime, duration); + const percentage = (currentTime / duration) * 100; + progressFill.style.width = `${percentage}%`; - // Check if video is completed (98% threshold to account for buffering) - if (currentTime / duration > 0.98 && !isVideoCompleted) { - isVideoCompleted = true; - onVideoComplete(); + // Update ARIA attributes + if (progressBar) { + progressBar.setAttribute('aria-valuenow', Math.round(percentage).toString()); } - }, 100); -} -function updateProgress(currentTime: number, duration: number) { - const progressFill = document.getElementById('progressFill'); - const countdown = document.getElementById('countdown'); - const progressPercentage = document.getElementById('progressPercentage'); - const progressBar = document.querySelector('.progress-bar'); - - if (!progressFill || !countdown) return; - - const percentage = (currentTime / duration) * 100; - progressFill.style.width = `${percentage}%`; - - // Update ARIA attributes - if (progressBar) { - progressBar.setAttribute('aria-valuenow', Math.round(percentage).toString()); - } - - const remaining = duration - currentTime; - const minutes = Math.floor(remaining / 60); - const seconds = Math.floor(remaining % 60); - countdown.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; - - // Update percentage display - if (progressPercentage) { - progressPercentage.textContent = `${Math.round(percentage)}%`; - } - - // Track analytics milestones - const progress = currentTime / duration; - milestones.forEach((milestone) => { - if (progress >= milestone && !reachedMilestones.has(milestone)) { - reachedMilestones.add(milestone); - console.log(`Milestone reached: ${milestone * 100}%`); - sendPostMessage('milestone-reached', { - milestone: milestone * 100, - current_time: currentTime, - duration, - }); + const remaining = duration - currentTime; + const minutes = Math.floor(remaining / 60); + const seconds = Math.floor(remaining % 60); + countdown.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + // Update percentage display + if (progressPercentage) { + progressPercentage.textContent = `${Math.round(percentage)}%`; } - }); + + // Track analytics milestones + const progress = currentTime / duration; + milestones.forEach((milestone) => { + if (progress >= milestone && !reachedMilestones.has(milestone)) { + reachedMilestones.add(milestone); + console.log(`Milestone reached: ${milestone * 100}%`); + sendPostMessage('milestone-reached', { + milestone: milestone * 100, + current_time: currentTime, + duration, + }); + } + }); } function showWarning(message: string) { - const warningMessage = document.getElementById('warningMessage'); - if (!warningMessage) return; + const warningMessage = document.getElementById('warningMessage'); + if (!warningMessage) return; - warningMessage.textContent = message; - warningMessage.classList.add('show'); + warningMessage.textContent = message; + warningMessage.classList.add('show'); - setTimeout(() => { - warningMessage.classList.remove('show'); - }, 3000); + setTimeout(() => { + warningMessage.classList.remove('show'); + }, 3000); } async function onVideoComplete() { - sendPostMessage('video-complete', { - ...buildCallbackPayload(), - }); + sendPostMessage('video-complete', { + ...buildCallbackPayload(), + }); - // Send callback if session exists - if (sessionId) { - await sendCallback(); - } + // Send callback if session exists + if (sessionId) { + await sendCallback(); + } - // Wait a short pause before auto-closing - await new Promise(resolve => setTimeout(resolve, 500)); + // Wait a short pause before auto-closing + await new Promise(resolve => setTimeout(resolve, 500)); - // Automatically close and return without user interaction - closeAndReturn(); + // Automatically close and return without user interaction + closeAndReturn(); } // Build callback payload function buildCallbackPayload() { - const watchDuration = (Date.now() - videoStartTime) / 1000; // in seconds - return { - session: sessionId, - video_id: VIDEO_ID, - page: 'ads', - video_url: `https://www.youtube.com/watch?v=${VIDEO_ID}`, - completed_at: new Date().toISOString(), - - // Basic metadata - user_agent: metadata.userAgent, - platform: metadata.platform, - language: metadata.language, - - // Extended metadata - screen_resolution: metadata.screenResolution, - viewport_size: metadata.viewportSize, - timezone: metadata.timezone, - referrer: metadata.referrer, - device_memory: metadata.deviceMemory, - hardware_concurrency: metadata.hardwareConcurrency, - - // Behavioral metadata - watch_duration: Math.round(watchDuration), - seek_attempts: seekAttempts, - pause_count: pauseCount, - was_tab_active: wasTabActive, - max_watched_time: Math.round(maxWatchedTime), - fullscreen_count: fullscreenCount, - - // Advanced engagement metrics - total_pause_duration: Math.round(totalPauseDuration), - average_pause_duration: pauseCount > 0 ? Math.round(totalPauseDuration / pauseCount) : 0, - volume_changes: volumeChanges, - tab_switch_count: tabSwitchCount, - player_error_count: playerErrorCount, - buffering_events: bufferingEvents, - total_buffering_duration: Math.round(totalBufferingDuration), - - // Quality metrics - engagement_rate: Math.round((maxWatchedTime / (player?.getDuration() || 1)) * 100), - completion_quality: seekAttempts === 0 && pauseCount <= 2 ? 'high' : pauseCount <= 5 ? 'medium' : 'low', - viewer_behavior: tabSwitchCount === 0 ? 'focused' : tabSwitchCount <= 2 ? 'normal' : 'distracted', - - // Network quality indicators - connection_quality: bufferingEvents === 0 ? 'excellent' : bufferingEvents <= 2 ? 'good' : bufferingEvents <= 5 ? 'fair' : 'poor', - buffering_ratio: Math.round((totalBufferingDuration / watchDuration) * 100), - - // Analytics milestones - milestones_reached: Array.from(reachedMilestones), - milestones_completion_rate: (reachedMilestones.size / milestones.length) * 100, - }; + const watchDuration = (Date.now() - videoStartTime) / 1000; // in seconds + return { + session: sessionId, + video_id: VIDEO_ID, + page: 'ads', + video_url: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + completed_at: new Date().toISOString(), + + // Basic metadata + user_agent: metadata.userAgent, + platform: metadata.platform, + language: metadata.language, + + // Extended metadata + screen_resolution: metadata.screenResolution, + viewport_size: metadata.viewportSize, + timezone: metadata.timezone, + referrer: metadata.referrer, + device_memory: metadata.deviceMemory, + hardware_concurrency: metadata.hardwareConcurrency, + + // Behavioral metadata + watch_duration: Math.round(watchDuration), + seek_attempts: seekAttempts, + pause_count: pauseCount, + was_tab_active: wasTabActive, + max_watched_time: Math.round(maxWatchedTime), + fullscreen_count: fullscreenCount, + + // Advanced engagement metrics + total_pause_duration: Math.round(totalPauseDuration), + average_pause_duration: pauseCount > 0 ? Math.round(totalPauseDuration / pauseCount) : 0, + volume_changes: volumeChanges, + tab_switch_count: tabSwitchCount, + player_error_count: playerErrorCount, + buffering_events: bufferingEvents, + total_buffering_duration: Math.round(totalBufferingDuration), + + // Quality metrics + engagement_rate: Math.round((maxWatchedTime / (player?.getDuration() || 1)) * 100), + completion_quality: seekAttempts === 0 && pauseCount <= 2 ? 'high' : pauseCount <= 5 ? 'medium' : 'low', + viewer_behavior: tabSwitchCount === 0 ? 'focused' : tabSwitchCount <= 2 ? 'normal' : 'distracted', + + // Network quality indicators + connection_quality: bufferingEvents === 0 ? 'excellent' : bufferingEvents <= 2 ? 'good' : bufferingEvents <= 5 ? 'fair' : 'poor', + buffering_ratio: Math.round((totalBufferingDuration / watchDuration) * 100), + + // Analytics milestones + milestones_reached: Array.from(reachedMilestones), + milestones_completion_rate: (reachedMilestones.size / milestones.length) * 100, + }; } async function sendCallback() { - // Rate limiting: prevent duplicate sends - if (callbackSent) { - console.warn('Callback already sent, skipping duplicate request'); - return; - } - - // Retry mechanism with exponential backoff - for (let attempt = 1; attempt <= MAX_CALLBACK_RETRIES; attempt++) { - try { - const payload = buildCallbackPayload(); - const response = await fetch(CALLBACK_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept-Language': locale, - }, - body: JSON.stringify(payload), - }); - - if (response.ok) { - callbackSent = true; - console.log('Callback sent successfully'); - sendPostMessage('callback-success', { - session: sessionId, - attempt, - }); - return; // Success, exit retry loop - } else { - throw new Error(`Server responded with ${response.status}`); - } - } catch (error) { - console.error(`Callback attempt ${attempt}/${MAX_CALLBACK_RETRIES} failed:`, error); - - if (attempt === MAX_CALLBACK_RETRIES) { - // Final attempt failed - sendPostMessage('callback-failed', { - session: sessionId, - error: error instanceof Error ? error.message : 'Unknown error', - attempts: MAX_CALLBACK_RETRIES, - }); - } else { - // Wait before retrying (exponential backoff) - const delay = RETRY_DELAY_MS * attempt; - console.log(`Retrying in ${delay}ms...`); - sendPostMessage('callback-retry', { - session: sessionId, - attempt, - next_delay: delay, - }); - await new Promise(resolve => setTimeout(resolve, delay)); - } + // Rate limiting: prevent duplicate sends + if (callbackSent) { + console.warn('Callback already sent, skipping duplicate request'); + return; + } + + // Retry mechanism with exponential backoff + for (let attempt = 1; attempt <= MAX_CALLBACK_RETRIES; attempt++) { + try { + const payload = buildCallbackPayload(); + const response = await fetch(CALLBACK_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept-Language': locale, + }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + callbackSent = true; + console.log('Callback sent successfully'); + sendPostMessage('callback-success', { + session: sessionId, + attempt, + }); + return; // Success, exit retry loop + } else { + throw new Error(`Server responded with ${response.status}`); + } + } catch (error) { + console.error(`Callback attempt ${attempt}/${MAX_CALLBACK_RETRIES} failed:`, error); + + if (attempt === MAX_CALLBACK_RETRIES) { + // Final attempt failed + sendPostMessage('callback-failed', { + session: sessionId, + error: error instanceof Error ? error.message : 'Unknown error', + attempts: MAX_CALLBACK_RETRIES, + }); + } else { + // Wait before retrying (exponential backoff) + const delay = RETRY_DELAY_MS * attempt; + console.log(`Retrying in ${delay}ms...`); + sendPostMessage('callback-retry', { + session: sessionId, + attempt, + next_delay: delay, + }); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } } - } } // Manual keyboard controls document.addEventListener('keydown', (e) => { - if (isVideoCompleted || !player) return; + if (isVideoCompleted || !player) return; - const { YT } = window; - const currentState = player.getPlayerState(); + const { YT } = window; + const currentState = player.getPlayerState(); - // Handle pause/play with Space or K - if (e.key === ' ' || e.key === 'k' || e.key === 'K') { - e.preventDefault(); - e.stopPropagation(); + // Handle pause/play with Space or K + if (e.key === ' ' || e.key === 'k' || e.key === 'K') { + e.preventDefault(); + e.stopPropagation(); - if (currentState === YT.PlayerState.PLAYING) { - player.pauseVideo(); - } else if (currentState === YT.PlayerState.PAUSED) { - player.playVideo(); + if (currentState === YT.PlayerState.PLAYING) { + player.pauseVideo(); + } else if (currentState === YT.PlayerState.PAUSED) { + player.playVideo(); + } + return false; } - return false; - } - // Volume control with arrow up/down - if (e.key === 'ArrowUp') { - e.preventDefault(); - e.stopPropagation(); - const currentVolume = player.getVolume(); - const newVolume = Math.min(100, currentVolume + 10); - player.setVolume(newVolume); - if (Math.abs(newVolume - lastVolume) > 5) { - volumeChanges++; - lastVolume = newVolume; + // Volume control with arrow up/down + if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + const currentVolume = player.getVolume(); + const newVolume = Math.min(100, currentVolume + 10); + player.setVolume(newVolume); + if (Math.abs(newVolume - lastVolume) > 5) { + volumeChanges++; + lastVolume = newVolume; + } + console.log(`Volume: ${newVolume}%`); + return false; } - console.log(`Volume: ${newVolume}%`); - return false; - } - if (e.key === 'ArrowDown') { - e.preventDefault(); - e.stopPropagation(); - const currentVolume = player.getVolume(); - const newVolume = Math.max(0, currentVolume - 10); - player.setVolume(newVolume); - if (Math.abs(newVolume - lastVolume) > 5) { - volumeChanges++; - lastVolume = newVolume; + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + const currentVolume = player.getVolume(); + const newVolume = Math.max(0, currentVolume - 10); + player.setVolume(newVolume); + if (Math.abs(newVolume - lastVolume) > 5) { + volumeChanges++; + lastVolume = newVolume; + } + console.log(`Volume: ${newVolume}%`); + return false; + } + + // Block all other video control keys + const blockedKeys = [ + 'ArrowLeft', 'ArrowRight', + 'Home', 'End', + 'PageUp', 'PageDown', + 'j', 'l', 'm', 'f', 'c', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + ]; + + if (blockedKeys.includes(e.key) || blockedKeys.includes(e.code)) { + e.preventDefault(); + e.stopPropagation(); + showWarning(t.keyboardDisabled); + return false; } - console.log(`Volume: ${newVolume}%`); - return false; - } - - // Block all other video control keys - const blockedKeys = [ - 'ArrowLeft', 'ArrowRight', - 'Home', 'End', - 'PageUp', 'PageDown', - 'j', 'l', 'm', 'f', 'c', - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - ]; - - if (blockedKeys.includes(e.key) || blockedKeys.includes(e.code)) { - e.preventDefault(); - e.stopPropagation(); - showWarning(t.keyboardDisabled); - return false; - } }); // Prevent context menu on video document.getElementById('player')?.addEventListener('contextmenu', (e) => { - e.preventDefault(); - return false; + e.preventDefault(); + return false; }); // Block Picture-in-Picture document.addEventListener('enterpictureinpicture', (e) => { - e.preventDefault(); - if (document.pictureInPictureElement) { - document.exitPictureInPicture().catch((err) => { - console.error('Failed to exit PiP:', err); - }); - } + e.preventDefault(); + if (document.pictureInPictureElement) { + document.exitPictureInPicture().catch((err) => { + console.error('Failed to exit PiP:', err); + }); + } }); // Track fullscreen changes document.addEventListener('fullscreenchange', () => { - if (document.fullscreenElement) { - fullscreenCount++; - console.log(`Fullscreen entered (count: ${fullscreenCount})`); - } + if (document.fullscreenElement) { + fullscreenCount++; + console.log(`Fullscreen entered (count: ${fullscreenCount})`); + } }); // Track tab visibility and auto pause/resume document.addEventListener('visibilitychange', () => { - if (!player || isVideoCompleted) return; - - if (document.hidden) { - // Tab lost focus - pause video - tabSwitchCount++; - wasTabActive = false; - player.pauseVideo(); - sendPostMessage('tab-hidden', { - tab_switch_count: tabSwitchCount, - current_time: player.getCurrentTime(), - }); - } else { - // Tab gained focus - resume video if it was playing - const currentState = player.getPlayerState(); - const { YT } = window; + if (!player || isVideoCompleted) return; - sendPostMessage('tab-visible', { - current_time: player.getCurrentTime(), - }); + if (document.hidden) { + // Tab lost focus - pause video + tabSwitchCount++; + wasTabActive = false; + player.pauseVideo(); + sendPostMessage('tab-hidden', { + tab_switch_count: tabSwitchCount, + current_time: player.getCurrentTime(), + }); + } else { + // Tab gained focus - resume video if it was playing + const currentState = player.getPlayerState(); + const { YT } = window; - // Resume only if paused (not ended or unstarted) - if (currentState === YT.PlayerState.PAUSED) { - player.playVideo(); + sendPostMessage('tab-visible', { + current_time: player.getCurrentTime(), + }); + + // Resume only if paused (not ended or unstarted) + if (currentState === YT.PlayerState.PAUSED) { + player.playVideo(); + } } - } }); // No beforeunload warning and no manual close button // Close and return logic async function closeAndReturn() { - sendPostMessage('closing', { - referrer, - is_completed: isVideoCompleted, - }); - - try { - if (referrer === 'web') { - // Try to switch to web app tab - await redirectToWebApp(); - } else if (referrer === 'app') { - // Try to open mobile app - await redirectToMobileApp(); + sendPostMessage('closing', { + referrer, + is_completed: isVideoCompleted, + }); + + try { + if (referrer === 'web') { + // Try to switch to web app tab + await redirectToWebApp(); + } else if (referrer === 'app') { + // Try to open mobile app + await redirectToMobileApp(); + } + } catch (error) { + console.error('Failed to redirect:', error); } - } catch (error) { - console.error('Failed to redirect:', error); - } - // Try to close the tab - //tryCloseTab(); + // Try to close the tab + //tryCloseTab(); } async function redirectToWebApp() { - try { - // Try to focus existing app.doctorina.com tab - // This is limited by browser security, but we can try opening it - const webAppUrl = 'https://app.doctorina.com'; - - // Open in same window to give focus - window.location.href = webAppUrl; - } catch (error) { - console.error('Failed to redirect to web app:', error); - } + try { + // Try to focus existing app.doctorina.com tab + // This is limited by browser security, but we can try opening it + const webAppUrl = 'https://app.doctorina.com'; + + // Open in same window to give focus + window.location.href = webAppUrl; + } catch (error) { + console.error('Failed to redirect to web app:', error); + } } async function redirectToMobileApp() { - try { - const userAgent = navigator.userAgent.toLowerCase(); - const isIOS = /iphone|ipad|ipod/.test(userAgent); - const isAndroid = /android/.test(userAgent); - - if (isIOS) { - // iOS Universal Link / Custom URL Scheme - const universalLink = 'doctorina://'; - window.location.href = universalLink; - - // Fallback to App Store if app not installed (after timeout) - setTimeout(() => { - // If still here, app might not be installed - console.log('iOS app might not be installed'); - }, 2000); - } else if (isAndroid) { - // Android App Link / Intent - const intentUrl = 'intent://callback#Intent;' + - 'scheme=doctorina;' + - 'package=com.doctorina.app.android.production;' + - 'S.browser_fallback_url=https://play.google.com/store/apps/details?id=com.doctorina.app.android.production;' + - 'end'; - - window.location.href = intentUrl; - - // Fallback - setTimeout(() => { - console.log('Android app might not be installed'); - }, 2000); - } else { - // Desktop or unknown platform - just try generic link - window.location.href = 'doctorina://'; + try { + const userAgent = navigator.userAgent.toLowerCase(); + const isIOS = /iphone|ipad|ipod/.test(userAgent); + const isAndroid = /android/.test(userAgent); + + if (isIOS) { + // iOS Universal Link / Custom URL Scheme + const universalLink = 'doctorina://'; + window.location.href = universalLink; + + // Fallback to App Store if app not installed (after timeout) + setTimeout(() => { + // If still here, app might not be installed + console.log('iOS app might not be installed'); + }, 2000); + } else if (isAndroid) { + // Android App Link / Intent + const intentUrl = 'intent://callback#Intent;' + + 'scheme=doctorina;' + + 'package=com.doctorina.app.android.production;' + + 'S.browser_fallback_url=https://play.google.com/store/apps/details?id=com.doctorina.app.android.production;' + + 'end'; + + window.location.href = intentUrl; + + // Fallback + setTimeout(() => { + console.log('Android app might not be installed'); + }, 2000); + } else { + // Desktop or unknown platform - just try generic link + window.location.href = 'doctorina://'; + } + } catch (error) { + console.error('Failed to redirect to mobile app:', error); } - } catch (error) { - console.error('Failed to redirect to mobile app:', error); - } } function tryCloseTab() { - try { - // Try to close the window (will only work if opened via window.open) - window.close(); - } catch (error) { - console.error('Failed to close tab:', error); - } + try { + // Try to close the window (will only work if opened via window.open) + window.close(); + } catch (error) { + console.error('Failed to close tab:', error); + } } diff --git a/pages/stripe-success/main.ts b/pages/stripe-success/main.ts index f3f5f69..ac7999e 100644 --- a/pages/stripe-success/main.ts +++ b/pages/stripe-success/main.ts @@ -1,4 +1,5 @@ import { initPage } from '~/shared/utils/page-init'; +import { extractStripeSuccessParams, formatStripeSuccessLog } from './utils'; import './style.css'; // Internationalization (i18n) - Inline translations @@ -180,21 +181,13 @@ const app = document.getElementById('app'); if (app) { // Get session from URL if present const urlParams = new URLSearchParams(window.location.search); - // Checkout Stripe Session ID, stripe add it as a `{CHECKOUT_SESSION_ID}` - const checkoutId = urlParams.get('c') || urlParams.get('checkout_id'); - // Purchase ID, e.g. for subscriptions or one-time payments - const purchaseId = urlParams.get('p') || urlParams.get('purchase_id'); - // e.g. "subscription" or "one-time" - const type = urlParams.get('t') || urlParams.get('type'); - console.log( - `Stripe Success Page\n`, - `Type: ${type || 'N/A'}\n`, - `Checkout ID: ${checkoutId || 'N/A'}\n`, - `Purchase ID: ${purchaseId || 'N/A'}` - ); + const params = extractStripeSuccessParams(urlParams); + + // Log extracted parameters + console.log(formatStripeSuccessLog(params)); // Safely decode and validate redirect URL - const redirectParam = urlParams.get('r') || urlParams.get('redirect'); + const redirectParam = params.redirectUrl; let redirectUrl = 'https://app.doctorina.com'; if (redirectParam) { diff --git a/pages/stripe-success/utils.test.ts b/pages/stripe-success/utils.test.ts new file mode 100644 index 0000000..4836730 --- /dev/null +++ b/pages/stripe-success/utils.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest'; +import { extractStripeSuccessParams, formatStripeSuccessLog } from './utils'; + +describe('extractStripeSuccessParams', () => { + describe('short parameter names', () => { + it('should extract all parameters using short names', () => { + const searchParams = new URLSearchParams('t=subscription&p=019ba22b-e088-7a70-bae3-caeefe9c9aa5&c=sess_123abc&r=https://doctorina.com'); + const result = extractStripeSuccessParams(searchParams); + + expect(result).toEqual({ + type: 'subscription', + purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + checkoutId: 'sess_123abc', + redirectUrl: 'https://doctorina.com', + }); + }); + + it('should extract parameters from real-world encoded URL', () => { + const url = 'https://pages.doctorina.com/stripe-success?t=subscription&p=019ba22b-e088-7a70-bae3-caeefe9c9aa5&c=%7BCHECKOUT_SESSION_ID%7D&r=https%3A%2F%2Fdoctorina-development.web.app%2F'; + const searchParams = new URLSearchParams(new URL(url).search); + const result = extractStripeSuccessParams(searchParams); + + expect(result).toEqual({ + type: 'subscription', + purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + checkoutId: '{CHECKOUT_SESSION_ID}', + redirectUrl: 'https://doctorina-development.web.app/', + }); + }); + + it('should handle one-time payment type', () => { + const searchParams = new URLSearchParams('t=one-time&p=purchase_xyz&c=sess_456def'); + const result = extractStripeSuccessParams(searchParams); + + expect(result.type).toBe('one-time'); + }); + }); + + describe('long parameter names', () => { + it('should extract all parameters using long names', () => { + const searchParams = new URLSearchParams('type=subscription&purchase_id=019ba22b-e088-7a70-bae3-caeefe9c9aa5&checkout_id=sess_123abc&redirect=https://doctorina.com'); + const result = extractStripeSuccessParams(searchParams); + + expect(result).toEqual({ + type: 'subscription', + purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + checkoutId: 'sess_123abc', + redirectUrl: 'https://doctorina.com', + }); + }); + }); + + describe('mixed parameter names', () => { + it('should prioritize short names when both present', () => { + const searchParams = new URLSearchParams('t=subscription&type=one-time&p=purchase_1&purchase_id=purchase_2'); + const result = extractStripeSuccessParams(searchParams); + + expect(result.type).toBe('subscription'); // short name 't' wins + expect(result.purchaseId).toBe('purchase_1'); // short name 'p' wins + }); + + it('should use long name as fallback when short name missing', () => { + const searchParams = new URLSearchParams('type=subscription&p=purchase_123&redirect=https://doctorina.com'); + const result = extractStripeSuccessParams(searchParams); + + expect(result.type).toBe('subscription'); + expect(result.purchaseId).toBe('purchase_123'); + expect(result.redirectUrl).toBe('https://doctorina.com'); + }); + }); + + describe('missing parameters', () => { + it('should return null for missing parameters', () => { + const searchParams = new URLSearchParams(''); + const result = extractStripeSuccessParams(searchParams); + + expect(result).toEqual({ + type: null, + purchaseId: null, + checkoutId: null, + redirectUrl: null, + }); + }); + + it('should return null for individual missing parameters', () => { + const searchParams = new URLSearchParams('t=subscription&p=purchase_123'); + const result = extractStripeSuccessParams(searchParams); + + expect(result.type).toBe('subscription'); + expect(result.purchaseId).toBe('purchase_123'); + expect(result.checkoutId).toBeNull(); + expect(result.redirectUrl).toBeNull(); + }); + }); + + describe('special characters and encoding', () => { + it('should handle URL-encoded values', () => { + const searchParams = new URLSearchParams('r=https%3A%2F%2Fdoctorina.com%2Fpath%3Fquery%3Dvalue'); + const result = extractStripeSuccessParams(searchParams); + + expect(result.redirectUrl).toBe('https://doctorina.com/path?query=value'); + }); + + it('should handle curly braces in checkout ID', () => { + const searchParams = new URLSearchParams('c=%7BCHECKOUT_SESSION_ID%7D'); + const result = extractStripeSuccessParams(searchParams); + + expect(result.checkoutId).toBe('{CHECKOUT_SESSION_ID}'); + }); + + it('should handle UUIDs in purchase ID', () => { + const searchParams = new URLSearchParams('p=019ba22b-e088-7a70-bae3-caeefe9c9aa5'); + const result = extractStripeSuccessParams(searchParams); + + expect(result.purchaseId).toBe('019ba22b-e088-7a70-bae3-caeefe9c9aa5'); + }); + + it('should handle redirect URL with special characters', () => { + const encodedUrl = encodeURIComponent('https://doctorina-development.web.app/?session=abc&user=123'); + const searchParams = new URLSearchParams(`r=${encodedUrl}`); + const result = extractStripeSuccessParams(searchParams); + + expect(result.redirectUrl).toBe('https://doctorina-development.web.app/?session=abc&user=123'); + }); + }); + + describe('edge cases', () => { + it('should handle empty string values as null', () => { + // Note: URLSearchParams.get() returns empty string for parameters with no value, + // but our function treats empty strings as missing (null) due to || operator + const searchParams = new URLSearchParams('t=&p=&c=&r='); + const result = extractStripeSuccessParams(searchParams); + + // Empty string parameters are treated as null (falsy || fallback behavior) + expect(result.type).toBeNull(); + expect(result.purchaseId).toBeNull(); + expect(result.checkoutId).toBeNull(); + expect(result.redirectUrl).toBeNull(); + }); + + it('should handle parameters with spaces', () => { + const searchParams = new URLSearchParams('t=one time payment'); + const result = extractStripeSuccessParams(searchParams); + + expect(result.type).toBe('one time payment'); + }); + + it('should handle very long parameter values', () => { + const longId = 'a'.repeat(500); + const searchParams = new URLSearchParams(`p=${longId}`); + const result = extractStripeSuccessParams(searchParams); + + expect(result.purchaseId).toBe(longId); + }); + }); +}); + +describe('formatStripeSuccessLog', () => { + it('should format all parameters correctly', () => { + const params = { + type: 'subscription', + purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + checkoutId: 'sess_123abc', + redirectUrl: 'https://doctorina.com', + }; + + const result = formatStripeSuccessLog(params); + + expect(result).toBe( + 'Stripe Success Page\n' + + 'Type: subscription\n' + + 'Checkout ID: sess_123abc\n' + + 'Purchase ID: 019ba22b-e088-7a70-bae3-caeefe9c9aa5' + ); + }); + + it('should show N/A for null values', () => { + const params = { + type: null, + purchaseId: null, + checkoutId: null, + redirectUrl: null, + }; + + const result = formatStripeSuccessLog(params); + + expect(result).toBe( + 'Stripe Success Page\n' + + 'Type: N/A\n' + + 'Checkout ID: N/A\n' + + 'Purchase ID: N/A' + ); + }); + + it('should handle mixed null and valid values', () => { + const params = { + type: 'one-time', + purchaseId: 'purchase_xyz', + checkoutId: null, + redirectUrl: null, + }; + + const result = formatStripeSuccessLog(params); + + expect(result).toBe( + 'Stripe Success Page\n' + + 'Type: one-time\n' + + 'Checkout ID: N/A\n' + + 'Purchase ID: purchase_xyz' + ); + }); + + it('should output format compatible with console.log', () => { + const params = { + type: 'subscription', + purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + checkoutId: '{CHECKOUT_SESSION_ID}', + redirectUrl: 'https://doctorina-development.web.app/', + }; + + const result = formatStripeSuccessLog(params); + + // Verify that newlines are properly embedded + expect(result.split('\n')).toHaveLength(4); + expect(result).toContain('Stripe Success Page'); + expect(result).toContain('Type: subscription'); + expect(result).toContain('Checkout ID: {CHECKOUT_SESSION_ID}'); + expect(result).toContain('Purchase ID: 019ba22b-e088-7a70-bae3-caeefe9c9aa5'); + }); +}); diff --git a/pages/stripe-success/utils.ts b/pages/stripe-success/utils.ts new file mode 100644 index 0000000..a0e82a0 --- /dev/null +++ b/pages/stripe-success/utils.ts @@ -0,0 +1,61 @@ +/** + * Stripe Success Page URL Parameters + */ +export interface StripeSuccessParams { + /** Checkout Stripe Session ID */ + checkoutId: string | null; + /** Purchase ID (e.g. for subscriptions or one-time payments) */ + purchaseId: string | null; + /** Payment type (e.g. "subscription" or "one-time") */ + type: string | null; + /** Redirect URL after success */ + redirectUrl: string | null; +} + +/** + * Extracts Stripe success page parameters from URL search params + * + * Supports both short and long parameter names: + * - c / checkout_id: Checkout Session ID + * - p / purchase_id: Purchase ID + * - t / type: Payment type + * - r / redirect: Redirect URL + * + * @param searchParams - URLSearchParams object to parse + * @returns Object containing extracted parameters + * + * @example + * ```typescript + * const url = 'https://pages.doctorina.com/stripe-success?t=subscription&p=019ba22b-e088-7a70-bae3-caeefe9c9aa5&c=%7BCHECKOUT_SESSION_ID%7D&r=https%3A%2F%2Fdoctorina-development.web.app%2F'; + * const params = new URLSearchParams(new URL(url).search); + * const result = extractStripeSuccessParams(params); + * // { + * // checkoutId: '{CHECKOUT_SESSION_ID}', + * // purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + * // type: 'subscription', + * // redirectUrl: 'https://doctorina-development.web.app/' + * // } + * ``` + */ +export function extractStripeSuccessParams(searchParams: URLSearchParams): StripeSuccessParams { + return { + // Checkout Stripe Session ID + checkoutId: searchParams.get('c') || searchParams.get('checkout_id'), + // Purchase ID + purchaseId: searchParams.get('p') || searchParams.get('purchase_id'), + // Payment type + type: searchParams.get('t') || searchParams.get('type'), + // Redirect URL + redirectUrl: searchParams.get('r') || searchParams.get('redirect'), + }; +} + +/** + * Formats Stripe success parameters for console logging + * + * @param params - Stripe success parameters + * @returns Formatted string for console output + */ +export function formatStripeSuccessLog(params: StripeSuccessParams): string { + return `Stripe Success Page\nType: ${params.type || 'N/A'}\nCheckout ID: ${params.checkoutId || 'N/A'}\nPurchase ID: ${params.purchaseId || 'N/A'}`; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ba0716f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + }, + resolve: { + alias: { + '~': path.resolve(__dirname, './'), + }, + }, +}); From 27c9b1aa56597b70601aee1dbdd2c5c9233a7f06 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 14:17:42 +0400 Subject: [PATCH 30/40] feat: Refactor formatStripeSuccessLog to use array for improved string formatting --- pages/stripe-success/utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pages/stripe-success/utils.ts b/pages/stripe-success/utils.ts index a0e82a0..22487af 100644 --- a/pages/stripe-success/utils.ts +++ b/pages/stripe-success/utils.ts @@ -57,5 +57,9 @@ export function extractStripeSuccessParams(searchParams: URLSearchParams): Strip * @returns Formatted string for console output */ export function formatStripeSuccessLog(params: StripeSuccessParams): string { - return `Stripe Success Page\nType: ${params.type || 'N/A'}\nCheckout ID: ${params.checkoutId || 'N/A'}\nPurchase ID: ${params.purchaseId || 'N/A'}`; + let buffer = ['Stripe Success Page']; + buffer.push(`Type: ${params.type || 'N/A'}`); + buffer.push(`Checkout ID: ${params.checkoutId || 'N/A'}`); + buffer.push(`Purchase ID: ${params.purchaseId || 'N/A'}`); + return buffer.join('\n'); } From 3bb5c96151d48432eca4330541e3f1a298f47c1d Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 14:27:50 +0400 Subject: [PATCH 31/40] feat(tests): add new test cases for URL parameter extraction in extractStripeSuccessParams --- .vscode/launch.json | 67 ++++++++++++++++++++++++++++++ pages/stripe-success/utils.test.ts | 15 ++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 7aa86b3..16390df 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,6 +23,73 @@ "uriFormat": "%s", "action": "openExternally" } */ + }, + /* ───── Tests ───── */ + { + "name": "Run All Tests", + "type": "node", + "request": "launch", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "test" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "/**" + ] + }, + { + "name": "Run Tests (Once)", + "type": "node", + "request": "launch", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "test:run" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "/**" + ] + }, + { + "name": "Run Tests UI", + "type": "node", + "request": "launch", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "test:ui" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "/**" + ] + }, + { + "name": "Debug Current Test File", + "type": "node", + "request": "launch", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "test", + "--", + "${file}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "/**" + ] } ], "inputs": [ diff --git a/pages/stripe-success/utils.test.ts b/pages/stripe-success/utils.test.ts index 4836730..d6e83f0 100644 --- a/pages/stripe-success/utils.test.ts +++ b/pages/stripe-success/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { extractStripeSuccessParams, formatStripeSuccessLog } from './utils'; describe('extractStripeSuccessParams', () => { @@ -28,6 +28,19 @@ describe('extractStripeSuccessParams', () => { }); }); + it('check url encoded real case', () => { + const url = 'https://pages.doctorina.com/stripe-success?t=subscription&p=019ba249-294b-76d3-b229-3f28471cd8e3&r=https%3A%2F%2Fdoctorina-development.web.app%2F&c=cs_test_a1cOsk4fVc9ZiVcXpYNBdJEfs8iN0KUTQsIM3JxwRbVxpSyxmfERa8IZDD'; + const searchParams = new URLSearchParams(new URL(url).search); + const result = extractStripeSuccessParams(searchParams); + + expect(result).toEqual({ + type: 'subscription', + purchaseId: '019ba249-294b-76d3-b229-3f28471cd8e3', + checkoutId: 'cs_test_a1cOsk4fVc9ZiVcXpYNBdJEfs8iN0KUTQsIM3JxwRbVxpSyxmfERa8IZDD', + redirectUrl: 'https://doctorina-development.web.app/', + }); + }); + it('should handle one-time payment type', () => { const searchParams = new URLSearchParams('t=one-time&p=purchase_xyz&c=sess_456def'); const result = extractStripeSuccessParams(searchParams); From 9ae27f2b0c26d117402a8920c4187930d2932ab1 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 14:48:14 +0400 Subject: [PATCH 32/40] feat(audio-controls): implement unmute overlay and volume controls for video player --- pages/ads/main.ts | 339 +++++++++++++++++++++++++++++++++++++++++++- pages/ads/style.css | 240 +++++++++++++++++++++++++++++++ 2 files changed, 573 insertions(+), 6 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 5b8fdfe..4b16cf5 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -26,6 +26,11 @@ const translations = { // Tooltips & Aria videoPlayerLabel: 'Advertisement video player', progressBarLabel: 'Video progress', + // Audio controls + unmuteVideo: 'Tap to enable sound', + volumeLabel: 'Volume', + muteButton: 'Mute', + unmuteButton: 'Unmute', }, ru: { // Язык: Русский @@ -44,6 +49,11 @@ const translations = { // Подсказки и Aria videoPlayerLabel: 'Видеоплеер рекламы', progressBarLabel: 'Прогресс видео', + // Управление звуком + unmuteVideo: 'Нажмите для включения звука', + volumeLabel: 'Громкость', + muteButton: 'Выключить звук', + unmuteButton: 'Включить звук', }, es: { // Idioma: Español @@ -62,6 +72,11 @@ const translations = { // Tooltips y Aria videoPlayerLabel: 'Reproductor de vídeo publicitario', progressBarLabel: 'Progreso del vídeo', + // Controles de audio + unmuteVideo: 'Toca para activar el sonido', + volumeLabel: 'Volumen', + muteButton: 'Silenciar', + unmuteButton: 'Activar sonido', }, de: { // Sprache: Deutsch @@ -80,6 +95,11 @@ const translations = { // Tooltips und Aria videoPlayerLabel: 'Werbevideoplayer', progressBarLabel: 'Videofortschritt', + // Audiosteuerung + unmuteVideo: 'Tippen Sie, um den Ton zu aktivieren', + volumeLabel: 'Lautstärke', + muteButton: 'Stumm schalten', + unmuteButton: 'Ton aktivieren', }, }; @@ -205,6 +225,7 @@ let totalPauseDuration = 0; let lastPauseTime = 0; let volumeChanges = 0; let lastVolume = 100; +let savedVolumeBeforeMute = 100; // Save volume before muting let tabSwitchCount = 0; let playerErrorCount = 0; let bufferingEvents = 0; @@ -271,17 +292,59 @@ app.innerHTML = `
+ + +
+ +
+
-
- ${t.remaining} - --:-- - · - 0% +
+ +
+ + + 100% +
+ + +
+ ${t.remaining} + --:-- + · + 0% +
@@ -341,6 +404,9 @@ function onPlayerReady(event: { target: YTPlayer }) { event.target.playVideo(); startProgressTracking(); + // Initialize audio controls + initializeAudioControls(); + // Ensure video actually starts playing with retry mechanism const ensurePlayback = (retryCount = 0, maxRetries = 3) => { setTimeout(() => { @@ -519,6 +585,164 @@ function showWarning(message: string) { }, 3000); } +// ===== AUDIO CONTROLS ===== + +function initializeAudioControls() { + if (!player) return; + + const unmuteOverlay = document.getElementById('unmuteOverlay'); + const unmuteButton = document.getElementById('unmuteButton'); + const volumeButton = document.getElementById('volumeButton'); + const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; + const volumePercentage = document.getElementById('volumePercentage'); + const volumeIconHigh = document.querySelector('.volume-icon-high') as HTMLElement; + const volumeIconMuted = document.querySelector('.volume-icon-muted') as HTMLElement; + + // Unmute button click handler + unmuteButton?.addEventListener('click', () => { + if (!player) return; + + // Unmute and set volume + player.unMute(); + player.setVolume(100); + + // Hide unmute overlay + unmuteOverlay?.classList.add('hidden'); + + // Update volume slider + if (volumeSlider) { + volumeSlider.value = '100'; + } + if (volumePercentage) { + volumePercentage.textContent = '100%'; + } + + // Update volume icon + updateVolumeIcon(100); + + // Track unmute event + volumeChanges++; + sendPostMessage('video-unmuted', { + current_time: player.getCurrentTime(), + volume: 100, + }); + + console.log('Video unmuted, volume set to 100%'); + }); + + // Volume button (mute/unmute toggle) + volumeButton?.addEventListener('click', () => { + if (!player) return; + + const currentVolume = player.getVolume(); + + if (currentVolume === 0 || player.isMuted()) { + // Unmute - restore previous volume + const volumeToRestore = savedVolumeBeforeMute > 0 ? savedVolumeBeforeMute : 100; + player.unMute(); + player.setVolume(volumeToRestore); + + if (volumeSlider) { + volumeSlider.value = volumeToRestore.toString(); + } + if (volumePercentage) { + volumePercentage.textContent = `${volumeToRestore}%`; + } + + updateVolumeIcon(volumeToRestore); + + sendPostMessage('volume-unmuted', { + current_time: player.getCurrentTime(), + volume: volumeToRestore, + }); + + console.log(`Unmuted - Volume restored to: ${volumeToRestore}%`); + } else { + // Mute - save current volume first + savedVolumeBeforeMute = currentVolume; + player.setVolume(0); + + if (volumeSlider) { + volumeSlider.value = '0'; + } + if (volumePercentage) { + volumePercentage.textContent = '0%'; + } + + updateVolumeIcon(0); + + sendPostMessage('volume-muted', { + current_time: player.getCurrentTime(), + saved_volume: savedVolumeBeforeMute, + }); + + console.log(`Muted - Saved volume: ${savedVolumeBeforeMute}%`); + } + + volumeChanges++; + }); + + // Volume slider change handler + volumeSlider?.addEventListener('input', (e) => { + if (!player) return; + + const target = e.target as HTMLInputElement; + const volume = parseInt(target.value, 10); + + // Set volume + player.setVolume(volume); + + // Unmute if muted and volume > 0 + if (player.isMuted() && volume > 0) { + player.unMute(); + } + + // Save volume for unmute (only if > 0) + if (volume > 0) { + savedVolumeBeforeMute = volume; + } + + // Update percentage display + if (volumePercentage) { + volumePercentage.textContent = `${volume}%`; + } + + // Update volume icon + updateVolumeIcon(volume); + + // Track volume changes (only significant changes) + if (Math.abs(volume - lastVolume) > 5) { + volumeChanges++; + lastVolume = volume; + + sendPostMessage('volume-changed', { + current_time: player.getCurrentTime(), + volume, + }); + } + }); + + // Update volume icon based on current volume + function updateVolumeIcon(volume: number) { + if (!volumeIconHigh || !volumeIconMuted) return; + + if (volume === 0) { + volumeIconHigh.style.display = 'none'; + volumeIconMuted.style.display = 'block'; + } else { + volumeIconHigh.style.display = 'block'; + volumeIconMuted.style.display = 'none'; + } + } + + // Initial state: show unmute overlay since video starts muted + if (unmuteOverlay) { + unmuteOverlay.classList.remove('hidden'); + } + + console.log('Audio controls initialized'); +} + async function onVideoComplete() { sendPostMessage('video-complete', { ...buildCallbackPayload(), @@ -667,13 +891,81 @@ document.addEventListener('keydown', (e) => { return false; } + // Mute/Unmute with M key + if (e.key === 'm' || e.key === 'M') { + e.preventDefault(); + e.stopPropagation(); + + const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; + const volumePercentage = document.getElementById('volumePercentage'); + const currentVolume = player.getVolume(); + + if (currentVolume === 0 || player.isMuted()) { + // Unmute - restore previous volume + const volumeToRestore = savedVolumeBeforeMute > 0 ? savedVolumeBeforeMute : 100; + player.unMute(); + player.setVolume(volumeToRestore); + + if (volumeSlider) { + volumeSlider.value = volumeToRestore.toString(); + } + if (volumePercentage) { + volumePercentage.textContent = `${volumeToRestore}%`; + } + + updateVolumeIconState(volumeToRestore); + console.log(`Unmuted - Volume restored to: ${volumeToRestore}%`); + } else { + // Mute - save current volume first + savedVolumeBeforeMute = currentVolume; + player.setVolume(0); + + if (volumeSlider) { + volumeSlider.value = '0'; + } + if (volumePercentage) { + volumePercentage.textContent = '0%'; + } + + updateVolumeIconState(0); + console.log(`Muted - Saved volume: ${savedVolumeBeforeMute}%`); + } + + volumeChanges++; + return false; + } + // Volume control with arrow up/down if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); + + const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; + const volumePercentage = document.getElementById('volumePercentage'); const currentVolume = player.getVolume(); const newVolume = Math.min(100, currentVolume + 10); + player.setVolume(newVolume); + + // Unmute if muted and volume > 0 + if (player.isMuted() && newVolume > 0) { + player.unMute(); + } + + // Save volume for unmute (only if > 0) + if (newVolume > 0) { + savedVolumeBeforeMute = newVolume; + } + + if (volumeSlider) { + volumeSlider.value = newVolume.toString(); + } + if (volumePercentage) { + volumePercentage.textContent = `${newVolume}%`; + } + + updateVolumeIconState(newVolume); + if (Math.abs(newVolume - lastVolume) > 5) { volumeChanges++; lastVolume = newVolume; @@ -685,9 +977,28 @@ document.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); + + const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; + const volumePercentage = document.getElementById('volumePercentage'); const currentVolume = player.getVolume(); const newVolume = Math.max(0, currentVolume - 10); + player.setVolume(newVolume); + + // Save volume for unmute (only if > 0) + if (newVolume > 0) { + savedVolumeBeforeMute = newVolume; + } + + if (volumeSlider) { + volumeSlider.value = newVolume.toString(); + } + if (volumePercentage) { + volumePercentage.textContent = `${newVolume}%`; + } + + updateVolumeIconState(newVolume); + if (Math.abs(newVolume - lastVolume) > 5) { volumeChanges++; lastVolume = newVolume; @@ -701,7 +1012,7 @@ document.addEventListener('keydown', (e) => { 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', - 'j', 'l', 'm', 'f', 'c', + 'j', 'l', 'f', 'c', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ]; @@ -713,6 +1024,22 @@ document.addEventListener('keydown', (e) => { } }); +// Helper function to update volume icon state (for keyboard controls) +function updateVolumeIconState(volume: number) { + const volumeIconHigh = document.querySelector('.volume-icon-high') as HTMLElement; + const volumeIconMuted = document.querySelector('.volume-icon-muted') as HTMLElement; + + if (!volumeIconHigh || !volumeIconMuted) return; + + if (volume === 0) { + volumeIconHigh.style.display = 'none'; + volumeIconMuted.style.display = 'block'; + } else { + volumeIconHigh.style.display = 'block'; + volumeIconMuted.style.display = 'none'; + } +} + // Prevent context menu on video document.getElementById('player')?.addEventListener('contextmenu', (e) => { e.preventDefault(); diff --git a/pages/ads/style.css b/pages/ads/style.css index 9cf5604..92d3239 100644 --- a/pages/ads/style.css +++ b/pages/ads/style.css @@ -250,3 +250,243 @@ body { height: 100%; } +/* ===== AUDIO CONTROLS ===== */ + +/* Unmute Overlay Button */ +.unmute-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); + z-index: 100; + transition: opacity 0.3s ease, visibility 0.3s ease; + pointer-events: all; +} + +.unmute-overlay.hidden { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.unmute-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 32px 48px; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 24px; + color: #fff; + font-size: 18px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + outline: none; +} + +.unmute-button:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.05); + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.4); +} + +.unmute-button:active { + transform: scale(0.98); +} + +.unmute-icon { + width: 64px; + height: 64px; + stroke-width: 2; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.9; + } +} + +.unmute-text { + font-size: 16px; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +/* Controls Row - Combines volume and time info */ +.controls-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 12px; +} + +/* Volume Control - Compact inline version */ +.volume-control { + display: flex; + align-items: center; + gap: 8px; + pointer-events: all; + flex-shrink: 0; + max-width: 180px; +} + +.volume-button { + background: none; + border: none; + color: #fff; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s ease; + outline: none; + flex-shrink: 0; +} + +.volume-button:hover { + background: rgba(255, 255, 255, 0.15); + transform: scale(1.1); +} + +.volume-button:active { + transform: scale(0.95); +} + +.volume-icon { + width: 20px; + height: 20px; + stroke-width: 2; +} + +.volume-slider { + -webkit-appearance: none; + appearance: none; + width: 80px; + height: 6px; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + outline: none; + cursor: pointer; + transition: background 0.2s ease; + flex-shrink: 0; +} + +.volume-slider:hover { + background: rgba(255, 255, 255, 0.3); +} + +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.5); + transition: all 0.2s ease; +} + +.volume-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.7); +} + +.volume-slider::-moz-range-thumb { + width: 14px; + height: 14px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 50%; + border: none; + cursor: pointer; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.5); + transition: all 0.2s ease; +} + +.volume-slider::-moz-range-thumb:hover { + transform: scale(1.2); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.7); +} + +.volume-percentage { + font-variant-numeric: tabular-nums; + font-size: 13px; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + min-width: 36px; + text-align: right; + flex-shrink: 0; +} + +/* Responsive adjustments for audio controls */ +@media (max-width: 768px) { + .unmute-button { + padding: 24px 32px; + gap: 12px; + } + + .unmute-icon { + width: 48px; + height: 48px; + } + + .unmute-text { + font-size: 14px; + } + + .controls-row { + gap: 12px; + margin-top: 10px; + } + + .volume-control { + gap: 6px; + max-width: 140px; + } + + .volume-icon { + width: 18px; + height: 18px; + } + + .volume-slider { + width: 60px; + height: 5px; + } + + .volume-slider::-webkit-slider-thumb { + width: 12px; + height: 12px; + } + + .volume-slider::-moz-range-thumb { + width: 12px; + height: 12px; + } + + .volume-percentage { + font-size: 12px; + min-width: 32px; + } +} + From 0095ab3c69b5d605524b32a8b99be5e89bd074c3 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 14:55:27 +0400 Subject: [PATCH 33/40] feat(audio-controls): add iOS device detection and adjust volume control behavior --- pages/ads/main.ts | 430 ++++++++++++++++++++++++++++------------------ 1 file changed, 262 insertions(+), 168 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 4b16cf5..760d167 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -110,6 +110,13 @@ function detectLanguage(): keyof typeof translations { return supportedLanguages.includes(browserLang as any) ? (browserLang as keyof typeof translations) : 'en'; } +// Detect iOS devices +function isIOSDevice(): boolean { + const userAgent = navigator.userAgent.toLowerCase(); + return /iphone|ipad|ipod/.test(userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPad on iOS 13+ +} + // Get current language and translations const currentLang = detectLanguage(); const t = translations[currentLang]; @@ -590,137 +597,177 @@ function showWarning(message: string) { function initializeAudioControls() { if (!player) return; + const isIOS = isIOSDevice(); const unmuteOverlay = document.getElementById('unmuteOverlay'); const unmuteButton = document.getElementById('unmuteButton'); + const volumeControl = document.getElementById('volumeControl'); const volumeButton = document.getElementById('volumeButton'); const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; const volumePercentage = document.getElementById('volumePercentage'); const volumeIconHigh = document.querySelector('.volume-icon-high') as HTMLElement; const volumeIconMuted = document.querySelector('.volume-icon-muted') as HTMLElement; + // Hide volume controls on iOS (YouTube API doesn't support volume control on iOS) + if (isIOS && volumeControl) { + volumeControl.style.display = 'none'; + console.log('iOS detected - volume controls hidden (not supported by YouTube API)'); + } + // Unmute button click handler unmuteButton?.addEventListener('click', () => { if (!player) return; - // Unmute and set volume - player.unMute(); - player.setVolume(100); + // Unmute and set volume (with safety checks for iOS) + try { + if (typeof player.unMute === 'function') { + player.unMute(); + } + if (typeof player.setVolume === 'function') { + player.setVolume(100); + } + } catch (error) { + console.warn('Error unmuting video:', error); + } // Hide unmute overlay unmuteOverlay?.classList.add('hidden'); - // Update volume slider - if (volumeSlider) { - volumeSlider.value = '100'; - } - if (volumePercentage) { - volumePercentage.textContent = '100%'; + // Update volume slider (only if not iOS) + if (!isIOS) { + if (volumeSlider) { + volumeSlider.value = '100'; + } + if (volumePercentage) { + volumePercentage.textContent = '100%'; + } + updateVolumeIcon(100); } - // Update volume icon - updateVolumeIcon(100); - // Track unmute event volumeChanges++; sendPostMessage('video-unmuted', { current_time: player.getCurrentTime(), - volume: 100, + volume: isIOS ? 'N/A (iOS)' : 100, }); - console.log('Video unmuted, volume set to 100%'); + console.log('Video unmuted' + (isIOS ? ' (iOS - volume control unavailable)' : ', volume set to 100%')); }); - // Volume button (mute/unmute toggle) - volumeButton?.addEventListener('click', () => { - if (!player) return; - - const currentVolume = player.getVolume(); - - if (currentVolume === 0 || player.isMuted()) { - // Unmute - restore previous volume - const volumeToRestore = savedVolumeBeforeMute > 0 ? savedVolumeBeforeMute : 100; - player.unMute(); - player.setVolume(volumeToRestore); - - if (volumeSlider) { - volumeSlider.value = volumeToRestore.toString(); - } - if (volumePercentage) { - volumePercentage.textContent = `${volumeToRestore}%`; - } - - updateVolumeIcon(volumeToRestore); - - sendPostMessage('volume-unmuted', { - current_time: player.getCurrentTime(), - volume: volumeToRestore, - }); - - console.log(`Unmuted - Volume restored to: ${volumeToRestore}%`); - } else { - // Mute - save current volume first - savedVolumeBeforeMute = currentVolume; - player.setVolume(0); + // Volume button (mute/unmute toggle) - Skip on iOS + if (!isIOS) { + volumeButton?.addEventListener('click', () => { + if (!player) return; + + try { + const currentVolume = typeof player.getVolume === 'function' ? player.getVolume() : 100; + const isMuted = typeof player.isMuted === 'function' ? player.isMuted() : false; + + if (currentVolume === 0 || isMuted) { + // Unmute - restore previous volume + const volumeToRestore = savedVolumeBeforeMute > 0 ? savedVolumeBeforeMute : 100; + + if (typeof player.unMute === 'function') { + player.unMute(); + } + if (typeof player.setVolume === 'function') { + player.setVolume(volumeToRestore); + } + + if (volumeSlider) { + volumeSlider.value = volumeToRestore.toString(); + } + if (volumePercentage) { + volumePercentage.textContent = `${volumeToRestore}%`; + } + + updateVolumeIcon(volumeToRestore); + + sendPostMessage('volume-unmuted', { + current_time: player.getCurrentTime(), + volume: volumeToRestore, + }); + + console.log(`Unmuted - Volume restored to: ${volumeToRestore}%`); + } else { + // Mute - save current volume first + savedVolumeBeforeMute = currentVolume; + + if (typeof player.setVolume === 'function') { + player.setVolume(0); + } + + if (volumeSlider) { + volumeSlider.value = '0'; + } + if (volumePercentage) { + volumePercentage.textContent = '0%'; + } + + updateVolumeIcon(0); + + sendPostMessage('volume-muted', { + current_time: player.getCurrentTime(), + saved_volume: savedVolumeBeforeMute, + }); + + console.log(`Muted - Saved volume: ${savedVolumeBeforeMute}%`); + } - if (volumeSlider) { - volumeSlider.value = '0'; + volumeChanges++; + } catch (error) { + console.warn('Error toggling mute:', error); } - if (volumePercentage) { - volumePercentage.textContent = '0%'; - } - - updateVolumeIcon(0); - - sendPostMessage('volume-muted', { - current_time: player.getCurrentTime(), - saved_volume: savedVolumeBeforeMute, - }); - - console.log(`Muted - Saved volume: ${savedVolumeBeforeMute}%`); - } - - volumeChanges++; - }); + }); + } - // Volume slider change handler - volumeSlider?.addEventListener('input', (e) => { - if (!player) return; + // Volume slider change handler - Skip on iOS + if (!isIOS) { + volumeSlider?.addEventListener('input', (e) => { + if (!player) return; - const target = e.target as HTMLInputElement; - const volume = parseInt(target.value, 10); + try { + const target = e.target as HTMLInputElement; + const volume = parseInt(target.value, 10); - // Set volume - player.setVolume(volume); + // Set volume + if (typeof player.setVolume === 'function') { + player.setVolume(volume); + } - // Unmute if muted and volume > 0 - if (player.isMuted() && volume > 0) { - player.unMute(); - } + // Unmute if muted and volume > 0 + const isMuted = typeof player.isMuted === 'function' ? player.isMuted() : false; + if (isMuted && volume > 0 && typeof player.unMute === 'function') { + player.unMute(); + } - // Save volume for unmute (only if > 0) - if (volume > 0) { - savedVolumeBeforeMute = volume; - } + // Save volume for unmute (only if > 0) + if (volume > 0) { + savedVolumeBeforeMute = volume; + } - // Update percentage display - if (volumePercentage) { - volumePercentage.textContent = `${volume}%`; - } + // Update percentage display + if (volumePercentage) { + volumePercentage.textContent = `${volume}%`; + } - // Update volume icon - updateVolumeIcon(volume); + // Update volume icon + updateVolumeIcon(volume); - // Track volume changes (only significant changes) - if (Math.abs(volume - lastVolume) > 5) { - volumeChanges++; - lastVolume = volume; + // Track volume changes (only significant changes) + if (Math.abs(volume - lastVolume) > 5) { + volumeChanges++; + lastVolume = volume; - sendPostMessage('volume-changed', { - current_time: player.getCurrentTime(), - volume, - }); - } - }); + sendPostMessage('volume-changed', { + current_time: player.getCurrentTime(), + volume, + }); + } + } catch (error) { + console.warn('Error changing volume:', error); + } + }); + } // Update volume icon based on current volume function updateVolumeIcon(volume: number) { @@ -891,86 +938,120 @@ document.addEventListener('keydown', (e) => { return false; } - // Mute/Unmute with M key + // Mute/Unmute with M key - Skip on iOS if (e.key === 'm' || e.key === 'M') { e.preventDefault(); e.stopPropagation(); - const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; - const volumePercentage = document.getElementById('volumePercentage'); - const currentVolume = player.getVolume(); + // iOS doesn't support volume control via YouTube API + if (isIOSDevice()) { + console.log('Volume control not available on iOS'); + return false; + } - if (currentVolume === 0 || player.isMuted()) { - // Unmute - restore previous volume - const volumeToRestore = savedVolumeBeforeMute > 0 ? savedVolumeBeforeMute : 100; - player.unMute(); - player.setVolume(volumeToRestore); + try { + const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; + const volumePercentage = document.getElementById('volumePercentage'); + const currentVolume = typeof player.getVolume === 'function' ? player.getVolume() : 100; + const isMuted = typeof player.isMuted === 'function' ? player.isMuted() : false; - if (volumeSlider) { - volumeSlider.value = volumeToRestore.toString(); - } - if (volumePercentage) { - volumePercentage.textContent = `${volumeToRestore}%`; - } + if (currentVolume === 0 || isMuted) { + // Unmute - restore previous volume + const volumeToRestore = savedVolumeBeforeMute > 0 ? savedVolumeBeforeMute : 100; - updateVolumeIconState(volumeToRestore); - console.log(`Unmuted - Volume restored to: ${volumeToRestore}%`); - } else { - // Mute - save current volume first - savedVolumeBeforeMute = currentVolume; - player.setVolume(0); + if (typeof player.unMute === 'function') { + player.unMute(); + } + if (typeof player.setVolume === 'function') { + player.setVolume(volumeToRestore); + } - if (volumeSlider) { - volumeSlider.value = '0'; - } - if (volumePercentage) { - volumePercentage.textContent = '0%'; + if (volumeSlider) { + volumeSlider.value = volumeToRestore.toString(); + } + if (volumePercentage) { + volumePercentage.textContent = `${volumeToRestore}%`; + } + + updateVolumeIconState(volumeToRestore); + console.log(`Unmuted - Volume restored to: ${volumeToRestore}%`); + } else { + // Mute - save current volume first + savedVolumeBeforeMute = currentVolume; + + if (typeof player.setVolume === 'function') { + player.setVolume(0); + } + + if (volumeSlider) { + volumeSlider.value = '0'; + } + if (volumePercentage) { + volumePercentage.textContent = '0%'; + } + + updateVolumeIconState(0); + console.log(`Muted - Saved volume: ${savedVolumeBeforeMute}%`); } - updateVolumeIconState(0); - console.log(`Muted - Saved volume: ${savedVolumeBeforeMute}%`); + volumeChanges++; + } catch (error) { + console.warn('Error toggling mute with keyboard:', error); } - volumeChanges++; return false; } - // Volume control with arrow up/down + // Volume control with arrow up/down - Skip on iOS if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); - const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; - const volumePercentage = document.getElementById('volumePercentage'); - const currentVolume = player.getVolume(); - const newVolume = Math.min(100, currentVolume + 10); + // iOS doesn't support volume control via YouTube API + if (isIOSDevice()) { + console.log('Volume control not available on iOS'); + return false; + } - player.setVolume(newVolume); + try { + const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; + const volumePercentage = document.getElementById('volumePercentage'); + const currentVolume = typeof player.getVolume === 'function' ? player.getVolume() : 100; + const newVolume = Math.min(100, currentVolume + 10); - // Unmute if muted and volume > 0 - if (player.isMuted() && newVolume > 0) { - player.unMute(); - } + if (typeof player.setVolume === 'function') { + player.setVolume(newVolume); + } - // Save volume for unmute (only if > 0) - if (newVolume > 0) { - savedVolumeBeforeMute = newVolume; - } + // Unmute if muted and volume > 0 + const isMuted = typeof player.isMuted === 'function' ? player.isMuted() : false; + if (isMuted && newVolume > 0 && typeof player.unMute === 'function') { + player.unMute(); + } - if (volumeSlider) { - volumeSlider.value = newVolume.toString(); - } - if (volumePercentage) { - volumePercentage.textContent = `${newVolume}%`; - } + // Save volume for unmute (only if > 0) + if (newVolume > 0) { + savedVolumeBeforeMute = newVolume; + } - updateVolumeIconState(newVolume); + if (volumeSlider) { + volumeSlider.value = newVolume.toString(); + } + if (volumePercentage) { + volumePercentage.textContent = `${newVolume}%`; + } - if (Math.abs(newVolume - lastVolume) > 5) { - volumeChanges++; - lastVolume = newVolume; + updateVolumeIconState(newVolume); + + if (Math.abs(newVolume - lastVolume) > 5) { + volumeChanges++; + lastVolume = newVolume; + } + console.log(`Volume: ${newVolume}%`); + } catch (error) { + console.warn('Error changing volume with keyboard:', error); } - console.log(`Volume: ${newVolume}%`); + return false; } @@ -978,32 +1059,45 @@ document.addEventListener('keydown', (e) => { e.preventDefault(); e.stopPropagation(); - const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; - const volumePercentage = document.getElementById('volumePercentage'); - const currentVolume = player.getVolume(); - const newVolume = Math.max(0, currentVolume - 10); + // iOS doesn't support volume control via YouTube API + if (isIOSDevice()) { + console.log('Volume control not available on iOS'); + return false; + } - player.setVolume(newVolume); + try { + const volumeSlider = document.getElementById('volumeSlider') as HTMLInputElement; + const volumePercentage = document.getElementById('volumePercentage'); + const currentVolume = typeof player.getVolume === 'function' ? player.getVolume() : 100; + const newVolume = Math.max(0, currentVolume - 10); - // Save volume for unmute (only if > 0) - if (newVolume > 0) { - savedVolumeBeforeMute = newVolume; - } + if (typeof player.setVolume === 'function') { + player.setVolume(newVolume); + } - if (volumeSlider) { - volumeSlider.value = newVolume.toString(); - } - if (volumePercentage) { - volumePercentage.textContent = `${newVolume}%`; - } + // Save volume for unmute (only if > 0) + if (newVolume > 0) { + savedVolumeBeforeMute = newVolume; + } + + if (volumeSlider) { + volumeSlider.value = newVolume.toString(); + } + if (volumePercentage) { + volumePercentage.textContent = `${newVolume}%`; + } - updateVolumeIconState(newVolume); + updateVolumeIconState(newVolume); - if (Math.abs(newVolume - lastVolume) > 5) { - volumeChanges++; - lastVolume = newVolume; + if (Math.abs(newVolume - lastVolume) > 5) { + volumeChanges++; + lastVolume = newVolume; + } + console.log(`Volume: ${newVolume}%`); + } catch (error) { + console.warn('Error changing volume with keyboard:', error); } - console.log(`Volume: ${newVolume}%`); + return false; } From 94d064c20d174689d6060c84c59852b6afa9fdf5 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 15:03:58 +0400 Subject: [PATCH 34/40] feat: update page title and description for consistency across ads --- pages/ads/index.html | 4 ++-- pages/ads/main.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pages/ads/index.html b/pages/ads/index.html index 23151b7..ba3466c 100644 --- a/pages/ads/index.html +++ b/pages/ads/index.html @@ -7,8 +7,8 @@ - Doctorina | Ads - + Doctorina | Watch Ad + diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 760d167..1a68891 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -13,7 +13,7 @@ const translations = { // Language: English lang: 'en', // Page - pageTitle: 'Watch Ad - Doctorina', + pageTitle: 'Doctorina | Watch Ad', // Progress remaining: 'Remaining:', // Warnings @@ -36,7 +36,7 @@ const translations = { // Язык: Русский lang: 'ru', // Страница - pageTitle: 'Просмотр рекламы - Doctorina', + pageTitle: 'Doctorina | Просмотр рекламы', // Прогресс remaining: 'Осталось:', // Предупреждения @@ -59,7 +59,7 @@ const translations = { // Idioma: Español lang: 'es', // Página - pageTitle: 'Ver anuncio - Doctorina', + pageTitle: 'Doctorina | Ver anuncio', // Progreso remaining: 'Restante:', // Advertencias @@ -82,7 +82,7 @@ const translations = { // Sprache: Deutsch lang: 'de', // Seite - pageTitle: 'Werbung ansehen - Doctorina', + pageTitle: 'Doctorina | Werbung ansehen', // Fortschritt remaining: 'Verbleibend:', // Warnungen @@ -114,7 +114,7 @@ function detectLanguage(): keyof typeof translations { function isIOSDevice(): boolean { const userAgent = navigator.userAgent.toLowerCase(); return /iphone|ipad|ipod/.test(userAgent) || - (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPad on iOS 13+ + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPad on iOS 13+ } // Get current language and translations From b4535b467e90bdf75a64c7e29990994d35630667 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 21:02:24 +0400 Subject: [PATCH 35/40] feat(stripe-success): add environment parameter to Stripe success handling and log --- pages/stripe-success/main.ts | 60 ++++++++++++++++++++++++++++++++++- pages/stripe-success/utils.ts | 9 +++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/pages/stripe-success/main.ts b/pages/stripe-success/main.ts index ac7999e..de152ed 100644 --- a/pages/stripe-success/main.ts +++ b/pages/stripe-success/main.ts @@ -1,6 +1,6 @@ import { initPage } from '~/shared/utils/page-init'; -import { extractStripeSuccessParams, formatStripeSuccessLog } from './utils'; import './style.css'; +import { extractStripeSuccessParams, formatStripeSuccessLog } from './utils'; // Internationalization (i18n) - Inline translations const translations = { @@ -186,6 +186,64 @@ if (app) { // Log extracted parameters console.log(formatStripeSuccessLog(params)); + // Send POST callback with payment status + async function sendCallback() { + // Determine callback URL based on environment and purchase ID + function getCallbackUrl(): string | undefined { + let purchaseId = params.purchaseId; + if (!purchaseId) return undefined; + switch (params.environment) { + case 'stg': + case 'stage': + case 'staging': + return `https://staging.api.doctorina.com/v1/subscriptions/${purchaseId}/sync`; + case 'prod': + case 'live': + case 'production': + return `https://live.api.doctorina.com/v1/subscriptions/${purchaseId}/sync`; + default: + return undefined; + } + } + + const callbackUrl = getCallbackUrl(); + if (!callbackUrl) { + console.log('Callback URL not determined, skipping callback'); + return; + } + + const body: { checkout_id?: string; status?: string; source: string } = { + source: 'stripe', + status: 'success' + }; + + if (params.checkoutId) + body.checkout_id = params.checkoutId; + + if (params.type) + body.status = params.type; + + try { + const response = await fetch(callbackUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (response.ok) { + console.log('Callback sent successfully'); + } else { + console.error('Callback failed with status:', response.status); + } + } catch (error) { + console.error('Error sending callback:', error); + } + } + + sendCallback(); + // Safely decode and validate redirect URL const redirectParam = params.redirectUrl; let redirectUrl = 'https://app.doctorina.com'; diff --git a/pages/stripe-success/utils.ts b/pages/stripe-success/utils.ts index 22487af..e5ccc24 100644 --- a/pages/stripe-success/utils.ts +++ b/pages/stripe-success/utils.ts @@ -10,6 +10,8 @@ export interface StripeSuccessParams { type: string | null; /** Redirect URL after success */ redirectUrl: string | null; + /** Environment (e.g. "production" or "development") */ + environment: string | null; } /** @@ -20,6 +22,7 @@ export interface StripeSuccessParams { * - p / purchase_id: Purchase ID * - t / type: Payment type * - r / redirect: Redirect URL + * - e / environment: Environment * * @param searchParams - URLSearchParams object to parse * @returns Object containing extracted parameters @@ -33,7 +36,8 @@ export interface StripeSuccessParams { * // checkoutId: '{CHECKOUT_SESSION_ID}', * // purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', * // type: 'subscription', - * // redirectUrl: 'https://doctorina-development.web.app/' + * // redirectUrl: 'https://doctorina-development.web.app/', + * // environment: 'production' * // } * ``` */ @@ -47,6 +51,8 @@ export function extractStripeSuccessParams(searchParams: URLSearchParams): Strip type: searchParams.get('t') || searchParams.get('type'), // Redirect URL redirectUrl: searchParams.get('r') || searchParams.get('redirect'), + // Environment + environment: searchParams.get('e') || searchParams.get('environment'), }; } @@ -61,5 +67,6 @@ export function formatStripeSuccessLog(params: StripeSuccessParams): string { buffer.push(`Type: ${params.type || 'N/A'}`); buffer.push(`Checkout ID: ${params.checkoutId || 'N/A'}`); buffer.push(`Purchase ID: ${params.purchaseId || 'N/A'}`); + buffer.push(`Environment: ${params.environment || 'N/A'}`); return buffer.join('\n'); } From 4fd12dffcaf414a88e16a1aff67f288b500a762b Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 21:09:58 +0400 Subject: [PATCH 36/40] feat(stripe-success): update checkoutId handling and refine parameter descriptions --- pages/ads/main.ts | 2 +- pages/stripe-success/main.ts | 6 +++--- pages/stripe-success/utils.ts | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 1a68891..2bf633a 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -176,7 +176,7 @@ declare global { // Configuration from ENV and URL params const urlParams = new URLSearchParams(window.location.search); -const VIDEO_ID = urlParams.get('v') || urlParams.get('video') || import.meta.env.VITE_DEFAULT_VIDEO_ID || '8fy94RQnnzw'; +const VIDEO_ID = urlParams.get('v') || urlParams.get('video') || import.meta.env.VITE_DEFAULT_VIDEO_ID || 'oE0DHbq-CJQ'; // Normalize and validate callback URL function normalizeCallbackUrl(url: string | null): string { diff --git a/pages/stripe-success/main.ts b/pages/stripe-success/main.ts index de152ed..0185b94 100644 --- a/pages/stripe-success/main.ts +++ b/pages/stripe-success/main.ts @@ -217,12 +217,12 @@ if (app) { status: 'success' }; - if (params.checkoutId) - body.checkout_id = params.checkoutId; - if (params.type) body.status = params.type; + if (params.checkoutId) + body.checkout_id = params.checkoutId; + try { const response = await fetch(callbackUrl, { method: 'POST', diff --git a/pages/stripe-success/utils.ts b/pages/stripe-success/utils.ts index e5ccc24..05b598a 100644 --- a/pages/stripe-success/utils.ts +++ b/pages/stripe-success/utils.ts @@ -4,13 +4,13 @@ export interface StripeSuccessParams { /** Checkout Stripe Session ID */ checkoutId: string | null; - /** Purchase ID (e.g. for subscriptions or one-time payments) */ + /** Purchase ID (e.g. for subscriptions or donation payments) */ purchaseId: string | null; - /** Payment type (e.g. "subscription" or "one-time") */ + /** Payment type (e.g. "subscription" or "donation") */ type: string | null; /** Redirect URL after success */ redirectUrl: string | null; - /** Environment (e.g. "production" or "development") */ + /** Environment (e.g. "live" or "stage") */ environment: string | null; } @@ -37,7 +37,7 @@ export interface StripeSuccessParams { * // purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', * // type: 'subscription', * // redirectUrl: 'https://doctorina-development.web.app/', - * // environment: 'production' + * // environment: 'live' * // } * ``` */ From fbfee00a5a6c913b0cb58b1bfe92079e860b50d2 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 21:20:01 +0400 Subject: [PATCH 37/40] feat(stripe-success): enhance parameter handling by adding 'type' and 'environment' fields --- pages/stripe-success/main.ts | 4 +- pages/stripe-success/utils.test.ts | 332 +++++++++++++++-------------- 2 files changed, 175 insertions(+), 161 deletions(-) diff --git a/pages/stripe-success/main.ts b/pages/stripe-success/main.ts index 0185b94..589c614 100644 --- a/pages/stripe-success/main.ts +++ b/pages/stripe-success/main.ts @@ -212,13 +212,13 @@ if (app) { return; } - const body: { checkout_id?: string; status?: string; source: string } = { + const body: { source: string; status?: string; type?: string; checkout_id?: string; } = { source: 'stripe', status: 'success' }; if (params.type) - body.status = params.type; + body.type = params.type; if (params.checkoutId) body.checkout_id = params.checkoutId; diff --git a/pages/stripe-success/utils.test.ts b/pages/stripe-success/utils.test.ts index d6e83f0..b49f909 100644 --- a/pages/stripe-success/utils.test.ts +++ b/pages/stripe-success/utils.test.ts @@ -12,6 +12,7 @@ describe('extractStripeSuccessParams', () => { purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', checkoutId: 'sess_123abc', redirectUrl: 'https://doctorina.com', + environment: null, }); }); @@ -25,6 +26,7 @@ describe('extractStripeSuccessParams', () => { purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', checkoutId: '{CHECKOUT_SESSION_ID}', redirectUrl: 'https://doctorina-development.web.app/', + environment: null, }); }); @@ -38,6 +40,7 @@ describe('extractStripeSuccessParams', () => { purchaseId: '019ba249-294b-76d3-b229-3f28471cd8e3', checkoutId: 'cs_test_a1cOsk4fVc9ZiVcXpYNBdJEfs8iN0KUTQsIM3JxwRbVxpSyxmfERa8IZDD', redirectUrl: 'https://doctorina-development.web.app/', + environment: null, }); }); @@ -47,197 +50,208 @@ describe('extractStripeSuccessParams', () => { expect(result.type).toBe('one-time'); }); - }); - - describe('long parameter names', () => { - it('should extract all parameters using long names', () => { - const searchParams = new URLSearchParams('type=subscription&purchase_id=019ba22b-e088-7a70-bae3-caeefe9c9aa5&checkout_id=sess_123abc&redirect=https://doctorina.com'); - const result = extractStripeSuccessParams(searchParams); - expect(result).toEqual({ - type: 'subscription', - purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', - checkoutId: 'sess_123abc', - redirectUrl: 'https://doctorina.com', + describe('long parameter names', () => { + it('should extract all parameters using long names', () => { + const searchParams = new URLSearchParams('type=subscription&purchase_id=019ba22b-e088-7a70-bae3-caeefe9c9aa5&checkout_id=sess_123abc&redirect=https://doctorina.com'); + const result = extractStripeSuccessParams(searchParams); + + expect(result).toEqual({ + type: 'subscription', + purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + checkoutId: 'sess_123abc', + redirectUrl: 'https://doctorina.com', + environment: null, + }); }); }); - }); - describe('mixed parameter names', () => { - it('should prioritize short names when both present', () => { - const searchParams = new URLSearchParams('t=subscription&type=one-time&p=purchase_1&purchase_id=purchase_2'); - const result = extractStripeSuccessParams(searchParams); + describe('mixed parameter names', () => { + it('should prioritize short names when both present', () => { + const searchParams = new URLSearchParams('t=subscription&type=one-time&p=purchase_1&purchase_id=purchase_2'); + const result = extractStripeSuccessParams(searchParams); - expect(result.type).toBe('subscription'); // short name 't' wins - expect(result.purchaseId).toBe('purchase_1'); // short name 'p' wins - }); + expect(result.type).toBe('subscription'); // short name 't' wins + expect(result.purchaseId).toBe('purchase_1'); // short name 'p' wins + }); - it('should use long name as fallback when short name missing', () => { - const searchParams = new URLSearchParams('type=subscription&p=purchase_123&redirect=https://doctorina.com'); - const result = extractStripeSuccessParams(searchParams); + it('should use long name as fallback when short name missing', () => { + const searchParams = new URLSearchParams('type=subscription&p=purchase_123&redirect=https://doctorina.com'); + const result = extractStripeSuccessParams(searchParams); - expect(result.type).toBe('subscription'); - expect(result.purchaseId).toBe('purchase_123'); - expect(result.redirectUrl).toBe('https://doctorina.com'); + expect(result.type).toBe('subscription'); + expect(result.purchaseId).toBe('purchase_123'); + expect(result.redirectUrl).toBe('https://doctorina.com'); + }); }); - }); - - describe('missing parameters', () => { - it('should return null for missing parameters', () => { - const searchParams = new URLSearchParams(''); - const result = extractStripeSuccessParams(searchParams); - expect(result).toEqual({ - type: null, - purchaseId: null, - checkoutId: null, - redirectUrl: null, + describe('missing parameters', () => { + it('should return null for missing parameters', () => { + const searchParams = new URLSearchParams(''); + const result = extractStripeSuccessParams(searchParams); + + expect(result).toEqual({ + type: null, + purchaseId: null, + checkoutId: null, + redirectUrl: null, + environment: null, + }); }); - }); - it('should return null for individual missing parameters', () => { - const searchParams = new URLSearchParams('t=subscription&p=purchase_123'); - const result = extractStripeSuccessParams(searchParams); + it('should return null for individual missing parameters', () => { + const searchParams = new URLSearchParams('t=subscription&p=purchase_123'); + const result = extractStripeSuccessParams(searchParams); - expect(result.type).toBe('subscription'); - expect(result.purchaseId).toBe('purchase_123'); - expect(result.checkoutId).toBeNull(); - expect(result.redirectUrl).toBeNull(); + expect(result.type).toBe('subscription'); + expect(result.purchaseId).toBe('purchase_123'); + expect(result.checkoutId).toBeNull(); + expect(result.redirectUrl).toBeNull(); + expect(result.environment).toBeNull(); + }); }); - }); - describe('special characters and encoding', () => { - it('should handle URL-encoded values', () => { - const searchParams = new URLSearchParams('r=https%3A%2F%2Fdoctorina.com%2Fpath%3Fquery%3Dvalue'); - const result = extractStripeSuccessParams(searchParams); + describe('special characters and encoding', () => { + it('should handle URL-encoded values', () => { + const searchParams = new URLSearchParams('r=https%3A%2F%2Fdoctorina.com%2Fpath%3Fquery%3Dvalue'); + const result = extractStripeSuccessParams(searchParams); - expect(result.redirectUrl).toBe('https://doctorina.com/path?query=value'); - }); + expect(result.redirectUrl).toBe('https://doctorina.com/path?query=value'); + }); - it('should handle curly braces in checkout ID', () => { - const searchParams = new URLSearchParams('c=%7BCHECKOUT_SESSION_ID%7D'); - const result = extractStripeSuccessParams(searchParams); + it('should handle curly braces in checkout ID', () => { + const searchParams = new URLSearchParams('c=%7BCHECKOUT_SESSION_ID%7D'); + const result = extractStripeSuccessParams(searchParams); - expect(result.checkoutId).toBe('{CHECKOUT_SESSION_ID}'); - }); + expect(result.checkoutId).toBe('{CHECKOUT_SESSION_ID}'); + }); - it('should handle UUIDs in purchase ID', () => { - const searchParams = new URLSearchParams('p=019ba22b-e088-7a70-bae3-caeefe9c9aa5'); - const result = extractStripeSuccessParams(searchParams); + it('should handle UUIDs in purchase ID', () => { + const searchParams = new URLSearchParams('p=019ba22b-e088-7a70-bae3-caeefe9c9aa5'); + const result = extractStripeSuccessParams(searchParams); - expect(result.purchaseId).toBe('019ba22b-e088-7a70-bae3-caeefe9c9aa5'); - }); + expect(result.purchaseId).toBe('019ba22b-e088-7a70-bae3-caeefe9c9aa5'); + }); - it('should handle redirect URL with special characters', () => { - const encodedUrl = encodeURIComponent('https://doctorina-development.web.app/?session=abc&user=123'); - const searchParams = new URLSearchParams(`r=${encodedUrl}`); - const result = extractStripeSuccessParams(searchParams); + it('should handle redirect URL with special characters', () => { + const encodedUrl = encodeURIComponent('https://doctorina-development.web.app/?session=abc&user=123'); + const searchParams = new URLSearchParams(`r=${encodedUrl}`); + const result = extractStripeSuccessParams(searchParams); - expect(result.redirectUrl).toBe('https://doctorina-development.web.app/?session=abc&user=123'); + expect(result.redirectUrl).toBe('https://doctorina-development.web.app/?session=abc&user=123'); + }); }); - }); - - describe('edge cases', () => { - it('should handle empty string values as null', () => { - // Note: URLSearchParams.get() returns empty string for parameters with no value, - // but our function treats empty strings as missing (null) due to || operator - const searchParams = new URLSearchParams('t=&p=&c=&r='); - const result = extractStripeSuccessParams(searchParams); - // Empty string parameters are treated as null (falsy || fallback behavior) - expect(result.type).toBeNull(); - expect(result.purchaseId).toBeNull(); - expect(result.checkoutId).toBeNull(); - expect(result.redirectUrl).toBeNull(); - }); + describe('edge cases', () => { + it('should handle empty string values as null', () => { + // Note: URLSearchParams.get() returns empty string for parameters with no value, + // but our function treats empty strings as missing (null) due to || operator + const searchParams = new URLSearchParams('t=&p=&c=&r='); + const result = extractStripeSuccessParams(searchParams); + + // Empty string parameters are treated as null (falsy || fallback behavior) + expect(result.type).toBeNull(); + expect(result.purchaseId).toBeNull(); + expect(result.checkoutId).toBeNull(); + expect(result.redirectUrl).toBeNull(); + }); - it('should handle parameters with spaces', () => { - const searchParams = new URLSearchParams('t=one time payment'); - const result = extractStripeSuccessParams(searchParams); + it('should handle parameters with spaces', () => { + const searchParams = new URLSearchParams('t=one time payment'); + const result = extractStripeSuccessParams(searchParams); - expect(result.type).toBe('one time payment'); - }); + expect(result.type).toBe('one time payment'); + }); - it('should handle very long parameter values', () => { - const longId = 'a'.repeat(500); - const searchParams = new URLSearchParams(`p=${longId}`); - const result = extractStripeSuccessParams(searchParams); + it('should handle very long parameter values', () => { + const longId = 'a'.repeat(500); + const searchParams = new URLSearchParams(`p=${longId}`); + const result = extractStripeSuccessParams(searchParams); - expect(result.purchaseId).toBe(longId); + expect(result.purchaseId).toBe(longId); + }); }); }); -}); -describe('formatStripeSuccessLog', () => { - it('should format all parameters correctly', () => { - const params = { - type: 'subscription', - purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', - checkoutId: 'sess_123abc', - redirectUrl: 'https://doctorina.com', - }; - - const result = formatStripeSuccessLog(params); - - expect(result).toBe( - 'Stripe Success Page\n' + - 'Type: subscription\n' + - 'Checkout ID: sess_123abc\n' + - 'Purchase ID: 019ba22b-e088-7a70-bae3-caeefe9c9aa5' - ); - }); + describe('formatStripeSuccessLog', () => { + it('should format all parameters correctly', () => { + const params = { + type: 'subscription', + purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + checkoutId: 'sess_123abc', + redirectUrl: 'https://doctorina.com', + environment: 'production', + }; + + const result = formatStripeSuccessLog(params); + + expect(result).toBe( + 'Stripe Success Page\n' + + 'Type: subscription\n' + + 'Checkout ID: sess_123abc\n' + + 'Purchase ID: 019ba22b-e088-7a70-bae3-caeefe9c9aa5\n' + + 'Environment: production' + ); + }); - it('should show N/A for null values', () => { - const params = { - type: null, - purchaseId: null, - checkoutId: null, - redirectUrl: null, - }; - - const result = formatStripeSuccessLog(params); - - expect(result).toBe( - 'Stripe Success Page\n' + - 'Type: N/A\n' + - 'Checkout ID: N/A\n' + - 'Purchase ID: N/A' - ); - }); + it('should show N/A for null values', () => { + const params = { + type: null, + purchaseId: null, + checkoutId: null, + redirectUrl: null, + environment: null, + }; + + const result = formatStripeSuccessLog(params); + + expect(result).toBe( + 'Stripe Success Page\n' + + 'Type: N/A\n' + + 'Checkout ID: N/A\n' + + 'Purchase ID: N/A\n' + + 'Environment: N/A' + ); + }); - it('should handle mixed null and valid values', () => { - const params = { - type: 'one-time', - purchaseId: 'purchase_xyz', - checkoutId: null, - redirectUrl: null, - }; - - const result = formatStripeSuccessLog(params); - - expect(result).toBe( - 'Stripe Success Page\n' + - 'Type: one-time\n' + - 'Checkout ID: N/A\n' + - 'Purchase ID: purchase_xyz' - ); - }); + it('should handle mixed null and valid values', () => { + const params = { + type: 'one-time', + purchaseId: 'purchase_xyz', + checkoutId: null, + redirectUrl: null, + environment: null, + }; + + const result = formatStripeSuccessLog(params); + + expect(result).toBe( + 'Stripe Success Page\n' + + 'Type: one-time\n' + + 'Checkout ID: N/A\n' + + 'Purchase ID: purchase_xyz\n' + + 'Environment: N/A' + ); + }); - it('should output format compatible with console.log', () => { - const params = { - type: 'subscription', - purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', - checkoutId: '{CHECKOUT_SESSION_ID}', - redirectUrl: 'https://doctorina-development.web.app/', - }; - - const result = formatStripeSuccessLog(params); - - // Verify that newlines are properly embedded - expect(result.split('\n')).toHaveLength(4); - expect(result).toContain('Stripe Success Page'); - expect(result).toContain('Type: subscription'); - expect(result).toContain('Checkout ID: {CHECKOUT_SESSION_ID}'); - expect(result).toContain('Purchase ID: 019ba22b-e088-7a70-bae3-caeefe9c9aa5'); + it('should output format compatible with console.log', () => { + const params = { + type: 'subscription', + purchaseId: '019ba22b-e088-7a70-bae3-caeefe9c9aa5', + checkoutId: '{CHECKOUT_SESSION_ID}', + redirectUrl: 'https://doctorina-development.web.app/', + environment: 'production', + }; + + const result = formatStripeSuccessLog(params); + + // Verify that newlines are properly embedded + expect(result.split('\n')).toHaveLength(5); + expect(result).toContain('Stripe Success Page'); + expect(result).toContain('Type: subscription'); + expect(result).toContain('Checkout ID: {CHECKOUT_SESSION_ID}'); + expect(result).toContain('Purchase ID: 019ba22b-e088-7a70-bae3-caeefe9c9aa5'); + expect(result).toContain('Environment: production'); + }); }); }); From 2c6dd90b3cde61f531d810bea6353eb9818d84a7 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Fri, 9 Jan 2026 21:29:48 +0400 Subject: [PATCH 38/40] feat(audio-controls): prevent event bubbling in unmute button handler --- pages/ads/main.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 2bf633a..b5d3c67 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -614,7 +614,11 @@ function initializeAudioControls() { } // Unmute button click handler - unmuteButton?.addEventListener('click', () => { + unmuteButton?.addEventListener('click', (e) => { + // Prevent event from bubbling up and affecting video player + e.preventDefault(); + e.stopPropagation(); + if (!player) return; // Unmute and set volume (with safety checks for iOS) From 8ac943f93829dd2046059f577a1c95292a240c0a Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 14 Jan 2026 17:42:02 +0400 Subject: [PATCH 39/40] feat(audio-controls): enhance unmute overlay interaction by preventing event propagation --- pages/ads/main.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index b5d3c67..f4415cd 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -613,11 +613,19 @@ function initializeAudioControls() { console.log('iOS detected - volume controls hidden (not supported by YouTube API)'); } + // Prevent any clicks on unmute overlay from affecting the video player + unmuteOverlay?.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }, true); // Use capture phase + // Unmute button click handler unmuteButton?.addEventListener('click', (e) => { // Prevent event from bubbling up and affecting video player e.preventDefault(); e.stopPropagation(); + e.stopImmediatePropagation(); if (!player) return; @@ -655,7 +663,7 @@ function initializeAudioControls() { }); console.log('Video unmuted' + (isIOS ? ' (iOS - volume control unavailable)' : ', volume set to 100%')); - }); + }, true); // Use capture phase // Volume button (mute/unmute toggle) - Skip on iOS if (!isIOS) { From 1f04cd11ec11f309d3cc8499799f56aa9e8c7f25 Mon Sep 17 00:00:00 2001 From: Mikhail Matiunin Date: Wed, 14 Jan 2026 18:35:34 +0400 Subject: [PATCH 40/40] feat(audio-controls): improve unmute button interaction by consolidating event handlers and preventing overlay clicks from affecting video player --- pages/ads/main.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pages/ads/main.ts b/pages/ads/main.ts index f4415cd..4e5c118 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -613,15 +613,8 @@ function initializeAudioControls() { console.log('iOS detected - volume controls hidden (not supported by YouTube API)'); } - // Prevent any clicks on unmute overlay from affecting the video player - unmuteOverlay?.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }, true); // Use capture phase - // Unmute button click handler - unmuteButton?.addEventListener('click', (e) => { + const handleUnmuteClick = (e: MouseEvent | TouchEvent) => { // Prevent event from bubbling up and affecting video player e.preventDefault(); e.stopPropagation(); @@ -663,7 +656,19 @@ function initializeAudioControls() { }); console.log('Video unmuted' + (isIOS ? ' (iOS - volume control unavailable)' : ', volume set to 100%')); - }, true); // Use capture phase + }; + + // Add click handler for unmute button (both click and touch events) + unmuteButton?.addEventListener('click', handleUnmuteClick, true); + unmuteButton?.addEventListener('touchend', handleUnmuteClick, true); + + // Prevent clicks on overlay itself from reaching video player + unmuteOverlay?.addEventListener('mousedown', (e) => { + e.stopPropagation(); + }, true); + unmuteOverlay?.addEventListener('touchstart', (e) => { + e.stopPropagation(); + }, true); // Volume button (mute/unmute toggle) - Skip on iOS if (!isIOS) {