diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..6411585da --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Custom Analytics — Supabase connection +# Create a free project at https://supabase.com, run src/analytics/setup.sql in the +# Supabase SQL Editor, then copy the values from Project Settings > API. +# +# The PUBLIC_ prefix exposes these values to the browser at runtime. +# The anon key is safe to expose — Supabase RLS policies control what it can access. +PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co +PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... diff --git a/.github/workflows/preview-on-pages.yml b/.github/workflows/preview-on-pages.yml index 3a9019c00..c97fb882f 100644 --- a/.github/workflows/preview-on-pages.yml +++ b/.github/workflows/preview-on-pages.yml @@ -34,6 +34,9 @@ jobs: run: pnpm install - name: Build with Astro id: build + env: + PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }} + PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }} run: NODE_ENV=github VITE_GITHUB_BASE_PATH=${{ steps.pages.outputs.base_path }} SKIP_LINK_VALIDATION=true pnpm astro build \ --site "${{ steps.pages.outputs.origin }}" - name: Upload static files as artifact diff --git a/astro.config.mjs b/astro.config.mjs index 160e92f34..d7388fc2d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -80,6 +80,17 @@ async function config() { }, }, }, + { + name: 'custom-analytics', + hooks: { + 'astro:config:setup': ({ injectScript }) => { + // Tracks page views, clicks, scroll depth, and form interactions on every page. + // Dynamic import so analytics loads after the main page bundle without blocking render. + // Source: src/analytics/tracker.ts (see src/analytics/README.md). + injectScript('page', `void import('/src/analytics/tracker.ts');`); + }, + }, + }, starlight({ editLink: { baseUrl: 'https://github.com/commerce-docs/microsite-commerce-storefront/edit/release/', diff --git a/package.json b/package.json index 6867c3615..b7d3db971 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@graphiql/react": "0.28.2", "@graphiql/toolkit": "0.11.1", "@playform/compress": "^0.2.2", + "@supabase/supabase-js": "^2.105.1", "@types/hast": "^3.0.4", "@types/lodash": "^4.17.21", "@types/react": "19.2.14", @@ -126,6 +127,7 @@ "prettier-plugin-astro": "^0.14.1", "react": "19.2.4", "react-dom": "19.2.4", + "recharts": "^3.8.1", "regenerator-runtime": "^0.14.1", "rehype": "^13.0.2", "sharp": "^0.34.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bad24dee..d713c8e57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@playform/compress': specifier: ^0.2.2 version: 0.2.3(@types/node@25.6.0)(rollup@4.60.1)(typescript@5.9.3)(yaml@2.8.3) + '@supabase/supabase-js': + specifier: ^2.105.1 + version: 2.105.1 '@types/hast': specifier: ^3.0.4 version: 3.0.4 @@ -175,6 +178,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) regenerator-runtime: specifier: ^0.14.1 version: 0.14.1 @@ -1572,6 +1578,17 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1730,6 +1747,12 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -1739,6 +1762,33 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@supabase/auth-js@2.105.1': + resolution: {integrity: sha512-zc4s8Xg4truwE1Q4Q8M8oUVDARMd05pKh73NyQsMbYU1HDdDN2iiKzena/yu+yJze3WrD4c092FdckPiK1rLQw==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.105.1': + resolution: {integrity: sha512-dTk1e7oE51VGc1lS2S0J0NLo0Wp4JYChj74ArJKbIWgoWuFwO0wcJYjeyOV3AAEpKst8/LQWUZOUKO1tRXBrpA==} + engines: {node: '>=20.0.0'} + + '@supabase/phoenix@0.4.1': + resolution: {integrity: sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==} + + '@supabase/postgrest-js@2.105.1': + resolution: {integrity: sha512-6SbtsoWC55xfsm7gbfLqvF+yIwTQEbjt+jFGf4klDpwSnUy17Hv5x0Dq52oqwTQlw6Ta0h1D5gTP0/pApqNojA==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.105.1': + resolution: {integrity: sha512-3X3cUEl5cJ4lRQHr1hXHx0b98OaL97RRO2vrRZ98FD91JV/MquZHhrGJSv/+IkOnjF6E2e0RUOxE8P3Zi035ow==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.105.1': + resolution: {integrity: sha512-owfdCNH5ikXXDusjzsgU6LavEBqGUoueOnL/9XIucld70/WJ/rbqp89K//c9QPICDNuegsmpoeasydDAiucLKQ==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.105.1': + resolution: {integrity: sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==} + engines: {node: '>=20.0.0'} + '@tanstack/react-virtual@3.13.23': resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} peerDependencies: @@ -1977,6 +2027,12 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.58.2': resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2650,6 +2706,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -2831,6 +2890,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -3260,6 +3322,10 @@ packages: i18next@23.16.8: resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3279,6 +3345,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -4138,6 +4210,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4196,6 +4280,14 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -4214,6 +4306,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} @@ -4287,6 +4387,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -4849,6 +4952,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@6.4.2: resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6602,6 +6708,18 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.3.0(rollup@4.60.1)': @@ -6720,6 +6838,10 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@storybook/global@5.0.0': {} '@storybook/icons@2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -6727,6 +6849,46 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@supabase/auth-js@2.105.1': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.105.1': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.1': {} + + '@supabase/postgrest-js@2.105.1': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.105.1': + dependencies: + '@supabase/phoenix': 0.4.1 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.105.1': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.105.1': + dependencies: + '@supabase/auth-js': 2.105.1 + '@supabase/functions-js': 2.105.1 + '@supabase/postgrest-js': 2.105.1 + '@supabase/realtime-js': 2.105.1 + '@supabase/storage-js': 2.105.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/virtual-core': 3.13.23 @@ -7005,6 +7167,12 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.0 + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.0)(typescript@5.9.3))(eslint@10.2.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -7833,6 +8001,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: @@ -7977,6 +8147,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.46.1: {} + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -8659,6 +8831,8 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 + iceberg-js@0.8.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -8671,6 +8845,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-meta-resolve@4.2.0: {} imurmurhash@0.1.4: {} @@ -9780,6 +9958,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): @@ -9834,6 +10021,26 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.46.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 17.0.2 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -9868,6 +10075,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + regenerator-runtime@0.14.1: {} regex-recursion@6.0.2: @@ -9989,6 +10202,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -10629,6 +10844,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@6.4.2(@types/node@25.6.0)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3): dependencies: esbuild: 0.25.12 diff --git a/src/analytics/Dashboard.tsx b/src/analytics/Dashboard.tsx new file mode 100644 index 000000000..7b0faee2a --- /dev/null +++ b/src/analytics/Dashboard.tsx @@ -0,0 +1,914 @@ +import { createClient } from '@supabase/supabase-js'; +import { useEffect, useId, useMemo, useState } from 'react'; +import { + Area, + CartesianGrid, + ComposedChart, + Legend, + Line, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface DailySummary { + day: string; + visits: number; + page_views: number; + unique_visitors: number; + avg_duration_seconds: number; + bounce_rate_pct: number; +} + +interface Totals { + visits: number; + page_views: number; + unique_visitors: number; + avg_duration_seconds: number; + bounce_rate_pct: number; +} + +interface TopPage { + url: string; + page_views: number; + unique_visitors: number; + avg_duration_seconds: number; +} + +interface EventRow { + event_type: string; + event_name: string | null; + event_count: number; +} + +interface EngagementRow { + avg_pages_per_session: number; + sessions_with_2plus_pages: number; + sessions_total: number; + returning_visitors: number; + unique_visitors_30d: number; +} + +interface ExternalLinkRow { + href: string; + clicks: number; +} + +type TrendRange = 7 | 30 | 90 | 365; + +const supabase = createClient( + import.meta.env.PUBLIC_SUPABASE_URL as string, + import.meta.env.PUBLIC_SUPABASE_ANON_KEY as string +); + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function fmtDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return s > 0 ? `${m}m ${s}s` : `${m}m`; +} + +function fmtShortDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function truncateUrl(url: string, max = 60): string { + try { + const u = new URL(url); + const path = u.pathname + (u.search ? u.search : ''); + return path.length > max ? path.slice(0, max) + '…' : path; + } catch { + return url.length > max ? url.slice(0, max) + '…' : url; + } +} + +function truncateHref(url: string, max = 72): string { + if (url.length <= max) return url; + return url.slice(0, max) + '…'; +} + +const TREND_RANGE_OPTIONS: { value: TrendRange; label: string }[] = [ + { value: 7, label: 'Last 7 days' }, + { value: 30, label: 'Last 30 days' }, + { value: 90, label: 'Last 90 days' }, + { value: 365, label: 'Last 365 days' }, +]; + +function totalsViewName(range: TrendRange): string { + switch (range) { + case 7: + return 'analytics_totals_7d'; + case 30: + return 'analytics_totals_30d'; + case 90: + return 'analytics_totals_90d'; + case 365: + return 'analytics_totals_365d'; + } +} + +function engagementViewName(range: TrendRange): string { + switch (range) { + case 7: + return 'management_engagement_7d'; + case 30: + return 'management_engagement_30d'; + case 90: + return 'management_engagement_90d'; + case 365: + return 'management_engagement_365d'; + } +} + +function externalLinksViewName(range: TrendRange): string { + switch (range) { + case 7: + return 'external_link_clicks_7d'; + case 30: + return 'external_link_clicks_30d'; + case 90: + return 'external_link_clicks_90d'; + case 365: + return 'external_link_clicks_365d'; + } +} + +/** UTC `YYYY-MM-DD` for "today" (calendar date in UTC). */ +function utcTodayDateString(): string { + const n = new Date(); + return new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate())) + .toISOString() + .slice(0, 10); +} + +/** + * First UTC calendar date in the chart window (inclusive). + * The window has `rangeDays` days ending today UTC (same idea as "last N days" on the axis). + */ +function chartWindowStartDate(rangeDays: TrendRange): string { + const end = new Date(Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate() + )); + const start = new Date(end); + start.setUTCDate(start.getUTCDate() - (rangeDays - 1)); + return start.toISOString().slice(0, 10); +} + +function normalizeDayKey(day: string): string { + return day.slice(0, 10); +} + +/** One row per UTC calendar day in the window; missing days get zero counts (for a full horizontal axis). */ +function padDailyChartSeries( + rows: DailySummary[], + rangeDays: TrendRange +): DailySummary[] { + const startStr = chartWindowStartDate(rangeDays); + const endStr = utcTodayDateString(); + + const byDay = new Map(); + for (const row of rows) { + byDay.set(normalizeDayKey(row.day), row); + } + + const emptyDay = (day: string): DailySummary => ({ + day, + visits: 0, + page_views: 0, + unique_visitors: 0, + avg_duration_seconds: 0, + bounce_rate_pct: 0, + }); + + const [y0, m0, d0] = startStr.split('-').map(Number); + const [y1, m1, d1] = endStr.split('-').map(Number); + const cur = new Date(Date.UTC(y0, m0 - 1, d0)); + const end = new Date(Date.UTC(y1, m1 - 1, d1)); + const out: DailySummary[] = []; + while (cur.getTime() <= end.getTime()) { + const key = cur.toISOString().slice(0, 10); + out.push(byDay.get(key) ?? emptyDay(key)); + cur.setUTCDate(cur.getUTCDate() + 1); + } + return out; +} + +function isPostgrestLikeError( + err: unknown +): err is { message: string; details?: string; hint?: string; code?: string } { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message: unknown }).message === 'string' + ); +} + +/** Turns Supabase/PostgREST errors into readable text for the error banner. */ +function formatAnalyticsLoadError(err: unknown): string { + if (isPostgrestLikeError(err)) { + let s = err.message; + if (err.details) s += ` ${err.details}`; + if (err.hint) s += ` ${err.hint}`; + return s; + } + if (err instanceof Error) return err.message; + return 'Failed to load analytics data.'; +} + +/** True when this index is a strict local maximum (used for highlight dots on page views). */ +function isLocalPeak( + data: DailySummary[], + index: number, + key: 'page_views' | 'visits' | 'unique_visitors' +): boolean { + if (data.length < 3) return false; + if (index <= 0 || index >= data.length - 1) return false; + const v = data[index][key]; + const prev = data[index - 1][key]; + const next = data[index + 1][key]; + return v > prev && v > next; +} + +/** Site root only (`/` or trailing-slash equivalent) — hides unlabeled hits from the Top pages table. */ +function isSiteRootUrl(url: string): boolean { + try { + const path = new URL(url).pathname.replace(/\/+$/, '') || '/'; + return path === '/'; + } catch { + return false; + } +} + +/** Same route rule as tracker.ts — exclude standalone `/analytics` dashboard URLs. */ +function isAnalyticsDashboardUrl(url: string): boolean { + try { + const path = new URL(url).pathname.replace(/\/+$/, '') || '/'; + return path.endsWith('/analytics'); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +function SectionHeader({ title }: { title: string }) { + return

