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/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/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" } ] } 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/index.html b/pages/ads/index.html index e198a3b..ba3466c 100644 --- a/pages/ads/index.html +++ b/pages/ads/index.html @@ -3,11 +3,22 @@ + - - - Ads - Doctorina - + + + Doctorina | Watch Ad + + + + + + + + + + + diff --git a/pages/ads/main.ts b/pages/ads/main.ts index 89cfcc0..4e5c118 100644 --- a/pages/ads/main.ts +++ b/pages/ads/main.ts @@ -1,18 +1,1291 @@ +// TODO(plugfox): +// [ ] 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 +// [x] Auto start YouTube video (muted for reliable autoplay) + import { initPage } from '~/shared/utils/page-init'; import './style.css'; -initPage('Ads - Doctorina'); +// Internationalization (i18n) - Inline translations +const translations = { + en: { + // Language: English + lang: 'en', + // Page + pageTitle: 'Doctorina | Watch Ad', + // 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', + // Audio controls + unmuteVideo: 'Tap to enable sound', + volumeLabel: 'Volume', + muteButton: 'Mute', + unmuteButton: 'Unmute', + }, + ru: { + // Язык: Русский + lang: 'ru', + // Страница + pageTitle: 'Doctorina | Просмотр рекламы', + // Прогресс + remaining: 'Осталось:', + // Предупреждения + watchToEnd: 'Пожалуйста, посмотрите видео до конца без пропусков', + doNotSkip: 'Не перематывайте! Видео начнется сначала.', + keyboardDisabled: 'Управление с клавиатуры отключено', + // Загрузка + loadingVideo: 'Загрузка видео...', + videoLoadError: 'Не удалось загрузить видео. Пожалуйста, обновите страницу.', + // Подсказки и Aria + videoPlayerLabel: 'Видеоплеер рекламы', + progressBarLabel: 'Прогресс видео', + // Управление звуком + unmuteVideo: 'Нажмите для включения звука', + volumeLabel: 'Громкость', + muteButton: 'Выключить звук', + unmuteButton: 'Включить звук', + }, + es: { + // Idioma: Español + lang: 'es', + // Página + pageTitle: 'Doctorina | Ver anuncio', + // 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', + // Controles de audio + unmuteVideo: 'Toca para activar el sonido', + volumeLabel: 'Volumen', + muteButton: 'Silenciar', + unmuteButton: 'Activar sonido', + }, + de: { + // Sprache: Deutsch + lang: 'de', + // Seite + pageTitle: 'Doctorina | Werbung ansehen', + // 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', + // Audiosteuerung + unmuteVideo: 'Tippen Sie, um den Ton zu aktivieren', + volumeLabel: 'Lautstärke', + muteButton: 'Stumm schalten', + unmuteButton: 'Ton aktivieren', + }, +}; + +// 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'; +} + +// 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]; + +// Update HTML lang attribute +document.documentElement.lang = currentLang; + +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; +} + +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; +} + +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 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 || 'oE0DHbq-CJQ'; + +// 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('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; +let maxWatchedTime = 0; +let isVideoCompleted = false; +let seekAttempts = 0; +let pauseCount = 0; +let wasTabActive = true; +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 savedVolumeBeforeMute = 100; // Save volume before muting +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(); + +// 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, + 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, +}); + +// 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 = ` +
+
+

${t.loadingVideo}

+
+
+
+ + +
+
- `; + +
+
+
+
+
+
+
+ +
+ + + 100% +
+ + +
+ ${t.remaining} + --:-- + · + 0% +
+
+
+
+ +
+
+`; + +// 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, + 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); + } + + // 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(); + + // Initialize audio controls + initializeAudioControls(); + + // 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 }) { + const { YT } = window; + + if (event.data === YT.PlayerState.PAUSED) { + pauseCount++; + lastPauseTime = Date.now(); + sendPostMessage('video-paused', { + current_time: event.target.getCurrentTime(), + pause_count: pauseCount, + }); + } + + 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(), + }); + } + + 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); + + sendPostMessage('video-error', { + error_code: event.data, + player_error_count: playerErrorCount, + }); + + const loadingScreen = document.getElementById('loadingScreen'); + if (loadingScreen) { + loadingScreen.innerHTML = ` +
⚠️
+

