diff --git a/.cache/pages.json b/.cache/pages.json index 57343e1db..fe212b75f 100644 --- a/.cache/pages.json +++ b/.cache/pages.json @@ -22,16 +22,10 @@ "contact", "offline", { - "privacy": [ - "my-data" - ] + "privacy": ["my-data"] }, { - "services": [ - "consulting", - "overview", - "web-development" - ] + "services": ["consulting", "overview", "web-development"] }, { "tags": [ @@ -67,4 +61,4 @@ "typescript" ] } -] \ No newline at end of file +] diff --git a/_TODO.md b/_TODO.md index 308a686ec..9ca677f4a 100644 --- a/_TODO.md +++ b/_TODO.md @@ -145,21 +145,6 @@ cat.structure: Rules related to the document's overall structure, like the prope cat.tables: Rules for data tables, including headers and associations. cat.text-alternatives: Rules for ensuring that text alternatives are provided for non-text content, such as images. -## Tags - -- Sitemap -- E2E all pages helper - -## Build output errors - -00:19:09 [WARN] [vite] [plugin:astro:assets:esm] context method emitFile() is not supported in serve mode. This plugin is likely not vite-compatible. - -## Use Confetti on CTA forms - -`canvas-confetti` -`https://github.com/catdad/canvas-confetti` -`https://www.kirilv.com/canvas-confetti/` - ## Use the Page Visibility API to pause videos, image carousels, and animations Stop unnecessary processes when the user doesn't see the page or inversely to perform background actions. diff --git a/package-lock.json b/package-lock.json index 5f0bdae2d..ee04debfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.7.2", "astro-vtbot": "^2.1.9", + "canvas-confetti": "^1.9.4", "cross-env": "^10.1.0", "dotenv": "17.2.3", "email-validator": "^2.0.4", @@ -80,6 +81,7 @@ "@testing-library/preact": "3.2.4", "@testing-library/user-event": "14.6.1", "@tktco/node-actionlint": "^1.6.0", + "@types/canvas-confetti": "^1.9.0", "@types/confusing-browser-globals": "1.0.3", "@types/cross-spawn": "6.0.6", "@types/dedent": "^0.7.2", @@ -262,8 +264,7 @@ "version": "2.13.0", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@astrojs/db": { "version": "0.18.3", @@ -465,7 +466,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1028,7 +1028,6 @@ "integrity": "sha512-c7Eccw2lhFx2/+qJn3g+uIDWRuWi2A6Sz3PVvckFUEzPsP0dPUo19hlvtarwP5GzrsXn0yEPRVhpewsIaSCGaQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-sdk/core": "3.932.0", "@aws-sdk/nested-clients": "3.933.0", @@ -1418,7 +1417,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2920,7 +2918,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -3042,7 +3039,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3085,7 +3081,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4756,7 +4751,6 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.15.15.tgz", "integrity": "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w==", "license": "MIT", - "peer": true, "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", @@ -5190,7 +5184,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -5212,7 +5205,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -5225,7 +5217,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -5241,7 +5232,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -5629,7 +5619,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -5646,7 +5635,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -5664,7 +5652,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -7754,7 +7741,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -7906,6 +7892,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -8124,7 +8117,6 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -8348,7 +8340,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -9357,8 +9348,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/@webcomponents/template-shadowroot/-/template-shadowroot-0.2.1.tgz", "integrity": "sha512-fXL/vIUakyZL62hyvUh+EMwbVoTc0hksublmRz6ai6et8znHkJa6gtqMUZo1oc7dIz46exHSIImml9QTdknMHg==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@webgpu/types": { "version": "0.1.21", @@ -9392,7 +9382,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9781,7 +9770,6 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-5.16.5.tgz", "integrity": "sha512-QeuM4xzTR0QuXFDNlGVW0BW7rcquKFIkylaPeM4ufii0/RRiPTYtwxDYVZ3KfiMRuuc+nbLD0214kMKTvz/yvQ==", "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.5", @@ -10245,7 +10233,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -10616,7 +10603,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -10814,6 +10800,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/canvaskit-wasm": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", @@ -12215,8 +12211,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -12608,7 +12603,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12861,7 +12855,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -15845,7 +15838,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -16294,7 +16286,6 @@ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -16551,7 +16542,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -18109,7 +18099,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -19066,7 +19055,6 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -19111,7 +19099,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19258,7 +19245,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz", "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -19288,7 +19274,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19305,7 +19290,6 @@ "integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@astrojs/compiler": "^2.9.1", "prettier": "^3.0.0", @@ -21645,7 +21629,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -21875,7 +21858,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -22197,8 +22179,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -22301,7 +22282,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -22387,7 +22367,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -22730,7 +22709,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23187,7 +23165,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -23469,7 +23446,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -23622,7 +23598,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23655,7 +23630,6 @@ "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.15", "@vitest/mocker": "4.0.15", @@ -24407,7 +24381,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24544,7 +24517,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -24995,7 +24967,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -25158,7 +25129,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 24d523362..e8adc8747 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.7.2", "astro-vtbot": "^2.1.9", + "canvas-confetti": "^1.9.4", "cross-env": "^10.1.0", "dotenv": "17.2.3", "email-validator": "^2.0.4", @@ -126,6 +127,7 @@ "@testing-library/preact": "3.2.4", "@testing-library/user-event": "14.6.1", "@tktco/node-actionlint": "^1.6.0", + "@types/canvas-confetti": "^1.9.0", "@types/confusing-browser-globals": "1.0.3", "@types/cross-spawn": "6.0.6", "@types/dedent": "^0.7.2", diff --git a/src/components/Animations/Computers/client/__tests__/index.spec.ts b/src/components/Animations/Computers/client/__tests__/index.spec.ts index 4fc281adb..85d63cc38 100644 --- a/src/components/Animations/Computers/client/__tests__/index.spec.ts +++ b/src/components/Animations/Computers/client/__tests__/index.spec.ts @@ -84,6 +84,17 @@ type TimelineMock = ReturnType const gsapMock = vi.mocked(gsap, true) let container: AstroContainer let timelineMock: TimelineMock +let intersectionObserverCallback: IntersectionObserverCallback | undefined + +class IntersectionObserverMock { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + + constructor(callback: IntersectionObserverCallback) { + intersectionObserverCallback = callback + } +} beforeAll(async () => { await ensureComputersModuleLoaded() @@ -94,6 +105,9 @@ beforeEach(async () => { vi.clearAllMocks() timelineMock = createTimelineMock() gsapMock.timeline.mockImplementation(() => timelineMock as unknown as ReturnType) + intersectionObserverCallback = undefined + ;(globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver }).IntersectionObserver = + IntersectionObserverMock as unknown as typeof IntersectionObserver }) describe('ComputersAnimation web component module', () => { @@ -227,6 +241,9 @@ describe('ComputersAnimationElement', () => { await renderComputersAnimation(async ({ element }) => { element.initialize() + timelineMock.pause.mockClear() + timelineMock.play.mockClear() + element.pause() expect(element.getAttribute('data-animation-state')).toBe('paused') element.resume() @@ -237,6 +254,67 @@ describe('ComputersAnimationElement', () => { }) }) + it('pauses when the element scrolls out of the viewport and resumes when it returns', async () => { + await renderComputersAnimation(async ({ element, window }) => { + void window + element.initialize() + + expect(intersectionObserverCallback).toBeTypeOf('function') + timelineMock.pause.mockClear() + timelineMock.play.mockClear() + + intersectionObserverCallback?.( + [ + { + isIntersecting: false, + intersectionRatio: 0, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + + expect(timelineMock.pause).toHaveBeenCalledTimes(1) + expect(element.getAttribute('data-animation-state')).toBe('playing') + + intersectionObserverCallback?.( + [ + { + isIntersecting: true, + intersectionRatio: 1, + } as unknown as IntersectionObserverEntry, + ], + {} as IntersectionObserver, + ) + + expect(timelineMock.play).toHaveBeenCalledTimes(1) + }) + }) + + it('pauses when the page is hidden and resumes when visible again', async () => { + await renderComputersAnimation(async ({ element, window }) => { + element.initialize() + + timelineMock.pause.mockClear() + timelineMock.play.mockClear() + + Object.defineProperty(window.document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }) + + window.document.dispatchEvent(new window.Event('visibilitychange')) + expect(timelineMock.pause).toHaveBeenCalledTimes(1) + + Object.defineProperty(window.document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }) + + window.document.dispatchEvent(new window.Event('visibilitychange')) + expect(timelineMock.play).toHaveBeenCalledTimes(1) + }) + }) + it('requests pause and play through the animation controller when the toggle is clicked', async () => { await renderComputersAnimation(async ({ element }) => { element.initialize() @@ -295,8 +373,6 @@ const renderComputersAnimation = async ( if (!window) { throw new TestError('Computers animation tests require a DOM-capable window environment') } - - forceDocumentReady(window) expect(renderResult).toContain(`<${module.registeredName}`) await assertion({ element, window: window as Window & typeof globalThis, module }) }, @@ -309,22 +385,6 @@ const getBreadcrumbOperations = (): string[] => { .filter((operation): operation is string => Boolean(operation)) } -const forceDocumentReady = (window: Window & typeof globalThis): void => { - const { document } = window - if (document.readyState === 'complete') return - - try { - Object.defineProperty(document, 'readyState', { - configurable: true, - get: () => 'complete', - }) - } catch { - // best effort only - } - - document.dispatchEvent(new window.Event('DOMContentLoaded')) -} - function createTimelineMock() { const timeline = { timeScale: vi.fn(), diff --git a/src/components/Animations/Computers/client/index.ts b/src/components/Animations/Computers/client/index.ts index 2725851c9..8f768e5d5 100644 --- a/src/components/Animations/Computers/client/index.ts +++ b/src/components/Animations/Computers/client/index.ts @@ -44,10 +44,22 @@ export class ComputersAnimationElement extends LitElement { private initialized = false private animationController: AnimationControllerHandle | undefined private toggleButton: HTMLButtonElement | null = null + private intersectionObserver: IntersectionObserver | undefined + private isInViewport = true + private isPageVisible = true private readonly domReadyHandler = () => { document.removeEventListener('DOMContentLoaded', this.domReadyHandler) this.initialize() } + private readonly visibilityChangeHandler = () => { + try { + if (typeof document === 'undefined') return + this.isPageVisible = document.visibilityState !== 'hidden' + this.syncPlaybackWithVisibility() + } catch { + // Best effort only + } + } private readonly toggleClickHandler = (event: Event) => { this.handleToggleClick(event) } @@ -107,6 +119,9 @@ export class ComputersAnimationElement extends LitElement { this.resume() }, }) + + this.setupVisibilityHandlers() + this.syncPlaybackWithVisibility() this.initialized = true } catch (error) { handleScriptError(error, context) @@ -130,13 +145,76 @@ export class ComputersAnimationElement extends LitElement { addScriptBreadcrumb(context) try { - this.timeline?.play() this.setAnimationState('playing') + this.syncPlaybackWithVisibility() } catch (error) { handleScriptError(error, context) } } + private setupVisibilityHandlers(): void { + if (typeof document === 'undefined') return + + try { + this.isPageVisible = document.visibilityState !== 'hidden' + document.addEventListener('visibilitychange', this.visibilityChangeHandler) + + const IntersectionObserverCtor = ( + globalThis as unknown as { IntersectionObserver?: typeof IntersectionObserver } + ).IntersectionObserver + + if (typeof IntersectionObserverCtor !== 'function') { + return + } + + this.intersectionObserver = new IntersectionObserverCtor( + (entries) => { + const entry = entries.at(0) + const ratio = entry?.intersectionRatio ?? 0 + this.isInViewport = Boolean(entry?.isIntersecting && ratio > 0) + this.syncPlaybackWithVisibility() + }, + { threshold: 0.01 }, + ) + + this.intersectionObserver.observe(this) + } catch { + // Best effort only + } + } + + private teardownVisibilityHandlers(): void { + try { + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this.visibilityChangeHandler) + } + + this.intersectionObserver?.disconnect() + this.intersectionObserver = undefined + this.isInViewport = true + this.isPageVisible = true + } catch { + // Best effort only + } + } + + private syncPlaybackWithVisibility(): void { + if (!this.timeline) return + + try { + if (!this.isPageVisible || !this.isInViewport) { + this.timeline.pause() + return + } + + if (this.getAnimationState() === 'playing') { + this.timeline.play() + } + } catch { + // Best effort only + } + } + private teardown(): void { if (!this.initialized) return @@ -144,6 +222,7 @@ export class ComputersAnimationElement extends LitElement { addScriptBreadcrumb(context) try { + this.teardownVisibilityHandlers() this.animationController?.destroy() this.animationController = undefined diff --git a/src/components/Animations/Confetti/client/__tests__/index.spec.ts b/src/components/Animations/Confetti/client/__tests__/index.spec.ts new file mode 100644 index 000000000..0c93cb2d6 --- /dev/null +++ b/src/components/Animations/Confetti/client/__tests__/index.spec.ts @@ -0,0 +1,248 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { experimental_AstroContainer as AstroContainer } from 'astro/container' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import type { AnimationControllerConfig, AnimationControllerHandle } from '@components/scripts/store' +import { executeRender, withJsdomEnvironment } from '@test/unit/helpers/litRuntime' +import ConfettiAstro from '@components/Animations/Confetti/index.astro' +import type { ConfettiAnimationElement } from '@components/Animations/Confetti/client' + +type ConfettiModule = WebComponentModule + +type ConfettiClientModule = typeof import('@components/Animations/Confetti/client') + +let confettiElementCtor: ConfettiClientModule['ConfettiAnimationElement'] | undefined +let registerConfettiAnimationWebComponentFn: ConfettiClientModule['registerConfettiAnimationWebComponent'] | undefined +let confettiWebComponentModule: ConfettiClientModule['webComponentModule'] | undefined + +const ensureConfettiModuleLoaded = async (): Promise => { + if (confettiElementCtor && registerConfettiAnimationWebComponentFn && confettiWebComponentModule) { + return + } + + await withJsdomEnvironment(async () => { + const module = await import('@components/Animations/Confetti/client') + confettiElementCtor = module.ConfettiAnimationElement + registerConfettiAnimationWebComponentFn = module.registerConfettiAnimationWebComponent + confettiWebComponentModule = module.webComponentModule + }) +} + +const getConfettiModule = () => { + if (!confettiElementCtor || !registerConfettiAnimationWebComponentFn || !confettiWebComponentModule) { + throw new Error('Confetti module was not initialized correctly') + } + + return { + ConfettiAnimationElement: confettiElementCtor, + registerConfettiAnimationWebComponent: registerConfettiAnimationWebComponentFn, + webComponentModule: confettiWebComponentModule, + } +} + +const addScriptBreadcrumbMock = vi.hoisted(() => vi.fn()) +const handleScriptErrorMock = vi.hoisted(() => vi.fn()) + +const createAnimationControllerMock = vi.hoisted(() => + vi.fn((_config: AnimationControllerConfig): AnimationControllerHandle => ({ + requestPlay: vi.fn(), + requestPause: vi.fn(), + clearUserPreference: vi.fn(), + destroy: vi.fn(), + })) +) + +const confettiInstanceMock = vi.hoisted(() => vi.fn()) +const confettiCreateMock = vi.hoisted(() => vi.fn(() => confettiInstanceMock)) + +vi.mock('@components/scripts/errors', () => ({ + addScriptBreadcrumb: addScriptBreadcrumbMock, +})) + +vi.mock('@components/scripts/errors/handler', () => ({ + handleScriptError: handleScriptErrorMock, +})) + +vi.mock('@components/scripts/store', () => ({ + createAnimationController: createAnimationControllerMock, +})) + +vi.mock('canvas-confetti', () => ({ + create: confettiCreateMock, +})) + +let container: AstroContainer + +beforeAll(async () => { + await ensureConfettiModuleLoaded() +}) + +beforeEach(async () => { + container = await AstroContainer.create() + vi.clearAllMocks() +}) + +describe('Confetti web component module', () => { + it('exposes metadata required by the loader', () => { + const { webComponentModule: module, ConfettiAnimationElement: ctor, registerConfettiAnimationWebComponent } = + getConfettiModule() + + expect(module.registeredName).toBe('confetti-animation') + expect(module.componentCtor).toBe(ctor) + expect(module.registerWebComponent).toBe(registerConfettiAnimationWebComponent) + }) + + it('registers the custom element when window is available', async () => { + const { registerConfettiAnimationWebComponent, ConfettiAnimationElement: ctor } = getConfettiModule() + + await withJsdomEnvironment(async ({ window }) => { + const tagName = 'confetti-animation' + registerConfettiAnimationWebComponent(tagName) + expect(window.customElements.get(tagName)).toBe(ctor) + }) + }) +}) + +describe('ConfettiAnimationElement', () => { + const renderConfetti = async ( + assertion: (_context: { element: ConfettiAnimationElement; window: Window }) => Promise | void, + ): Promise => { + await executeRender({ + container, + component: ConfettiAstro, + moduleSpecifier: '@components/Animations/Confetti/client/index', + args: {}, + waitForReady: async (element) => { + await element.updateComplete + }, + assert: async ({ element, module, renderResult, window }) => { + expect(renderResult).toContain(`<${module.registeredName}`) + await assertion({ element, window: window as unknown as Window }) + }, + }) + } + + it('creates an animation controller on initialize', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + + expect(createAnimationControllerMock).toHaveBeenCalledWith( + expect.objectContaining({ + animationId: 'confetti-animation', + debugLabel: 'ConfettiAnimationElement', + onPause: expect.any(Function), + onPlay: expect.any(Function), + }), + ) + }) + }) + + it('creates a canvas-confetti instance and fires when allowed', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + element.fire() + + expect(confettiCreateMock).toHaveBeenCalledTimes(1) + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + + const args = confettiInstanceMock.mock.calls[0]?.[0] + expect(args).toEqual( + expect.objectContaining({ + particleCount: expect.any(Number), + spread: expect.any(Number), + startVelocity: expect.any(Number), + }), + ) + }) + }) + + it('skips firing when controller pauses animations', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + + const controllerArgs = createAnimationControllerMock.mock.calls[0]?.[0] + controllerArgs?.onPause() + + element.fire() + expect(confettiInstanceMock).not.toHaveBeenCalled() + + controllerArgs?.onPlay() + + element.fire() + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + }) + }) + + it('fires in response to a confetti:fire event', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + + element.dispatchEvent( + new CustomEvent('confetti:fire', { + detail: { particleCount: 12 }, + }), + ) + + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + expect(confettiInstanceMock).toHaveBeenCalledWith(expect.objectContaining({ particleCount: 12 })) + }) + }) + + it('defaults the origin to the bottom-center of the event target element', async () => { + await renderConfetti(async ({ element, window }) => { + element.initialize() + + Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: 500, configurable: true }) + + const button = window.document.createElement('button') + button.setAttribute('type', 'button') + element.appendChild(button) + + button.getBoundingClientRect = () => + ({ + x: 100, + y: 50, + left: 100, + top: 50, + width: 200, + height: 40, + right: 300, + bottom: 90, + toJSON: () => ({}), + }) as DOMRect + + button.dispatchEvent( + new CustomEvent('confetti:fire', { + bubbles: true, + detail: { particleCount: 12 }, + }), + ) + + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + + const callArgs = confettiInstanceMock.mock.calls[0]?.[0] + expect(callArgs).toEqual(expect.objectContaining({ particleCount: 12 })) + expect(callArgs?.origin?.x).toBeCloseTo(0.2, 5) + expect(callArgs?.origin?.y).toBeCloseTo(0.18, 5) + }) + }) + + it('passes colors and shapes through to canvas-confetti', async () => { + await renderConfetti(async ({ element }) => { + element.initialize() + + element.fire({ + colors: ['#bada55', '#ff0000'], + shapes: ['star'], + }) + + expect(confettiInstanceMock).toHaveBeenCalledTimes(1) + expect(confettiInstanceMock).toHaveBeenCalledWith( + expect.objectContaining({ + colors: ['#bada55', '#ff0000'], + shapes: ['star'], + }), + ) + }) + }) +}) diff --git a/src/components/Animations/Confetti/client/index.ts b/src/components/Animations/Confetti/client/index.ts new file mode 100644 index 000000000..545dc0428 --- /dev/null +++ b/src/components/Animations/Confetti/client/index.ts @@ -0,0 +1,269 @@ +import { LitElement } from 'lit' +import { create as createConfetti } from 'canvas-confetti' +import type { Shape as ConfettiShape } from 'canvas-confetti' +import { addScriptBreadcrumb } from '@components/scripts/errors' +import { handleScriptError } from '@components/scripts/errors/handler' +import { + createAnimationController, + type AnimationControllerHandle, +} from '@components/scripts/store' +import { defineCustomElement } from '@components/scripts/utils' +import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' + +const SCRIPT_NAME = 'ConfettiAnimationElement' +const COMPONENT_TAG_NAME = 'confetti-animation' +const CANVAS_SELECTOR = '[data-confetti-canvas]' + +export type ConfettiOrigin = { + /** + * The x position on the page, with 0 being the left edge and 1 being the right edge (default: 0.5) + */ + x: number + /** + * The y position on the page, with 0 being the top edge and 1 being the bottom edge (default: 0.5) + */ + y: number +} + +export type ConfettiFireOptions = { + /** + * Where to start firing confetti from + */ + origin?: ConfettiOrigin + + /** + * The number of confetti to launch + */ + particleCount?: number + + /** + * How far off center the confetti can go, in degrees. 70 means the confetti + * will launch at the defined angle plus or minus 35 degrees (default: 70). + */ + spread?: number + + /** + * How fast the confetti will start going, in pixels (default: 45) + */ + startVelocity?: number + + /** + * Scale factor for each confetti particle. Use decimals to make the confetti + * smaller. (default: 1) + */ + scalar?: number + + /** + * An array of color strings in HEX format, e.g. `#bada55`. + * + * Default: `canvas-confetti`'s built-in colors. + */ + colors?: string[] + + /** + * An array of shapes for the confetti. + * Built-ins: `square`, `circle`, `star`. + * Custom shapes can be created via `shapeFromPath` / `shapeFromText` from `canvas-confetti`. + * + * Default: `['square', 'circle']`. + */ + shapes?: ConfettiShape[] +} + +type ConfettiInstance = ReturnType + +export class ConfettiAnimationElement extends LitElement { + private initialized = false + private animationController: AnimationControllerHandle | undefined + private canAnimate = true + private confettiInstance: ConfettiInstance | undefined + + private clamp01(value: number): number { + return Math.max(0, Math.min(1, value)) + } + + private getOriginFromElement(element: Element): ConfettiOrigin | undefined { + if (typeof window === 'undefined') return + + const rect = element.getBoundingClientRect() + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + + if (!viewportWidth || !viewportHeight) return + + const originX = (rect.left + rect.width / 2) / viewportWidth + const originY = (rect.top + rect.height) / viewportHeight + + return { + x: this.clamp01(originX), + y: this.clamp01(originY), + } + } + + private readonly domReadyHandler = () => { + document.removeEventListener('DOMContentLoaded', this.domReadyHandler) + this.initialize() + } + + override createRenderRoot() { + return this + } + + override connectedCallback(): void { + super.connectedCallback() + if (typeof document === 'undefined') return + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', this.domReadyHandler) + return + } + + this.initialize() + } + + override disconnectedCallback(): void { + if (typeof document !== 'undefined') { + document.removeEventListener('DOMContentLoaded', this.domReadyHandler) + this.teardown() + } + + super.disconnectedCallback() + } + + initialize(): void { + if (this.initialized) return + + const context = { scriptName: SCRIPT_NAME, operation: 'initialize' } + addScriptBreadcrumb(context) + + try { + this.registerFireListener() + this.ensureConfettiInstance() + + this.animationController = createAnimationController({ + animationId: 'confetti-animation', + debugLabel: SCRIPT_NAME, + // Reduced motion and global pause should only be respected via the store. + defaultState: 'playing', + onPause: () => { + this.canAnimate = false + }, + onPlay: () => { + this.canAnimate = true + }, + }) + + this.initialized = true + } catch (error) { + handleScriptError(error, context) + } + } + + /** + * Trigger a one-shot confetti burst. + * + * If animations are globally paused (including reduced motion), this is a no-op. + */ + fire(options: ConfettiFireOptions = {}): void { + const context = { scriptName: SCRIPT_NAME, operation: 'fire' } + addScriptBreadcrumb(context) + + try { + if (!this.canAnimate) return + this.ensureConfettiInstance() + if (!this.confettiInstance) return + + const { + origin, + particleCount = 50, + spread = 70, + startVelocity = 45, + scalar = 1, + colors, + shapes = ['square', 'circle'], + } = options + + void this.confettiInstance({ + particleCount, + spread, + startVelocity, + scalar, + origin, + colors, + shapes, + }) + } catch (error) { + handleScriptError(error, context) + } + } + + private registerFireListener(): void { + this.addEventListener('confetti:fire', (event) => { + const detail = (event as CustomEvent).detail + + const target = event.target + const elementTarget = target instanceof Element ? target : undefined + const originFromTarget = elementTarget ? this.getOriginFromElement(elementTarget) : undefined + + const originToUse = detail?.origin ?? originFromTarget + + if (originToUse) { + this.fire({ + ...(detail ?? {}), + origin: originToUse, + }) + + return + } + + this.fire(detail ?? {}) + }) + } + + private ensureConfettiInstance(): void { + if (typeof window === 'undefined') return + if (this.confettiInstance) return + + const canvas = this.querySelector(CANVAS_SELECTOR) as HTMLCanvasElement | null + if (!canvas) return + + // Prefer a per-canvas instance so callers can place the canvas where they want. + this.confettiInstance = createConfetti(canvas, { + resize: true, + useWorker: true, + }) + } + + private teardown(): void { + if (!this.initialized) return + + const context = { scriptName: SCRIPT_NAME, operation: 'teardown' } + addScriptBreadcrumb(context) + + try { + this.animationController?.destroy() + this.animationController = undefined + this.confettiInstance = undefined + this.canAnimate = true + this.initialized = false + } catch (error) { + handleScriptError(error, context) + } + } +} + +export const registerWebComponent = async (tagName = COMPONENT_TAG_NAME) => { + if (typeof window === 'undefined') { + return + } + + defineCustomElement(tagName, ConfettiAnimationElement) +} + +export const registerConfettiAnimationWebComponent = registerWebComponent + +export const webComponentModule: WebComponentModule = { + registeredName: COMPONENT_TAG_NAME, + componentCtor: ConfettiAnimationElement, + registerWebComponent, +} diff --git a/src/components/Animations/Confetti/index.astro b/src/components/Animations/Confetti/index.astro new file mode 100644 index 000000000..c61ce0f95 --- /dev/null +++ b/src/components/Animations/Confetti/index.astro @@ -0,0 +1,17 @@ +--- +/** + * Short-lived confetti animation. + * + * The client web component respects global animation lifecycle state via the store. + */ +--- + + + + + + + diff --git a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts index ba00fca97..5da56577b 100644 --- a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts +++ b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts @@ -122,6 +122,9 @@ describe('NewsletterFormElement web component', () => { }) await renderNewsletter(async ({ elements }) => { + const confettiFireHandler = vi.fn() + elements.submitButton.addEventListener('confetti:fire', confettiFireHandler) + elements.emailInput.value = 'test@example.com' elements.consentCheckbox.checked = true @@ -136,6 +139,7 @@ describe('NewsletterFormElement web component', () => { body: JSON.stringify({ email: 'test@example.com', consentGiven: true }), }) expect(elements.message.textContent).toBe('Subscribed successfully!') + expect(confettiFireHandler).toHaveBeenCalledTimes(1) expect(elements.submitButton.disabled).toBe(false) expect(elements.buttonSpinner.classList.contains('hidden')).toBe(true) }) diff --git a/src/components/CallToAction/Newsletter/client/index.ts b/src/components/CallToAction/Newsletter/client/index.ts index 7bb215b73..121ff8966 100644 --- a/src/components/CallToAction/Newsletter/client/index.ts +++ b/src/components/CallToAction/Newsletter/client/index.ts @@ -5,6 +5,7 @@ */ import { LitElement } from 'lit' +import emailValidator from 'email-validator' import { addScriptBreadcrumb, ClientScriptError } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { getNewsletterElements } from './selectors' @@ -22,9 +23,6 @@ export class NewsletterFormElement extends LitElement { return this // No shadow DOM - works with Astro templates! } - // Email validation pattern (matches server-side) - private readonly emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - // DOM elements private form!: HTMLFormElement private emailInput!: HTMLInputElement @@ -124,7 +122,7 @@ export class NewsletterFormElement extends LitElement { addScriptBreadcrumb(context) try { - return this.emailRegex.test(email) + return emailValidator.validate(email) } catch (error) { handleScriptError(error, context) return false @@ -250,6 +248,7 @@ export class NewsletterFormElement extends LitElement { data.message || 'Check your email! Click the confirmation link to complete your subscription.', 'success' ) + this.submitButton.dispatchEvent(new CustomEvent('confetti:fire', { bubbles: true, composed: true })) this.form?.reset() } else { this.showMessage(data.error || 'Failed to subscribe. Please try again.', 'error') diff --git a/src/components/CallToAction/Newsletter/index.astro b/src/components/CallToAction/Newsletter/index.astro index bcacc0d06..877c01537 100644 --- a/src/components/CallToAction/Newsletter/index.astro +++ b/src/components/CallToAction/Newsletter/index.astro @@ -16,6 +16,7 @@ */ import GDPRConsent from '@components/Consent/Checkbox/index.astro' +import Confetti from '@components/Animations/Confetti/index.astro' import type { NewsletterProps } from './props' export type Props = NewsletterProps @@ -72,46 +73,48 @@ const { aria-label="Email address for newsletter subscription" aria-describedby="newsletter-message" /> - + viewBox="0 0 24 24" + > + + + + + diff --git a/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts b/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts index 5b071e281..86ab31201 100644 --- a/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts +++ b/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts @@ -56,6 +56,11 @@ describe('ContactForm submission', () => { json: async () => ({ success: true }), } as Response) + let confettiEvent: Event | undefined + context.elements.submitBtn.addEventListener('confetti:fire', (event) => { + confettiEvent = event + }) + const submitEvent = new context.window.Event('submit', { bubbles: true, cancelable: true }) context.elements.form.dispatchEvent(submitEvent) @@ -78,6 +83,11 @@ describe('ContactForm submission', () => { expect(context.elements.fields.name.input.value).toBe('') expect(context.elements.fields.email.input.value).toBe('') + expect(confettiEvent).toBeDefined() + expect(confettiEvent?.target).toBe(context.elements.submitBtn) + expect(confettiEvent?.bubbles).toBe(true) + expect((confettiEvent as CustomEvent)?.composed).toBe(true) + fetchSpy.mockRestore() }) }) diff --git a/src/components/Forms/Contact/client/formSubmission.ts b/src/components/Forms/Contact/client/formSubmission.ts index 5bdccbcf2..865fc1486 100644 --- a/src/components/Forms/Contact/client/formSubmission.ts +++ b/src/components/Forms/Contact/client/formSubmission.ts @@ -94,6 +94,14 @@ export const initFormSubmission = ( if (response.ok && result.success) { showSuccessMessage(elements) + + elements.submitBtn.dispatchEvent( + new CustomEvent('confetti:fire', { + bubbles: true, + composed: true, + }), + ) + resetFormState(elements, controllers) } else { showErrorMessage(elements, result.message || 'An error occurred while sending your message.') diff --git a/src/components/Forms/Contact/client/validation.ts b/src/components/Forms/Contact/client/validation.ts index 6ccb9d71b..a01562965 100644 --- a/src/components/Forms/Contact/client/validation.ts +++ b/src/components/Forms/Contact/client/validation.ts @@ -1,4 +1,5 @@ import { ClientScriptError } from '@components/scripts/errors' +import emailValidator from 'email-validator' import type { FieldElements } from './@types' import { showFieldFeedback, clearFieldFeedback } from './feedback' import { @@ -156,8 +157,7 @@ export const validateGenericField = (field: GenericField): boolean => { isValid = false errorMessage = 'This field is required' } else if (field instanceof HTMLInputElement && field.type === 'email' && value) { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(value)) { + if (!emailValidator.validate(value)) { isValid = false errorMessage = 'Please enter a valid email address' } diff --git a/src/components/Forms/Contact/index.astro b/src/components/Forms/Contact/index.astro index b5807b9ca..48b78f176 100644 --- a/src/components/Forms/Contact/index.astro +++ b/src/components/Forms/Contact/index.astro @@ -1,6 +1,7 @@ --- // ContactForm.astro - Reusable contact form component import GDPRConsent from '@components/Consent/Checkbox/index.astro' +import Confetti from '@components/Animations/Confetti/index.astro' import UploadPlaceholder from './upload.astro' --- @@ -241,29 +242,31 @@ import UploadPlaceholder from './upload.astro'

Your Rights: You can request access, correction, or deletion of your data at any time by contacting kevin@webstackbuilders.com.

-
- -
+ +
+ +
+
diff --git a/src/components/Forms/Download/client/__tests__/index.spec.ts b/src/components/Forms/Download/client/__tests__/index.spec.ts index 411e21e4f..574476bd2 100644 --- a/src/components/Forms/Download/client/__tests__/index.spec.ts +++ b/src/components/Forms/Download/client/__tests__/index.spec.ts @@ -93,6 +93,28 @@ describe('download-form web component', () => { }) }) + it('dispatches a confetti:fire event from the submit button on success', async () => { + await renderDownloadForm(async ({ elements, window }) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) + fillDownloadForm(elements) + + let confettiEvent: Event | undefined + elements.submitButton.addEventListener('confetti:fire', (event) => { + confettiEvent = event + }) + + submitForm(window, elements.form) + await flushPromises() + + expect(confettiEvent).toBeDefined() + expect(confettiEvent?.target).toBe(elements.submitButton) + expect(confettiEvent?.bubbles).toBe(true) + expect((confettiEvent as CustomEvent)?.composed).toBe(true) + + fetchSpy.mockRestore() + }) + }) + it('displays error state when API fails', async () => { await renderDownloadForm(async ({ elements, window }) => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ diff --git a/src/components/Forms/Download/client/index.ts b/src/components/Forms/Download/client/index.ts index f00b62f5a..22d254702 100644 --- a/src/components/Forms/Download/client/index.ts +++ b/src/components/Forms/Download/client/index.ts @@ -110,6 +110,14 @@ export class DownloadFormElement extends LitElement { await response.json() this.showStatus('success', 'Thank you! Click the button below to download your resource.') + + submitButton.dispatchEvent( + new CustomEvent('confetti:fire', { + bubbles: true, + composed: true, + }), + ) + downloadButtonWrapper.classList.remove('hidden') submitButton.classList.add('hidden') form.reset() diff --git a/src/components/Forms/Download/index.astro b/src/components/Forms/Download/index.astro index 43fecec8d..8d1a9ea8d 100644 --- a/src/components/Forms/Download/index.astro +++ b/src/components/Forms/Download/index.astro @@ -1,5 +1,7 @@ --- // DownloadForm.astro - Gated form for download resources +import Confetti from '@components/Animations/Confetti/index.astro' + export interface Props { title: string fileName: string @@ -130,15 +132,17 @@ const downloadUrl = `/downloads/${fileName}` -
- -
+ +
+ +
+