{title}

; +} + +function LoadingPlaceholder({ rows = 3 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ); +} + +function ErrorBanner({ message }: { message: string }) { + return

{message}

; +} + +function RangeControl({ + value, + onChange, + disabled, +}: { + value: TrendRange; + onChange: (next: TrendRange) => void; + disabled?: boolean; +}) { + return ( +
+ {TREND_RANGE_OPTIONS.map((opt) => ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Dashboard +// --------------------------------------------------------------------------- + +interface DashboardProps { + docsHome: string; +} + +function AnalyticsSiteHeader({ + docsHome, + showRange, + trendRange, + onTrendRange, + loading, +}: { + docsHome: string; + showRange: boolean; + trendRange?: TrendRange; + onTrendRange?: (r: TrendRange) => void; + loading?: boolean; +}) { + return ( +
+
+
+

Storefront Analytics

+ {showRange && trendRange != null && onTrendRange ? ( + + ) : null} +
+ + Commerce Storefront Docs + + +
+
+ ); +} + +export default function Dashboard({ docsHome }: DashboardProps) { + const [trendRange, setTrendRange] = useState(7); + const [totals, setTotals] = useState(null); + const [chartData, setChartData] = useState([]); + const [topPages, setTopPages] = useState([]); + const [events, setEvents] = useState([]); + const [engagement, setEngagement] = useState(null); + const [externalLinks, setExternalLinks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const areaGradientId = `dailyPvGrad-${useId().replace(/:/g, '')}`; + const dailyChartMeta = useMemo(() => { + const n = chartData.length; + if (n === 0) return { meanPv: 0, lastDay: null as string | null }; + return { + meanPv: chartData.reduce((s, d) => s + d.page_views, 0) / n, + lastDay: chartData[n - 1]?.day ?? null, + }; + }, [chartData]); + + useEffect(() => { + async function load() { + setLoading(true); + setError(null); + try { + const [ + totalsRes, + chartRes, + pagesRes, + eventsRes, + engagementRes, + externalLinksRes, + ] = await Promise.all([ + supabase.from(totalsViewName(trendRange)).select('*').single(), + supabase + .from('analytics_summary') + .select('*') + .gte('day', chartWindowStartDate(trendRange)) + .order('day', { ascending: false }) + .limit(400), + supabase.from('top_pages').select('*').limit(40), + supabase.from('events_summary').select('*').limit(30), + supabase.from(engagementViewName(trendRange)).select('*').single(), + supabase.from(externalLinksViewName(trendRange)).select('*').limit(100), + ]); + + if (totalsRes.error) throw totalsRes.error; + if (chartRes.error) throw chartRes.error; + if (pagesRes.error) throw pagesRes.error; + if (eventsRes.error) throw eventsRes.error; + if (engagementRes.error) throw engagementRes.error; + if (externalLinksRes.error) throw externalLinksRes.error; + + setTotals(totalsRes.data as Totals); + setChartData( + padDailyChartSeries( + ([...(chartRes.data ?? [])] as DailySummary[]).reverse(), + trendRange + ) + ); + const pages = (pagesRes.data ?? []) as TopPage[]; + setTopPages( + pages + .filter( + (p) => + !isAnalyticsDashboardUrl(p.url) && !isSiteRootUrl(p.url) + ) + .slice(0, 20) + ); + setEvents((eventsRes.data ?? []) as EventRow[]); + setEngagement(engagementRes.data as EngagementRow); + setExternalLinks((externalLinksRes.data ?? []) as ExternalLinkRow[]); + } catch (err) { + let message = formatAnalyticsLoadError(err); + if ( + trendRange !== 30 && + !message.toLowerCase().includes('setup.sql') + ) { + message += + ' For 7-, 90-, or 365-day windows, your Supabase project needs the matching views from `src/analytics/setup.sql` (names ending in `_7d`, `_90d`, or `_365d`). Paste the full file into the Supabase SQL Editor, run it, then reload this page.'; + } + setError(message); + } finally { + setLoading(false); + } + } + + load(); + }, [trendRange]); + + const isConfigured = + import.meta.env.PUBLIC_SUPABASE_URL && + import.meta.env.PUBLIC_SUPABASE_ANON_KEY; + + if (!isConfigured) { + return ( + <> + +
+
+

+ Storefront Analytics is not configured. Add{' '} + PUBLIC_SUPABASE_URL and{' '} + PUBLIC_SUPABASE_ANON_KEY to your{' '} + .env file, then restart the dev server. +

+
+
+ + ); + } + + return ( + <> + +
+
+ + + {loading ? ( +
+ + + + + + + + + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + +
VisitsPage viewsUnique visitorsAvg. active timeBounce rate
+
+
+ ) : error ? ( + + ) : totals ? ( +
+ + + + + + + + + + + + + + + + + + + +
VisitsPage viewsUnique visitorsAvg. active timeBounce rate
+ {totals.visits.toLocaleString()} + + {totals.page_views.toLocaleString()} + + {totals.unique_visitors.toLocaleString()} + + {fmtDuration(totals.avg_duration_seconds)} + + foreground tab only + + + {`${totals.bounce_rate_pct}%`} + + single-page sessions + +
+
+ ) : null} + + + + {loading ? ( +
+ ) : chartData.length === 0 ? ( +

+ No data in this range yet. Visit a few pages, then refresh. +

+ ) : ( +
+ + + + + + + + + + = 90 ? 28 : trendRange <= 7 ? 4 : 8} + /> + + fmtShortDate(String(v))} + contentStyle={{ + background: 'var(--chart-daily-tooltip-bg)', + border: '1px solid var(--chart-daily-tooltip-border)', + borderRadius: '8px', + fontSize: '12px', + color: 'var(--chart-daily-tooltip-fg)', + }} + labelStyle={{ color: 'var(--chart-daily-tooltip-muted)' }} + itemStyle={{ color: 'var(--chart-daily-tooltip-fg)' }} + /> + + { + const { cx, cy, index } = dotProps; + if ( + cx == null || + cy == null || + index == null || + !isLocalPeak(chartData, index, 'page_views') + ) { + return null; + } + return ( + + ); + }} + activeDot={{ r: 5 }} + /> + + + {dailyChartMeta.meanPv > 0 && + Number.isFinite(dailyChartMeta.meanPv) ? ( + + ) : null} + {dailyChartMeta.lastDay ? ( + + ) : null} + + +
+ )} + + + + {loading ? ( + + ) : topPages.length === 0 ? ( +

No page view data yet.

+ ) : ( +
+ + + + + + + + + + + {topPages.map((page) => ( + + + + + + + ))} + +
PageViewsUnique visitorsAvg. active time
+ + {truncateUrl(page.url)} + + {page.page_views.toLocaleString()} + {page.unique_visitors.toLocaleString()} + + {fmtDuration(page.avg_duration_seconds)} +
+
+ )} + + {loading ? ( +
+ + + + + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + +
Avg pages / sessionSessions with 2+ pagesReturning visitorsUnique visitors
+
+
+ ) : engagement ? ( +
+ + + + + + + + + + + + + + + + + +
Avg pages / sessionSessions with 2+ pagesReturning visitorsUnique visitors
+ {Number(engagement.avg_pages_per_session ?? 0).toFixed(2)} + + {Number( + engagement.sessions_with_2plus_pages ?? 0 + ).toLocaleString()} + + of{' '} + {Number(engagement.sessions_total ?? 0).toLocaleString()}{' '} + sessions + + + {Number(engagement.returning_visitors ?? 0).toLocaleString()} + {Number(engagement.unique_visitors_30d ?? 0) > 0 ? ( + + {`${Math.round( + (100 * Number(engagement.returning_visitors ?? 0)) / + Number(engagement.unique_visitors_30d) + )}% of unique visitors`} + + ) : null} + + {Number(engagement.unique_visitors_30d ?? 0).toLocaleString()} +
+
+ ) : null} + + + + {loading ? ( + + ) : externalLinks.length === 0 ? ( +

+ No external link clicks yet. Open a few outbound links from the docs, + then refresh this page. +

+ ) : ( +
+ + + + + + + + + {externalLinks.map((row) => ( + + + + + ))} + +
LinkClicks
+ + {truncateHref(row.href)} + + + {Number(row.clicks).toLocaleString()} +
+
+ )} + + + + {loading ? ( + + ) : events.length === 0 ? ( +

No events recorded yet.

+ ) : ( +
+ + + + + + + + + + {events.map((ev, i) => ( + + + + + + ))} + +
TypeNameCount
+ + {ev.event_type} + + {ev.event_name ?? '—'}{ev.event_count.toLocaleString()}
+
+ )} +
+
+ + ); +} diff --git a/src/analytics/README.md b/src/analytics/README.md new file mode 100644 index 000000000..3e1676654 --- /dev/null +++ b/src/analytics/README.md @@ -0,0 +1,17 @@ +# Site analytics (Supabase) + +This folder holds everything for the optional docs-site analytics dashboard (traffic, outcomes, and engagement). + +| File | Role | +|------|------| +| `setup.sql` | Run in the Supabase SQL Editor (tables, RLS, views, grants). Re-run when this file adds views (for example `analytics_totals_7d`, `analytics_totals_90d` / `analytics_totals_365d`, matching `management_engagement_*` and `external_link_clicks_*` views for each dashboard date range). | +| `tracker.ts` | Injected on every page from `astro.config.mjs` — records page views, events, and active (foreground-tab) dwell time. | +| `Dashboard.tsx` | React UI for `/analytics/` (loaded from `src/pages/analytics/index.astro`). | + +Environment: set `PUBLIC_SUPABASE_URL` and `PUBLIC_SUPABASE_ANON_KEY` in your `.env` file (see `.env.example`). + +## Date range (7 / 30 / 90 / 365 days) + +The dashboard calls separate views for each window (for example `analytics_totals_7d`, `analytics_totals_90d`, `management_engagement_365d`, `external_link_clicks_7d`). If Last 7 days, Last 90 days, or Last 365 days shows a load error but Last 30 days works, your Supabase project is missing those views. Re-run the current `setup.sql` in the SQL Editor (safe to run again; it uses `CREATE OR REPLACE`). + +Import alias: `@analytics/Dashboard` (see `tsconfig.json`). diff --git a/src/analytics/setup.sql b/src/analytics/setup.sql new file mode 100644 index 000000000..f08fed794 --- /dev/null +++ b/src/analytics/setup.sql @@ -0,0 +1,550 @@ +-- --------------------------------------------------------------------------- +-- 3. Aggregation Views +-- --------------------------------------------------------------------------- + +-- Daily summary — used for the trend chart and summary card totals +-- Bounce rate = share of sessions that had exactly one page view that day +-- (not per-page is_bounce flags, which would exceed 100% for multi-page sessions) +-- Use BIGINT for counts so CREATE OR REPLACE VIEW matches prior column types (SUM() is numeric by default). +CREATE OR REPLACE VIEW analytics_summary +WITH (security_invoker = true) AS +WITH session_day AS ( + SELECT + session_id, + (timestamp::DATE) AS day, + COUNT(*) AS pv_count + FROM page_views + GROUP BY session_id, (timestamp::DATE) +), +day_sessions AS ( + SELECT + day, + COUNT(*)::BIGINT AS visits, + SUM(pv_count)::BIGINT AS page_views, + COUNT(*) FILTER (WHERE pv_count = 1)::BIGINT AS bounced_sessions + FROM session_day + GROUP BY day +), +unique_per_day AS ( + SELECT + (timestamp::DATE) AS day, + COUNT(DISTINCT visitor_id) AS unique_visitors + FROM page_views + GROUP BY (timestamp::DATE) +), +duration_per_day AS ( + SELECT + (timestamp::DATE) AS day, + AVG(duration_seconds)::NUMERIC AS avg_duration_seconds + FROM page_views + WHERE duration_seconds IS NOT NULL + GROUP BY (timestamp::DATE) +) +SELECT + ds.day, + ds.visits, + ds.page_views, + COALESCE(upd.unique_visitors, 0)::BIGINT AS unique_visitors, + COALESCE( + ROUND(dpd.avg_duration_seconds, 0), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 * ds.bounced_sessions::NUMERIC / NULLIF(ds.visits, 0), + 1 + ), + 0 + ) AS bounce_rate_pct +FROM day_sessions ds +LEFT JOIN unique_per_day upd ON upd.day = ds.day +LEFT JOIN duration_per_day dpd ON dpd.day = ds.day +ORDER BY ds.day DESC; + + +-- Top pages by view count +CREATE OR REPLACE VIEW top_pages +WITH (security_invoker = true) AS +SELECT + url, + COUNT(*) AS page_views, + COUNT(DISTINCT visitor_id) AS unique_visitors, + COALESCE( + ROUND(AVG(duration_seconds)::NUMERIC, 0), 0 + )::INTEGER AS avg_duration_seconds +FROM page_views +GROUP BY url +ORDER BY page_views DESC +LIMIT 50; + + +-- 7-day totals (same logic as analytics_totals_30d; used when the dashboard range is 7 days) +CREATE OR REPLACE VIEW analytics_totals_7d +WITH (security_invoker = true) AS +WITH windowed AS ( + SELECT * + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' +), +session_pv AS ( + SELECT session_id, COUNT(*) AS pv_count + FROM windowed + GROUP BY session_id +) +SELECT + (SELECT COUNT(DISTINCT session_id) FROM windowed) AS visits, + (SELECT COUNT(*) FROM windowed) AS page_views, + (SELECT COUNT(DISTINCT visitor_id) FROM windowed) AS unique_visitors, + COALESCE( + ( + SELECT ROUND(AVG(duration_seconds)::NUMERIC, 0) + FROM windowed + WHERE duration_seconds IS NOT NULL + ), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * (SELECT COUNT(*) FROM session_pv WHERE pv_count = 1)::NUMERIC + / NULLIF((SELECT COUNT(*) FROM session_pv), 0), + 1 + ), + 0 + ) AS bounce_rate_pct; + + +-- 30-day totals for summary cards — accurate unique counts across the window +-- Bounce rate = sessions with exactly one page view in the window / all sessions +CREATE OR REPLACE VIEW analytics_totals_30d +WITH (security_invoker = true) AS +WITH windowed AS ( + SELECT * + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' +), +session_pv AS ( + SELECT session_id, COUNT(*) AS pv_count + FROM windowed + GROUP BY session_id +) +SELECT + (SELECT COUNT(DISTINCT session_id) FROM windowed) AS visits, + (SELECT COUNT(*) FROM windowed) AS page_views, + (SELECT COUNT(DISTINCT visitor_id) FROM windowed) AS unique_visitors, + COALESCE( + ( + SELECT ROUND(AVG(duration_seconds)::NUMERIC, 0) + FROM windowed + WHERE duration_seconds IS NOT NULL + ), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * (SELECT COUNT(*) FROM session_pv WHERE pv_count = 1)::NUMERIC + / NULLIF((SELECT COUNT(*) FROM session_pv), 0), + 1 + ), + 0 + ) AS bounce_rate_pct; + + +-- 90-day totals (same logic as analytics_totals_30d; used when the dashboard range is 90 days) +CREATE OR REPLACE VIEW analytics_totals_90d +WITH (security_invoker = true) AS +WITH windowed AS ( + SELECT * + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' +), +session_pv AS ( + SELECT session_id, COUNT(*) AS pv_count + FROM windowed + GROUP BY session_id +) +SELECT + (SELECT COUNT(DISTINCT session_id) FROM windowed) AS visits, + (SELECT COUNT(*) FROM windowed) AS page_views, + (SELECT COUNT(DISTINCT visitor_id) FROM windowed) AS unique_visitors, + COALESCE( + ( + SELECT ROUND(AVG(duration_seconds)::NUMERIC, 0) + FROM windowed + WHERE duration_seconds IS NOT NULL + ), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * (SELECT COUNT(*) FROM session_pv WHERE pv_count = 1)::NUMERIC + / NULLIF((SELECT COUNT(*) FROM session_pv), 0), + 1 + ), + 0 + ) AS bounce_rate_pct; + + +-- 365-day totals (same logic; used when the dashboard range is one year) +CREATE OR REPLACE VIEW analytics_totals_365d +WITH (security_invoker = true) AS +WITH windowed AS ( + SELECT * + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' +), +session_pv AS ( + SELECT session_id, COUNT(*) AS pv_count + FROM windowed + GROUP BY session_id +) +SELECT + (SELECT COUNT(DISTINCT session_id) FROM windowed) AS visits, + (SELECT COUNT(*) FROM windowed) AS page_views, + (SELECT COUNT(DISTINCT visitor_id) FROM windowed) AS unique_visitors, + COALESCE( + ( + SELECT ROUND(AVG(duration_seconds)::NUMERIC, 0) + FROM windowed + WHERE duration_seconds IS NOT NULL + ), + 0 + )::INTEGER AS avg_duration_seconds, + COALESCE( + ROUND( + 100.0 + * (SELECT COUNT(*) FROM session_pv WHERE pv_count = 1)::NUMERIC + / NULLIF((SELECT COUNT(*) FROM session_pv), 0), + 1 + ), + 0 + ) AS bounce_rate_pct; + + +-- Event breakdown by type and name (excludes outcome, external_link, and legacy web_vital rows) +CREATE OR REPLACE VIEW events_summary +WITH (security_invoker = true) AS +SELECT + event_type, + event_name, + COUNT(*) AS event_count +FROM events +WHERE event_type NOT IN ('web_vital', 'outcome', 'external_link') +GROUP BY event_type, event_name +ORDER BY event_count DESC; + + +-- Engagement depth (last 7 days) — same shape as management_engagement_30d +CREATE OR REPLACE VIEW management_engagement_7d +WITH (security_invoker = true) AS +SELECT + COALESCE( + ( + SELECT ROUND(AVG(cnt)::numeric, 2) + FROM ( + SELECT session_id, COUNT(*)::bigint AS cnt + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + GROUP BY session_id + ) q + ), + 0 + ) AS avg_pages_per_session, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT session_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + GROUP BY session_id + HAVING COUNT(*) >= 2 + ) m + ), + 0 + ) AS sessions_with_2plus_pages, + COALESCE( + ( + SELECT COUNT(DISTINCT session_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + ), + 0 + ) AS sessions_total, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT visitor_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + GROUP BY visitor_id + HAVING COUNT(DISTINCT session_id) > 1 + ) r + ), + 0 + ) AS returning_visitors, + COALESCE( + ( + SELECT COUNT(DISTINCT visitor_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '7 days' + ), + 0 + ) AS unique_visitors_30d; + + +-- Engagement depth (last 30 days): pages per session and returning visitors +CREATE OR REPLACE VIEW management_engagement_30d +WITH (security_invoker = true) AS +SELECT + COALESCE( + ( + SELECT ROUND(AVG(cnt)::numeric, 2) + FROM ( + SELECT session_id, COUNT(*)::bigint AS cnt + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + GROUP BY session_id + ) q + ), + 0 + ) AS avg_pages_per_session, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT session_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + GROUP BY session_id + HAVING COUNT(*) >= 2 + ) m + ), + 0 + ) AS sessions_with_2plus_pages, + COALESCE( + ( + SELECT COUNT(DISTINCT session_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + ), + 0 + ) AS sessions_total, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT visitor_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + GROUP BY visitor_id + HAVING COUNT(DISTINCT session_id) > 1 + ) r + ), + 0 + ) AS returning_visitors, + COALESCE( + ( + SELECT COUNT(DISTINCT visitor_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '30 days' + ), + 0 + ) AS unique_visitors_30d; + + +-- Engagement depth (last 90 days) — same shape as management_engagement_30d +CREATE OR REPLACE VIEW management_engagement_90d +WITH (security_invoker = true) AS +SELECT + COALESCE( + ( + SELECT ROUND(AVG(cnt)::numeric, 2) + FROM ( + SELECT session_id, COUNT(*)::bigint AS cnt + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + GROUP BY session_id + ) q + ), + 0 + ) AS avg_pages_per_session, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT session_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + GROUP BY session_id + HAVING COUNT(*) >= 2 + ) m + ), + 0 + ) AS sessions_with_2plus_pages, + COALESCE( + ( + SELECT COUNT(DISTINCT session_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + ), + 0 + ) AS sessions_total, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT visitor_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + GROUP BY visitor_id + HAVING COUNT(DISTINCT session_id) > 1 + ) r + ), + 0 + ) AS returning_visitors, + COALESCE( + ( + SELECT COUNT(DISTINCT visitor_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '90 days' + ), + 0 + ) AS unique_visitors_30d; + + +-- Engagement depth (last 365 days) — same shape as management_engagement_30d +CREATE OR REPLACE VIEW management_engagement_365d +WITH (security_invoker = true) AS +SELECT + COALESCE( + ( + SELECT ROUND(AVG(cnt)::numeric, 2) + FROM ( + SELECT session_id, COUNT(*)::bigint AS cnt + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + GROUP BY session_id + ) q + ), + 0 + ) AS avg_pages_per_session, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT session_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + GROUP BY session_id + HAVING COUNT(*) >= 2 + ) m + ), + 0 + ) AS sessions_with_2plus_pages, + COALESCE( + ( + SELECT COUNT(DISTINCT session_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + ), + 0 + ) AS sessions_total, + COALESCE( + ( + SELECT COUNT(*)::bigint + FROM ( + SELECT visitor_id + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + GROUP BY visitor_id + HAVING COUNT(DISTINCT session_id) > 1 + ) r + ), + 0 + ) AS returning_visitors, + COALESCE( + ( + SELECT COUNT(DISTINCT visitor_id)::bigint + FROM page_views + WHERE timestamp >= NOW() - INTERVAL '365 days' + ), + 0 + ) AS unique_visitors_30d; + + +-- High-value outbound clicks (legacy outcome events), last 30 days +CREATE OR REPLACE VIEW outcome_clicks_30d +WITH (security_invoker = true) AS +SELECT + event_name AS outcome_key, + MAX(event_data->>'category') AS category, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'outcome' + AND timestamp >= NOW() - INTERVAL '30 days' +GROUP BY event_name +ORDER BY clicks DESC; + + +-- External link clicks by destination URL, last 7 days +CREATE OR REPLACE VIEW public.external_link_clicks_7d +WITH (security_invoker = true) AS +SELECT + event_data->>'href' AS href, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'external_link' + AND timestamp >= NOW() - INTERVAL '7 days' + AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' +GROUP BY event_data->>'href' +ORDER BY clicks DESC +LIMIT 100; + + +-- External link clicks by destination URL, last 30 days (one row per href) +CREATE OR REPLACE VIEW public.external_link_clicks_30d +WITH (security_invoker = true) AS +SELECT + event_data->>'href' AS href, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'external_link' + AND timestamp >= NOW() - INTERVAL '30 days' + AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' +GROUP BY event_data->>'href' +ORDER BY clicks DESC +LIMIT 100; + + +-- External link clicks by destination URL, last 90 days +CREATE OR REPLACE VIEW public.external_link_clicks_90d +WITH (security_invoker = true) AS +SELECT + event_data->>'href' AS href, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'external_link' + AND timestamp >= NOW() - INTERVAL '90 days' + AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' +GROUP BY event_data->>'href' +ORDER BY clicks DESC +LIMIT 100; + + +-- External link clicks by destination URL, last 365 days +CREATE OR REPLACE VIEW public.external_link_clicks_365d +WITH (security_invoker = true) AS +SELECT + event_data->>'href' AS href, + COUNT(*)::bigint AS clicks +FROM events +WHERE event_type = 'external_link' + AND timestamp >= NOW() - INTERVAL '365 days' + AND COALESCE(NULLIF(TRIM(event_data->>'href'), ''), '') <> '' +GROUP BY event_data->>'href' +ORDER BY clicks DESC +LIMIT 100; \ No newline at end of file diff --git a/src/analytics/tracker.ts b/src/analytics/tracker.ts new file mode 100644 index 000000000..fd754e10a --- /dev/null +++ b/src/analytics/tracker.ts @@ -0,0 +1,310 @@ +/** + * Custom Analytics Tracking Script + * + * Captures page views, active time on page (foreground tab only via Page Visibility), + * bounce rate, clicks, scroll depth, form interactions, and external link clicks, then writes + * them to Supabase via the REST API. + * + * Injected on every page via the Astro integration in astro.config.mjs. + * No cookies are set. Visitor IDs are random UUIDs stored in localStorage. + */ + +const SUPABASE_URL = import.meta.env.PUBLIC_SUPABASE_URL as string | undefined; +const SUPABASE_KEY = import.meta.env.PUBLIC_SUPABASE_ANON_KEY as string | undefined; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isBot(): boolean { + if (typeof navigator === 'undefined') return true; + if (navigator.webdriver) return true; + return /bot|crawl|slurp|spider|mediapartners|prerender|headlesschrome/i.test( + navigator.userAgent + ); +} + +/** Standalone analytics dashboard (`/analytics`); not doc topics under `/setup/analytics/`. */ +function isAnalyticsDashboardPage(): boolean { + const path = location.pathname.replace(/\/+$/, '') || '/'; + return path.endsWith('/analytics'); +} + +function uuid(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + // RFC 4122 version 4 + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice( + 16, + 20 + )}-${hex.slice(20)}`; + } + + // Last-resort non-cryptographic fallback in very old environments. + // Avoid Math.random to prevent predictable PRNG use in security checks. + return `legacy-${Date.now().toString(36)}-${performance.now().toString(36).replace('.', '')}`; +} + +function getIds(): { sessionId: string; visitorId: string } { + let sessionId = sessionStorage.getItem('_a_sid'); + if (!sessionId) { + sessionId = uuid(); + sessionStorage.setItem('_a_sid', sessionId); + } + + let visitorId = localStorage.getItem('_a_vid'); + if (!visitorId) { + visitorId = uuid(); + localStorage.setItem('_a_vid', visitorId); + } + + return { sessionId, visitorId }; +} + +// --------------------------------------------------------------------------- +// Supabase REST calls +// --------------------------------------------------------------------------- + +const supabaseHeaders = (): Record => ({ + apikey: SUPABASE_KEY!, + Authorization: `Bearer ${SUPABASE_KEY}`, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', +}); + +function dbInsert(table: string, data: Record): void { + if (!SUPABASE_URL || !SUPABASE_KEY) return; + void fetch(`${SUPABASE_URL}/rest/v1/${table}`, { + method: 'POST', + headers: supabaseHeaders(), + body: JSON.stringify(data), + keepalive: true, + mode: 'cors', + }).catch(() => { + // Analytics must never break the page + }); +} + +function isExternalUrl(url: URL): boolean { + return url.origin !== location.origin; +} + +function dbPatch( + table: string, + filter: string, + data: Record +): void { + if (!SUPABASE_URL || !SUPABASE_KEY) return; + void fetch(`${SUPABASE_URL}/rest/v1/${table}?${filter}`, { + method: 'PATCH', + headers: supabaseHeaders(), + body: JSON.stringify(data), + keepalive: true, + mode: 'cors', + }).catch(() => {}); +} + +// --------------------------------------------------------------------------- +// Main init +// --------------------------------------------------------------------------- + +(function init() { + if (!SUPABASE_URL || !SUPABASE_KEY) return; + if (isBot()) return; + if (isAnalyticsDashboardPage()) return; + + const { sessionId, visitorId } = getIds(); + const pageViewId = uuid(); + + // Track whether the visitor navigates to another page (not a bounce) + let navigated = false; + + // ---- Visible dwell time only (ignore background tabs / minimized windows) ---- + let visibleMs = 0; + let visibleStartedAt: number | null = null; + + function pauseVisibleClock() { + if (visibleStartedAt !== null) { + visibleMs += Date.now() - visibleStartedAt; + visibleStartedAt = null; + } + } + + function syncVisibleClock() { + if (document.visibilityState === 'visible') { + if (visibleStartedAt === null) { + visibleStartedAt = Date.now(); + } + } else { + pauseVisibleClock(); + } + } + + document.addEventListener('visibilitychange', syncVisibleClock); + syncVisibleClock(); + + // ---- Page view ---- + dbInsert('page_views', { + id: pageViewId, + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + referrer: document.referrer || null, + title: document.title, + }); + + // ---- Active duration + bounce on unload ---- + window.addEventListener('pagehide', () => { + pauseVisibleClock(); + const duration = Math.round(visibleMs / 1000); + dbPatch('page_views', `id=eq.${pageViewId}`, { + duration_seconds: duration, + // Per-page "no internal click before leave" — dashboards use session-level bounce in SQL + is_bounce: !navigated, + }); + }); + + // ---- Detect in-page navigation (marks session as non-bounce) ---- + document.addEventListener( + 'click', + (e) => { + const anchor = (e.target as Element).closest('a'); + if (anchor?.href) { + const dest = new URL(anchor.href, location.href); + if (dest.hostname === location.hostname) { + navigated = true; + } + } + }, + { capture: true } + ); + + // ---- External links: capture phase + sync dbInsert so the request starts before navigation ---- + document.addEventListener( + 'click', + (e) => { + const link = (e.target as Element | null)?.closest('a'); + if (!link?.href) return; + let dest: URL; + try { + dest = new URL(link.href, location.href); + } catch { + return; + } + if (!isExternalUrl(dest)) return; + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'external_link', + event_name: 'click', + event_data: { + href: dest.href.slice(0, 2000), + link_text: link.textContent?.trim().slice(0, 200) ?? null, + }, + }); + }, + true + ); + + // ---- Click events (generic; external links are skipped — recorded above) ---- + document.addEventListener('click', (e) => { + const target = e.target as Element; + const link = target.closest('a'); + const button = target.closest('button'); + const el = link ?? button; + if (!el) return; + + if (link?.href) { + try { + const dest = new URL(link.href, location.href); + if (isExternalUrl(dest)) return; + } catch { + // ignore bad href + } + } + + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'click', + event_name: link ? 'link_click' : 'button_click', + event_data: { + text: el.textContent?.trim().slice(0, 100) ?? null, + href: link?.getAttribute('href') ?? null, + }, + }); + }); + + // ---- Scroll depth ---- + const scrollThresholds = [25, 50, 75, 100] as const; + const firedDepths = new Set(); + + const onScroll = () => { + const docHeight = + document.documentElement.scrollHeight - window.innerHeight; + if (docHeight <= 0) return; + + const pct = Math.round((window.scrollY / docHeight) * 100); + + for (const threshold of scrollThresholds) { + if (pct >= threshold && !firedDepths.has(threshold)) { + firedDepths.add(threshold); + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'scroll', + event_name: `scroll_${threshold}pct`, + event_data: { depth_pct: threshold }, + }); + } + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + + // ---- Form interactions ---- + document.addEventListener('focusin', (e) => { + const el = e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) return; + + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'form', + event_name: 'field_focus', + event_data: { + field_type: (el as HTMLInputElement).type ?? el.tagName.toLowerCase(), + field_name: el.name || el.id || null, + }, + }); + }); + + document.addEventListener('submit', (e) => { + const form = e.target as HTMLFormElement; + dbInsert('events', { + session_id: sessionId, + visitor_id: visitorId, + url: location.href, + event_type: 'form', + event_name: 'form_submit', + event_data: { + form_id: form.id || null, + form_action: form.action || null, + }, + }); + }); +})(); diff --git a/src/content/docs/boilerplate/customizing-blocks.mdx b/src/content/docs/boilerplate/customizing-blocks.mdx index e97ea3f51..db09f41f7 100644 --- a/src/content/docs/boilerplate/customizing-blocks.mdx +++ b/src/content/docs/boilerplate/customizing-blocks.mdx @@ -272,7 +272,7 @@ $emptyCart.appendChild(emptyState); Track custom events for analytics: ```javascript -// Add to your block file or scripts/analytics.js +// Add to your block file or your storefront analytics integration import { events } from '@dropins/tools/event-bus.js'; events.on('cart/data', (cartData) => { diff --git a/src/env.d.ts b/src/env.d.ts index acef35f17..d18dfbc7d 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,2 +1,7 @@ /// /// + +interface ImportMetaEnv { + readonly PUBLIC_SUPABASE_URL: string; + readonly PUBLIC_SUPABASE_ANON_KEY: string; +} diff --git a/src/pages/analytics/index.astro b/src/pages/analytics/index.astro new file mode 100644 index 000000000..99f39532a --- /dev/null +++ b/src/pages/analytics/index.astro @@ -0,0 +1,675 @@ +--- +import Dashboard from '@analytics/Dashboard'; +import { pathWithBase } from '@utils/base'; + +const docsHome = pathWithBase(''); +--- + + + + + + + + Storefront Analytics — Commerce Storefront Docs + + + + + + + + + + +
+

+ Data stored in Supabase. Visitor IDs are random UUIDs — no personal + data is collected. +

+
+ + + + diff --git a/tsconfig.json b/tsconfig.json index b60869fd8..0d6d0626c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "@data/*": ["./src/data/*"], "@styles/*": ["./src/styles/*"], "@utils/*": ["./src/utils/*"], + "@analytics/*": ["./src/analytics/*"], "@images/*": ["./public/images/*"], "@storybook/*": ["./public/storybook-static/*"], },