${t.videoLoadError}

+ `; + } +} + +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++; + sendPostMessage('seek-attempt-blocked', { + attempted_time: currentTime, + max_watched_time: maxWatchedTime, + seek_attempts: seekAttempts, + }); + showWarning(t.doNotSkip); + 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'); + 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, + }); + } + }); +} + +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); +} + +// ===== AUDIO CONTROLS ===== + +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 + const handleUnmuteClick = (e: MouseEvent | TouchEvent) => { + // Prevent event from bubbling up and affecting video player + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + if (!player) return; + + // 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 (only if not iOS) + if (!isIOS) { + if (volumeSlider) { + volumeSlider.value = '100'; + } + if (volumePercentage) { + volumePercentage.textContent = '100%'; + } + updateVolumeIcon(100); + } + + // Track unmute event + volumeChanges++; + sendPostMessage('video-unmuted', { + current_time: player.getCurrentTime(), + volume: isIOS ? 'N/A (iOS)' : 100, + }); + + console.log('Video unmuted' + (isIOS ? ' (iOS - volume control unavailable)' : ', volume set to 100%')); + }; + + // 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) { + 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}%`); + } + + volumeChanges++; + } catch (error) { + console.warn('Error toggling mute:', error); + } + }); + } + + // Volume slider change handler - Skip on iOS + if (!isIOS) { + volumeSlider?.addEventListener('input', (e) => { + if (!player) return; + + try { + const target = e.target as HTMLInputElement; + const volume = parseInt(target.value, 10); + + // Set volume + if (typeof player.setVolume === 'function') { + player.setVolume(volume); + } + + // 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; + } + + // 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, + }); + } + } catch (error) { + console.warn('Error changing volume:', error); + } + }); + } + + // 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(), + }); + + // Send callback if session exists + if (sessionId) { + await sendCallback(); + } + + // Wait a short pause before auto-closing + await new Promise(resolve => setTimeout(resolve, 500)); + + // 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, + }; +} + +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)); + } + } + } +} + +// 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; + } + + // Mute/Unmute with M key - Skip on iOS + if (e.key === 'm' || e.key === 'M') { + e.preventDefault(); + e.stopPropagation(); + + // iOS doesn't support volume control via YouTube API + if (isIOSDevice()) { + console.log('Volume control not available on iOS'); + return false; + } + + 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 (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}%`; + } + + 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}%`); + } + + volumeChanges++; + } catch (error) { + console.warn('Error toggling mute with keyboard:', error); + } + + return false; + } + + // Volume control with arrow up/down - Skip on iOS + if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + + // iOS doesn't support volume control via YouTube API + if (isIOSDevice()) { + console.log('Volume control not available on iOS'); + return false; + } + + 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); + + if (typeof player.setVolume === 'function') { + player.setVolume(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(); + } + + // 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; + } + console.log(`Volume: ${newVolume}%`); + } catch (error) { + console.warn('Error changing volume with keyboard:', error); + } + + return false; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + + // iOS doesn't support volume control via YouTube API + if (isIOSDevice()) { + console.log('Volume control not available on iOS'); + return false; + } + + 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); + + if (typeof player.setVolume === 'function') { + 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; + } + console.log(`Volume: ${newVolume}%`); + } catch (error) { + console.warn('Error changing volume with keyboard:', error); + } + + return false; + } + + // Block all other video control keys + const blockedKeys = [ + 'ArrowLeft', 'ArrowRight', + 'Home', 'End', + 'PageUp', 'PageDown', + 'j', 'l', '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; + } +}); + +// 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(); + 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; + + 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; + + 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(); + } + } 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(); + } catch (error) { + console.error('Failed to close tab:', error); + } } diff --git a/pages/ads/style.css b/pages/ads/style.css index e69de29..92d3239 100644 --- a/pages/ads/style.css +++ b/pages/ads/style.css @@ -0,0 +1,492 @@ +* { + 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: 10px; + 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; + font-weight: 600; +} + +.time-separator { + color: rgba(255, 255, 255, 0.4); + font-size: 16px; + font-weight: 300; +} + +/* 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); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .overlay { + padding: 20px 24px; + } + + .time-info { + font-size: 12px; + gap: 8px; + } + + .time-countdown { + font-size: 14px; + } + + .time-separator { + font-size: 14px; + } + + .progress-percentage { + font-size: 12px; + } + + .warning-message { + font-size: 16px; + padding: 16px 24px; + max-width: 300px; + } +} + +/* Prevent text selection */ +body { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -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.8); + font-weight: 500; + min-width: 36px; + text-align: left; +} + +/* Loading state for iframe */ +#player iframe { + border: none; + width: 100%; + 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; + } +} + 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..589c614 100644 --- a/pages/stripe-success/main.ts +++ b/pages/stripe-success/main.ts @@ -1,23 +1,352 @@ import { initPage } from '~/shared/utils/page-init'; import './style.css'; +import { extractStripeSuccessParams, formatStripeSuccessLog } from './utils'; -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'); + // Get session from URL if present + const urlParams = new URLSearchParams(window.location.search); + const params = extractStripeSuccessParams(urlParams); + + // 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: { source: string; status?: string; type?: string; checkout_id?: string; } = { + source: 'stripe', + status: 'success' + }; + + if (params.type) + body.type = params.type; + + if (params.checkoutId) + body.checkout_id = params.checkoutId; + + 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(); - app.innerHTML = ` + // Safely decode and validate redirect URL + const redirectParam = params.redirectUrl; + let redirectUrl = 'https://app.doctorina.com'; + + 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; + } + + // 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' + ]; + + 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); + } + } + + app.innerHTML = `
-
-

Payment Successful!

-

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

- ${sessionId ? `

Session ID: ${sessionId}

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

${t.successTitle}

+

${t.successMessage}

+
+

${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 diff --git a/pages/stripe-success/utils.test.ts b/pages/stripe-success/utils.test.ts new file mode 100644 index 0000000..b49f909 --- /dev/null +++ b/pages/stripe-success/utils.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } 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', + environment: null, + }); + }); + + 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/', + environment: null, + }); + }); + + 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/', + environment: null, + }); + }); + + 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', + 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); + + 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, + environment: 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(); + 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); + + 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', + 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, + 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, + 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/', + 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'); + }); + }); +}); diff --git a/pages/stripe-success/utils.ts b/pages/stripe-success/utils.ts new file mode 100644 index 0000000..05b598a --- /dev/null +++ b/pages/stripe-success/utils.ts @@ -0,0 +1,72 @@ +/** + * Stripe Success Page URL Parameters + */ +export interface StripeSuccessParams { + /** Checkout Stripe Session ID */ + checkoutId: string | null; + /** Purchase ID (e.g. for subscriptions or donation payments) */ + purchaseId: string | null; + /** Payment type (e.g. "subscription" or "donation") */ + type: string | null; + /** Redirect URL after success */ + redirectUrl: string | null; + /** Environment (e.g. "live" or "stage") */ + environment: 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 + * - e / environment: Environment + * + * @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/', + * // environment: 'live' + * // } + * ``` + */ +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'), + // Environment + environment: searchParams.get('e') || searchParams.get('environment'), + }; +} + +/** + * Formats Stripe success parameters for console logging + * + * @param params - Stripe success parameters + * @returns Formatted string for console output + */ +export function formatStripeSuccessLog(params: StripeSuccessParams): string { + 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'}`); + buffer.push(`Environment: ${params.environment || 'N/A'}`); + return buffer.join('\n'); +} 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}`; } } 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, './'), + }, + }, +});