From e0eb577e3ed7811435b13484cef6a528e639d50e Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Sun, 6 Sep 2026 16:03:13 +0000 Subject: [PATCH] Google analytics integration. Sentry.io integration. Vince integration. Region-scoped consent (denied-by-default in EU/EEA/UK/CH, granted elsewhere, DPDP-friendly notice) --- .dockerignore | 1 + Dockerfile | 17 + Makefile | 11 + ansible/files/Caddyfile.j2 | 7 + ansible/playbook.yml | 1 + ansible/roles/analytics/defaults/main.yaml | 4 + ansible/roles/analytics/tasks/main.yaml | 28 + ansible/vars/default.yml | 4 + docs/analytics-setup.md | 106 +++ package-lock.json | 974 ++++++++++++++++----- package.json | 7 +- public/robots.txt | 4 + scripts/generate-sitemap.mjs | 74 ++ scripts/pb-seed.mjs | 54 +- src/app/-analytics.tsx | 89 ++ src/app/__root.tsx | 36 + src/app/collections.$handle.tsx | 30 +- src/app/gifting.tsx | 3 +- src/app/privacy.tsx | 99 +++ src/app/products.$handle.tsx | 21 +- src/client.tsx | 33 + src/components/ConsentBanner.tsx | 53 ++ src/components/ContactForm.tsx | 8 + src/components/Footer.tsx | 29 +- src/components/GiftingTabs.tsx | 59 +- src/components/Link.tsx | 45 +- src/components/ProductCard.tsx | 10 +- src/components/SearchOverlay.tsx | 21 +- src/components/sections/CraftCinematic.tsx | 16 +- src/components/sections/Hero.tsx | 2 +- src/components/sections/MozimoEdit.tsx | 26 +- src/components/sections/SeasonalTeaser.tsx | 3 +- src/lib/analytics.ts | 364 ++++++++ src/lib/content.ts | 22 +- src/routeTree.gen.ts | 21 + src/server.ts | 35 + vite.config.ts | 16 + 37 files changed, 2059 insertions(+), 274 deletions(-) create mode 100644 ansible/roles/analytics/defaults/main.yaml create mode 100644 ansible/roles/analytics/tasks/main.yaml create mode 100644 docs/analytics-setup.md create mode 100644 public/robots.txt create mode 100644 scripts/generate-sitemap.mjs create mode 100644 src/app/-analytics.tsx create mode 100644 src/app/privacy.tsx create mode 100644 src/client.tsx create mode 100644 src/components/ConsentBanner.tsx create mode 100644 src/lib/analytics.ts create mode 100644 src/server.ts diff --git a/.dockerignore b/.dockerignore index 81176bd..2db0ca2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,5 +27,6 @@ tmp # repo tooling not needed inside the image scripts/* !scripts/pb-seed.mjs +!scripts/generate-sitemap.mjs /.pb/ *.md diff --git a/Dockerfile b/Dockerfile index 3880c48..fc47147 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,19 @@ FROM node:${NODE_VERSION}-trixie-slim AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . + +# Analytics ids are baked into the client bundle at build time (Vite +# statically replaces import.meta.env.VITE_*). The Makefile passes them +# from the deploy machine's .env — empty values cleanly disable each tool. +ARG VITE_GA4_MEASUREMENT_ID="" +ARG VITE_PLAUSIBLE_DOMAIN="" +ARG VITE_SENTRY_DSN="" +ARG VITE_SENTRY_RELEASE="" +ENV VITE_GA4_MEASUREMENT_ID=$VITE_GA4_MEASUREMENT_ID \ + VITE_PLAUSIBLE_DOMAIN=$VITE_PLAUSIBLE_DOMAIN \ + VITE_SENTRY_DSN=$VITE_SENTRY_DSN \ + VITE_SENTRY_RELEASE=$VITE_SENTRY_RELEASE + RUN npm run build # ── runner ────────────────────────────────────────────────── @@ -53,6 +66,10 @@ ENV NODE_ENV=production \ PB_URL=http://127.0.0.1:8090 \ SHOPIFY_TOKEN_CACHE=/app/pb_data/shopify-token.json +# Release id for server-side Sentry (matches the client bundle's baked value). +ARG SENTRY_RELEASE="" +ENV SENTRY_RELEASE=$SENTRY_RELEASE + # Install production dependencies only (react + @tanstack/* — no vite, # tailwind, eslint or typescript ship in the runtime image). COPY package.json package-lock.json ./ diff --git a/Makefile b/Makefile index 135f8aa..6c06ba3 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,21 @@ COMMIT ?= $(shell git rev-parse --short HEAD) +# Analytics ids baked into the client bundle — read from the local .env +# (gitignored) and passed to docker build. Empty values disable the tool. +VITE_ENV_KEYS := VITE_GA4_MEASUREMENT_ID VITE_PLAUSIBLE_DOMAIN VITE_SENTRY_DSN +BUILD_ARGS := $(shell for k in VITE_GA4_MEASUREMENT_ID VITE_PLAUSIBLE_DOMAIN VITE_SENTRY_DSN; do \ + v=$$(grep -E "^$$k=" .env 2>/dev/null | head -1 | cut -d= -f2-); \ + [ -n "$$v" ] && printf -- '--build-arg %s=%s ' "$$k" "$$v"; \ + done; true) +BUILD_ARGS += --build-arg VITE_SENTRY_RELEASE=$(COMMIT) --build-arg SENTRY_RELEASE=$(COMMIT) + .PHONY: build-production build-production: ## Multi-arch build from HEAD; tags registry.tanshu.com/mozimo: + :main @git archive --format=tar HEAD | docker buildx build \ --platform linux/amd64,linux/arm64 \ --tag registry.tanshu.com/mozimo:$(COMMIT) \ --tag registry.tanshu.com/mozimo:main \ + $(BUILD_ARGS) \ --push \ - @@ -15,5 +25,6 @@ build-check: ## Multi-arch compile check from HEAD, nothing pushed --platform linux/amd64,linux/arm64 \ --tag mozimo:check \ --pull \ + $(BUILD_ARGS) \ --progress=plain \ - diff --git a/ansible/files/Caddyfile.j2 b/ansible/files/Caddyfile.j2 index 13af9de..2e276b7 100644 --- a/ansible/files/Caddyfile.j2 +++ b/ansible/files/Caddyfile.j2 @@ -8,6 +8,13 @@ reverse_proxy {{ docker_container }}:{{ cms_port }} } + # First-party analytics (Vince) — script (/stats/js/script.js) and event + # endpoint (/stats/api/event) proxied same-origin so they survive ad + # blockers and never touch a third-party domain. + handle /stats/* { + reverse_proxy {{ vince_container }}:{{ vince_port }} + } + # TanStack Start frontend handle { reverse_proxy {{ docker_container }}:{{ www_port }} diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 9d6bf79..99b21e7 100755 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -10,5 +10,6 @@ roles: - upload - network + - analytics - mozimo - caddy diff --git a/ansible/roles/analytics/defaults/main.yaml b/ansible/roles/analytics/defaults/main.yaml new file mode 100644 index 0000000..aaecfa3 --- /dev/null +++ b/ansible/roles/analytics/defaults/main.yaml @@ -0,0 +1,4 @@ +--- +# vince_container / vince_port live in ansible/vars/default.yml (the Caddy +# template needs them at play level). +vince_image: vinceanalytics/vince:latest diff --git a/ansible/roles/analytics/tasks/main.yaml b/ansible/roles/analytics/tasks/main.yaml new file mode 100644 index 0000000..b743705 --- /dev/null +++ b/ansible/roles/analytics/tasks/main.yaml @@ -0,0 +1,28 @@ +--- +# Self-hosted first-party analytics (Vince — Plausible-protocol, single +# binary). Only reachable on the docker network: Caddy proxies /stats/* +# on the www host to this container, so browser requests stay first-party. +- name: Pull Vince image + docker_image: + name: "{{ vince_image }}" + source: pull + state: present + force_source: "{{ vince_image_force_update | default(false) }}" + +- name: Ensure Vince data directory exists + file: + path: "{{ host_directory }}/vince_data" + state: directory + owner: "1000" + group: "1000" + +- name: Create Vince container + docker_container: + name: "{{ vince_container }}" + image: "{{ vince_image }}" + state: started + restart_policy: "unless-stopped" + volumes: + - "{{ host_directory }}/vince_data:/data" + networks: + - name: "{{ docker_network }}" diff --git a/ansible/vars/default.yml b/ansible/vars/default.yml index ee2c11f..81ff901 100644 --- a/ansible/vars/default.yml +++ b/ansible/vars/default.yml @@ -20,6 +20,10 @@ docker_network: "{{ title }}_net" docker_image: "{{ registry }}/{{ title }}:{{ image_tag | default(tag) }}" docker_container: "{{ title }}-staging" +# self-hosted first-party analytics (Vince) — proxied at /stats/* on www host +vince_container: vince +vince_port: 8000 + # caddy publishes www (SSR + /api/* + /_/* -> pocketbase) and cms (admin) caddy_container: caddy diff --git a/docs/analytics-setup.md b/docs/analytics-setup.md new file mode 100644 index 0000000..ab8c78e --- /dev/null +++ b/docs/analytics-setup.md @@ -0,0 +1,106 @@ +# Analytics Setup — mozimo.in + +The site ships with a layered, code-complete analytics stack. This document +covers the dashboards you must create and the steps only you can do; after +them, everything works with no further code changes. + +``` +Visitor browser + ├─ gtag.js (GA4) ───────────────► GA4 property (consent-mode v2 gated) + ├─ /stats/js/script.js ─┐ + └─ events /stats/api/event ── Caddy ──► Vince container (first-party) +Server (SSR) ── errors ──────────► Sentry +Shopify (shop.mozimo.in) ─ purchase/checkout ──► same GA4 property +``` + +## 1. What is already wired in code + +| Layer | Implementation | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GA4 | Direct `gtag.js` injected in `src/app/__root.tsx` head scripts (production only, when `VITE_GA4_MEASUREMENT_ID` is set at build). `send_page_view: false` + `PageViewTracker` = exactly one `page_view` per navigation. Cross-domain linker to `shop.mozimo.in`. | +| Consent | Google Consent Mode v2: denied-by-default in EU/EEA/UK/CH, granted elsewhere. `ConsentBanner` shows only to those visitors (timezone-detected, cookie-stored choice, `/privacy` explains everything). | +| First-party layer | Vince (Plausible protocol), self-hosted via `ansible/roles/analytics`, proxied same-origin at `/stats/*` by Caddy — survives ad blockers, needs no consent. | +| E-commerce events | `view_item_list`, `select_item`, `view_item`, `begin_checkout`, `search`, `generate_lead` (forms — no PII), `click`/`contact` (all outbound/tel/mailto links via `src/components/Link.tsx`), `video_start`, `page_not_found`, `web_vitals`. **`purchase` is NOT hand-coded** — Shopify reports it (step 2.2). | +| Errors | Sentry client + server (custom entries `src/client.tsx` / `src/server.ts`), off unless DSN set. | + +## 2. One-time dashboards (manual) + +### 2.1 GA4 property + +1. analytics.google.com → Admin → **Create property** (`mozimo.in`), + currency **INR**, timezone **India**. +2. Add a **Web** data stream for `https://mozimo.in` → copy the + Measurement ID (`G-…`). +3. Put it in your local `.env` as `VITE_GA4_MEASUREMENT_ID=G-…` + (it is baked into the bundle at `docker build` by the Makefile). +4. Data retention: Admin → Data settings → set **14 months**. +5. Data filters: add your office IP as **Internal Traffic** (defined filter). +6. Cross-domain: Data stream → Configure tag settings → **Configure your + domains** → add `shop.mozimo.in`. +7. Mark **key events**: `generate_lead`, `begin_checkout` (Admin → Events). +8. Register **custom definitions** (dimensions): `web_vitals_metric`, + `web_vitals_rating`, `lead_type`, `lead_topic`, `lead_budget`, + `search_term` (dimension), `result_count`, `link_domain`, `video_title`. +9. Admin → Product links → **Search Console** → link the existing property + (site verification meta is already in `__root.tsx`). + +### 2.2 Shopify → GA4 (this is what makes purchases visible) + +1. Shopify Admin → Settings → Apps → **Google & YouTube** channel → install. +2. Connect the same Google account and select the GA4 property from 2.1. +3. Shopify then injects the Google tag on `shop.mozimo.in` (storefront + + checkout) and reports `purchase`/`begin_checkout`/`add_to_cart` into the + property. The cross-domain linker on both sides stitches sessions — + verify a `_gl=` parameter appears when clicking _Order this_ on a + product page. +4. Place one real (then refunded) test order and confirm `purchase` in + GA4 Realtime/DebugView. + +### 2.3 Sentry + +1. sentry.io → create project **mozimo-web** (platform: TanStack Start / + JavaScript). +2. Copy the DSN into your local `.env` (`VITE_SENTRY_DSN`) **and** into + `ansible/files/.env` (`SENTRY_DSN`) — client needs it at build, server at + runtime. +3. Optional sourcemap upload (better stack traces): put `SENTRY_AUTH_TOKEN`, + `SENTRY_ORG`, `SENTRY_PROJECT` in the build machine env. Builds without + them skip Sentry's vite plugin entirely. +4. Verify: visit any page with `?sentry-test=1`… simplest check is a thrown + error in devtools; a visible page_view + the event landing in Sentry is + enough for sign-off. + +### 2.4 Vince (first-party layer) + +1. `./deploy.sh` once (the playbook creates the container + Caddy route). +2. Open `https:///stats/` → register the admin account (first + visit only). Then **Add a website** with domain `mozimo.in`. +3. Set `VITE_PLAUSIBLE_DOMAIN=mozimo.in` in your local `.env` and redeploy — + the script tag is injected only when this is set. +4. Check `https:///stats/js/script.js` returns the script (200) + and that browsing the site populates the dashboard within seconds. +5. With uBlock Origin enabled: visits still appear in Vince but not GA4 — + expected and exactly why this layer exists. + +## 3. Verifying GA4 events (DebugView) + +Append `?debug_mode=1`… simpler: build with `VITE_GA4_DEBUG=1` in `.env` +temporarily — every event then carries `debug_mode: true` and appears in +GA4 Admin → DebugView (with the Tag Assistant Companion extension). + +Walk-through: home (page_view, view_item_list _The Mozimo Edit_) → +/chocolate → /collections/bars (view_item_list) → a product (view_item) → +_Order this_ (begin_checkout + outbound click with `_gl`) → /contact +submit (generate_lead) → search overlay (`search`). + +## 4. Operational notes + +- **Dev is always excluded** — analytics scripts only inject in production + builds. +- **Disabling a tool** = leaving its env var empty. Nothing breaks. +- **Changing the GA4 property** = change the env var and redeploy. +- Sitemap (`dist/client/sitemap.xml` from `scripts/generate-sitemap.mjs`) + and `robots.txt` are live for Search Console; submit the sitemap once in + Search Console → Sitemaps. +- Phase 2 (in-container cart) should add `add_to_cart`, `view_cart`, + `remove_from_cart` via the same helpers in `src/lib/analytics.ts`. diff --git a/package-lock.json b/package-lock.json index d09a99f..637296f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,14 @@ "name": "mozimo", "version": "0.2.0", "dependencies": { + "@sentry/tanstackstart-react": "^10.73.0", "@tanstack/react-router": "^1.168.3", "@tanstack/react-start": "^1.167.5", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "web-vitals": "^6.2.1" }, "devDependencies": { - "@eslint/eslintrc": "^3", "@eslint/js": "^10.0.1", "@svgr/webpack": "^8.1.0", "@tailwindcss/vite": "^4.2.2", @@ -28,6 +29,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", + "globals": "^17.12.0", "prettier": "^3.9.6", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", @@ -1685,42 +1687,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@eslint/config-helpers": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", @@ -1745,29 +1711,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", - "dev": true, - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint/js": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", @@ -1956,6 +1899,119 @@ "node": ">=20.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.220.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.220.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", + "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", @@ -2196,6 +2252,427 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "devOptional": true }, + "node_modules/@sentry/browser": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.73.0.tgz", + "integrity": "sha512-HqTe1S5RrWLufhX2LaFP3yNoMxfNDroh120bq1zdGHZfFDBMJQ0CDXxHO+L4UJfQ5dWdCCzWbXIAiZuWGa/DFQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0", + "@sentry/feedback": "10.73.0", + "@sentry/replay": "10.73.0", + "@sentry/replay-canvas": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.73.0.tgz", + "integrity": "sha512-qQygxJZ+RV779+iL1+lrJ4f4sZLgbgW0/JWPNp0YlcEAE62yCsdKbqoTEjB/EugdS4mSjBMX0chZC6rblu2Ycw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugins": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-10.73.0.tgz", + "integrity": "sha512-4X5m2hoqgKO6SxHR7MF9QnvxPNk0hwTJFixDJ/pzQGVM+C34j/rSabouBqd0M+ZdxFeAt/9kA5X0TyeceNo9YQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/cli": "^2.58.6", + "@sentry/core": "10.73.0", + "dotenv": "^17.4.2", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "rollup": ">=3.2.0", + "webpack": ">=5.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.73.0.tgz", + "integrity": "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.73.0.tgz", + "integrity": "sha512-D6nSngX+e46Mae2/oh2bxBvxNK1z2NERbuMAhB5sx9x4xMBWIyGnYYTECehvEqV9+AqGAgxxhOZoYIG3AmRwww==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.73.0.tgz", + "integrity": "sha512-jiMJ6GgXDw6UMGzJY+o0c8OoeA9OfHqZ/xEpHfDqy75hn+9CEkRkbNGCIdMvsc7wW/Se1Os394hHfTpU+YEskg==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.220.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0", + "@sentry/node-core": "10.73.0", + "@sentry/opentelemetry": "10.73.0", + "@sentry/server-utils": "10.73.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.73.0.tgz", + "integrity": "sha512-GHAGUmZPmm6FKfxfv2maVVJ/A99YAmd7oFOuKMDmITI6O/Msl6JB3vPTEpopVAHLW0LYJQBOjddL9C7o+Jt44g==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0", + "@sentry/opentelemetry": "10.73.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.73.0.tgz", + "integrity": "sha512-fQouPQKsH0CQrw6oAn1k0Z2I+tgyochCovifr5qNS69i0OzjknLa03WJyiZ/IuzXc4AVa5jAKfOeE9slABz8Qw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/react": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.73.0.tgz", + "integrity": "sha512-wJrzS98ddPvhGS/MKNHZyE8X7ecd4KwKdz7fHino2qkqBBrF4cxWr+q/uLTIk5PYWMkeJ4oKMjHAjOqmzGR4tg==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/replay": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.73.0.tgz", + "integrity": "sha512-nN2wjN/Y0J5BOJV5hqRHUEBfxwUsipp1PKjcDHh6Fpxnrtfldu3Y99E8cQInseo5heFdzEvrOHBBrqWZXOHVKQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.73.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.73.0.tgz", + "integrity": "sha512-sxa2lKkHPfF/j5xFpW7gocthWXRqyoHz8KCPy6yGc8plT477nl57iGSKhQDYsx1Ny10TLjs7YkIuPP1GY/Ax2Q==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.73.0", + "@sentry/replay": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.73.0.tgz", + "integrity": "sha512-QskripdKFbM/+gipC6mpa2crLwL7+VbkX84IpHg2z9UlYQ1kNKd3aMT+Qk9NLRSw/zu1rSIAvlbWfx4D3rgNAA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/tanstackstart-react": { + "version": "10.73.0", + "resolved": "https://registry.npmjs.org/@sentry/tanstackstart-react/-/tanstackstart-react-10.73.0.tgz", + "integrity": "sha512-AsOnqFjRu1dRMg9rguC13zmDtyAc2MFtGvMuOMwar2xr+zRI7D6keWIb3E/poiSE57b2P8We0AB8sg18U3uYpA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@sentry/browser-utils": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/react": "10.73.0", + "@sentry/vite-plugin": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/vite-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.4.0.tgz", + "integrity": "sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==", + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugins": "^10.64.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -3388,45 +3865,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -3532,6 +3970,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -3648,10 +4098,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { "version": "2.11.20", @@ -3671,13 +4124,15 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -3766,6 +4221,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "license": "MIT" + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -3775,12 +4236,6 @@ "node": ">= 10" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4039,6 +4494,18 @@ "tslib": "^2.0.3" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.420", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", @@ -4087,6 +4554,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4238,27 +4711,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/eslint/node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -4276,50 +4728,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -4422,7 +4830,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -4496,6 +4903,23 @@ "node": ">=6.9.0" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4509,10 +4933,11 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -4579,6 +5004,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4604,6 +5042,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.4.0.tgz", + "integrity": "sha512-Xfjwfarhe+LGmoaof+sexeNo3sGRysb5x56WrZLwHtmqT0OilSKtVWkv0lrCcMgSKyKP9oBogBAisHZvtJH0aw==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4666,8 +5118,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/jiti": { "version": "2.7.0", @@ -5018,7 +5469,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -5067,17 +5517,35 @@ "dev": true }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5117,6 +5585,26 @@ "tslib": "^2.0.3" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.54", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", @@ -5158,7 +5646,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -5173,7 +5660,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -5218,7 +5704,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -5238,6 +5723,31 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -5326,6 +5836,21 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5419,6 +5944,19 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5587,18 +6125,6 @@ "node": ">=20.16.0" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -5679,6 +6205,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6225,16 +6757,37 @@ } } }, + "node_modules/web-vitals": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", + "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -6277,7 +6830,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index d9c4c38..da3a85d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "dev": "node --env-file-if-exists=.env node_modules/vite/bin/vite.js dev", - "build": "vite build", + "build": "vite build && node scripts/generate-sitemap.mjs", "start": "node --env-file-if-exists=.env server.mjs", "preview": "vite preview --host 0.0.0.0", "lint": "eslint . && prettier --check .", @@ -17,10 +17,12 @@ "seed": "node --env-file-if-exists=.env scripts/pb-seed.mjs" }, "dependencies": { + "@sentry/tanstackstart-react": "^10.73.0", "@tanstack/react-router": "^1.168.3", "@tanstack/react-start": "^1.167.5", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "web-vitals": "^6.2.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -36,6 +38,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", + "globals": "^17.12.0", "prettier": "^3.9.6", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..5426c5c --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://mozimo.in/sitemap.xml diff --git a/scripts/generate-sitemap.mjs b/scripts/generate-sitemap.mjs new file mode 100644 index 0000000..d2cbdf2 --- /dev/null +++ b/scripts/generate-sitemap.mjs @@ -0,0 +1,74 @@ +/** + * Generates dist/client/sitemap.xml after `vite build`. + * + * Static routes are listed explicitly; journal essay slugs are parsed from + * src/data/catalog.ts so they stay in sync with the source of truth. + * Dynamic product/collection pages are intentionally excluded for now — + * they duplicate shop.mozimo.in content and churn constantly. + * + * Origin: SITE_ORIGIN env (default https://mozimo.in). + */ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const origin = (process.env.SITE_ORIGIN ?? "https://mozimo.in").replace( + /\/+$/, + "", +); + +const STATIC_ROUTES = [ + { path: "/", priority: 1.0, changefreq: "weekly" }, + { path: "/chocolate", priority: 0.9, changefreq: "weekly" }, + { path: "/gifting", priority: 0.9, changefreq: "weekly" }, + { path: "/experiences", priority: 0.7, changefreq: "monthly" }, + { path: "/bespoke", priority: 0.8, changefreq: "monthly" }, + { path: "/contact", priority: 0.5, changefreq: "yearly" }, + { path: "/our-world", priority: 0.6, changefreq: "monthly" }, + { path: "/journal", priority: 0.6, changefreq: "monthly" }, + { path: "/privacy", priority: 0.2, changefreq: "yearly" }, +]; + +// journal essay slugs live in src/data/catalog.ts (the essays block) +const catalogSrc = readFileSync(join(root, "src/data/catalog.ts"), "utf8"); +const slugs = [...catalogSrc.matchAll(/^\s{4}slug:\s*"([^"]+)"/gm)].map( + (m) => m[1], +); + +const today = new Date().toISOString().slice(0, 10); + +const urls = [ + ...STATIC_ROUTES, + ...slugs.map((slug) => ({ + path: `/journal/${slug}`, + priority: 0.5, + changefreq: "yearly", + })), +]; + +const xml = [ + '', + '', + ...urls.map(({ path, priority, changefreq }) => + [ + " ", + ` ${origin}${path === "/" ? "/" : path}`, + ` ${today}`, + changefreq ? ` ${changefreq}` : null, + priority !== undefined ? ` ${priority}` : null, + " ", + ] + .filter(Boolean) + .join("\n"), + ), + "", + "", +].join("\n"); + +const outDir = join(root, "dist/client"); +mkdirSync(outDir, { recursive: true }); +writeFileSync(join(outDir, "sitemap.xml"), xml, "utf8"); +console.log( + `[sitemap] ${urls.length} URLs → dist/client/sitemap.xml (origin ${origin})`, +); diff --git a/scripts/pb-seed.mjs b/scripts/pb-seed.mjs index ed6b681..7e19768 100644 --- a/scripts/pb-seed.mjs +++ b/scripts/pb-seed.mjs @@ -518,19 +518,29 @@ async function main() { // one-off migration: gifting tab CTAs must keep users on mozimo.in, // never bounce them out to the shopify domain if (name === "gifting_tabs") { - const all = await authed(`/api/collections/${name}/records?perPage=100`); + const all = await authed( + `/api/collections/${name}/records?perPage=100`, + ); for (const rec of all.body.items ?? []) { const url = rec.cta_url ?? ""; if (url.startsWith("https://shop.mozimo.in/")) { const internal = url.replace("https://shop.mozimo.in", ""); - const patched = await authed(`/api/collections/${name}/records/${rec.id}`, { - method: "PATCH", - body: JSON.stringify({ cta_url: internal }), - }); + const patched = await authed( + `/api/collections/${name}/records/${rec.id}`, + { + method: "PATCH", + body: JSON.stringify({ cta_url: internal }), + }, + ); if (patched.ok) { - console.log(`[pb-seed] migrated cta_url for "${rec.name}" -> ${internal}`); + console.log( + `[pb-seed] migrated cta_url for "${rec.name}" -> ${internal}`, + ); } else { - console.warn(`[pb-seed] cta migration failed for "${rec.name}":`, patched.status); + console.warn( + `[pb-seed] cta migration failed for "${rec.name}":`, + patched.status, + ); } } } @@ -539,26 +549,34 @@ async function main() { // migration: fill section copy that arrived after the record was seeded // (only empty fields — anything edited in the admin is never overwritten) if (name === "gifting_page") { - const rec = (await authed(`/api/collections/${name}/records?perPage=1`)).body.items?.[0]; + const rec = (await authed(`/api/collections/${name}/records?perPage=1`)) + .body.items?.[0]; if (rec) { const patch = {}; for (const [k, v] of Object.entries(SEEDS[name][0] ?? {})) { if (k !== "sort" && !rec[k]) patch[k] = v; } if (Object.keys(patch).length) { - const patched = await authed(`/api/collections/${name}/records/${rec.id}`, { - method: "PATCH", - body: JSON.stringify(patch), - }); + const patched = await authed( + `/api/collections/${name}/records/${rec.id}`, + { + method: "PATCH", + body: JSON.stringify(patch), + }, + ); if (patched.ok) - console.log(`[pb-seed] gifting_page: filled ${Object.keys(patch).length} empty field(s)`); + console.log( + `[pb-seed] gifting_page: filled ${Object.keys(patch).length} empty field(s)`, + ); } } } // migration: make sure editorial rows/settings that shipped later exist if (name === "collection_rows") { - const all = (await authed(`/api/collections/${name}/records?perPage=100`)).body.items ?? []; + const all = + (await authed(`/api/collections/${name}/records?perPage=100`)).body + .items ?? []; for (const row of COLLECTION_ROWS) { if (!all.some((r) => r.shopify_handle === row.shopify_handle)) { const inserted = await authed(`/api/collections/${name}/records`, { @@ -566,12 +584,16 @@ async function main() { body: JSON.stringify(row), }); if (inserted.ok) - console.log(`[pb-seed] collection_rows: added missing row "${row.name_override}"`); + console.log( + `[pb-seed] collection_rows: added missing row "${row.name_override}"`, + ); } } } if (name === "site_settings") { - const all = (await authed(`/api/collections/${name}/records?perPage=100`)).body.items ?? []; + const all = + (await authed(`/api/collections/${name}/records?perPage=100`)).body + .items ?? []; for (const setting of SITE_SETTINGS) { if (!all.some((r) => r.key === setting.key)) { const inserted = await authed(`/api/collections/${name}/records`, { diff --git a/src/app/-analytics.tsx b/src/app/-analytics.tsx new file mode 100644 index 0000000..16b21b1 --- /dev/null +++ b/src/app/-analytics.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from "react"; +import { useRouterState } from "@tanstack/react-router"; +import { + eeaVisitor, + pageView, + saveConsent, + storedConsent, + track, + type ConsentChoice, +} from "@/lib/analytics"; +import ConsentBanner from "@/components/ConsentBanner"; + +/** + * Client-only analytics pieces mounted from `__root.tsx` (the leading `-` + * keeps this file out of the route tree). + * + * PageViewTracker fires exactly one `page_view` per load/navigation — the + * gtag config sets `send_page_view: false` so nothing double counts. + * WebVitalsReporter streams field LCP/INP/CLS/FCP/TTFB into GA4 as a + * `web_vitals` event. + */ + +export function PageViewTracker() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + useEffect(() => { + pageView(pathname, document.title); + }, [pathname]); + + return null; +} + +export function ConsentGate() { + const [mode, setMode] = useState<"off" | "banner">("off"); + + useEffect(() => { + // EU/EEA/UK/CH visitors with no stored choice see the banner once; + // everyone else keeps the regional defaults set in the head snippet. + if (eeaVisitor() && storedConsent() === null) setMode("banner"); + }, []); + + if (mode !== "banner") return null; + + const choose = (choice: ConsentChoice) => { + saveConsent(choice); + setMode("off"); + }; + + return ; +} + +/** Field performance → GA4 (`web_vitals` event, one per metric per session). */ +export function WebVitalsReporter() { + useEffect(() => { + let cancelled = false; + import("web-vitals").then(({ onCLS, onFCP, onINP, onLCP, onTTFB }) => { + if (cancelled) return; + const report = (metric: { + name: string; + value: number; + id: string; + rating: string; + }) => { + track("web_vitals", { + // CLS is unitless — scale ×1000 so the value is an integer. + web_vitals_value: Math.round( + metric.name === "CLS" ? metric.value * 1000 : metric.value, + ), + web_vitals_id: metric.id, + web_vitals_metric: metric.name, + web_vitals_rating: metric.rating, + value: Math.round( + metric.name === "CLS" ? metric.value * 1000 : metric.value, + ), + }); + }; + onCLS(report); + onFCP(report); + onINP(report); + onLCP(report); + onTTFB(report); + }); + return () => { + cancelled = true; + }; + }, []); + + return null; +} diff --git a/src/app/__root.tsx b/src/app/__root.tsx index 4fd0dc0..8feb1eb 100644 --- a/src/app/__root.tsx +++ b/src/app/__root.tsx @@ -6,6 +6,12 @@ import { } from "@tanstack/react-router"; import "./globals.css"; import Providers from "./-providers"; +import { ConsentGate, PageViewTracker, WebVitalsReporter } from "./-analytics"; +import { + consentDefaultsSnippet, + ga4HeadScripts, + PLAUSIBLE_DOMAIN, +} from "@/lib/analytics"; import { getAnnouncement } from "@/lib/content"; export const Route = createRootRoute({ @@ -97,10 +103,36 @@ export const Route = createRootRoute({ { rel: "preload", as: "image", href: "/banners/banner-1.jpg" }, { rel: "preload", as: "image", href: "/logo/logo-white.svg" }, ], + scripts: analyticsHeadScripts(), }), component: RootDocument, }); +/** + * Analytics tags, production-only (nothing loads in `npm run dev` or when the + * corresponding env var is unset). Order matters: + * 1. Consent Mode v2 regional defaults (must precede the gtag loader), + * 2. GA4 gtag loader + config (cross-domain linked with shop.mozimo.in), + * 3. first-party Vince/Plausible-protocol script, proxied at /stats. + */ +function analyticsHeadScripts() { + if (!import.meta.env.PROD) return []; + return [ + ...(ga4HeadScripts().length + ? [{ children: consentDefaultsSnippet() }, ...ga4HeadScripts()] + : []), + ...(PLAUSIBLE_DOMAIN + ? [ + { + src: "/stats/js/script.js", + defer: true, + "data-domain": PLAUSIBLE_DOMAIN, + }, + ] + : []), + ]; +} + function RootDocument() { return ( @@ -111,6 +143,10 @@ function RootDocument() { + {/* analytics — client-only, render nothing */} + + + diff --git a/src/app/collections.$handle.tsx b/src/app/collections.$handle.tsx index 7d4c7f8..549d58a 100644 --- a/src/app/collections.$handle.tsx +++ b/src/app/collections.$handle.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { createFileRoute, Link, notFound } from "@tanstack/react-router"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; @@ -7,6 +7,8 @@ import Reveal from "@/components/Reveal"; import ProductCard from "@/components/ProductCard"; import { getCollectionPage } from "@/lib/content"; import type { Product } from "@/data/catalog"; +import { track, toItem, useImpression, viewItemList } from "@/lib/analytics"; +import { useRouterState } from "@tanstack/react-router"; export const Route = createFileRoute("/collections/$handle")({ loader: async ({ params }) => { @@ -24,6 +26,10 @@ export const Route = createFileRoute("/collections/$handle")({ }); function CollectionNotFound() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + useEffect(() => { + track("page_not_found", { page_path: pathname }); + }, [pathname]); return (
@@ -76,6 +82,7 @@ function CollectionPage() { >
@@ -135,9 +142,11 @@ type BandId = (typeof PRICE_BANDS)[number]["id"]; /** Sort dropdown + price bands + product-type chips; auto-hides on small * collections. Client-side over the already-loaded collection. */ function CollectionBrowser({ + handle, title, products, }: { + handle: string; title: string; products: Product[]; }) { @@ -145,6 +154,15 @@ function CollectionBrowser({ const [band, setBand] = useState(null); const [type, setType] = useState(null); + const listId = `collection_${handle}`; + const gridRef = useImpression(() => { + viewItemList( + listId, + title, + products.map((p, i) => toItem(p, { listId, listName: title, index: i })), + ); + }); + const types = useMemo(() => { const set = new Set(); for (const p of products) if (p.productType) set.add(p.productType); @@ -258,10 +276,16 @@ function CollectionBrowser({ )} {visible.length ? ( -
+
{visible.map((p, i) => ( - + ))}
diff --git a/src/app/gifting.tsx b/src/app/gifting.tsx index df7889d..7a3b849 100644 --- a/src/app/gifting.tsx +++ b/src/app/gifting.tsx @@ -14,8 +14,7 @@ export const Route = createFileRoute("/gifting")({ }); function GiftingPage() { - const { hero, sections, tabs, hampers, seasonal } = - Route.useLoaderData(); + const { hero, sections, tabs, hampers, seasonal } = Route.useLoaderData(); return (
diff --git a/src/app/privacy.tsx b/src/app/privacy.tsx new file mode 100644 index 0000000..2788257 --- /dev/null +++ b/src/app/privacy.tsx @@ -0,0 +1,99 @@ +import { createFileRoute } from "@tanstack/react-router"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import PageHero from "@/components/PageHero"; + +export const Route = createFileRoute("/privacy")({ + component: PrivacyPage, + head: () => ({ + meta: [{ title: "Cookies & Analytics — Mozimo" }], + }), +}); + +function PrivacyPage() { + return ( +
+
+
+ + Cookies &{" "} + + analytics + + + } + lead="How this site is measured — what we collect, what we never collect, and how to change your mind." + /> + +
+
+

+ The short version +

+

+ We measure this site so we can make it better — which pages are + read, which chocolates are loved, where journeys stall. Two + systems do that work, and neither exists to follow you around the + internet. +

+ +

+ 1. Aggregate, cookieless counting +

+

+ A first-party, cookie-free counter (hosted on our own + infrastructure) records anonymous page views — no cookies, no + personal data, no cross-site tracking, no fingerprinting. It needs + no consent and cannot identify you. +

+ +

+ 2. Google Analytics 4 — only with consent +

+

+ Where required by law (the EU/EEA, UK and Switzerland), Google + Analytics loads with all measurement switched off until you + accept. Visitors elsewhere are measured by default, in line with + India’s Digital Personal Data Protection Act notice + requirements — you can decline at any time below. When granted, + GA4 receives pseudonymised events (IP addresses are truncated by + Google) about pages viewed, products seen and enquiry forms + submitted. We never send your name, email, phone number or message + text to Google. +

+ +

+ 3. Errors & performance +

+

+ Technical faults (a page that failed to render, a slow-loading + section) may be reported to Sentry. These reports contain no form + submissions or account details — just the technical trace needed + to fix the fault. +

+ +

+ Your choice, any time +

+

+ If you accepted cookies and would like to withdraw, clear this + site’s cookies in your browser (or use private browsing) and + you will be asked again on your next visit. Declining never limits + what you can browse or buy. Orders, accounts and checkouts live on + our Shopify store, whose own privacy policy governs that data. +

+ +

+ Questions? Write to the atelier via the contact page or call +91 + 172 404 5414. +

+
+
+
+
+
+ ); +} diff --git a/src/app/products.$handle.tsx b/src/app/products.$handle.tsx index 0974639..4c41cdf 100644 --- a/src/app/products.$handle.tsx +++ b/src/app/products.$handle.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { createFileRoute, Link, notFound } from "@tanstack/react-router"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; @@ -6,6 +6,8 @@ import Reveal from "@/components/Reveal"; import Rise from "@/components/Rise"; import { getProductPage } from "@/lib/content"; import { formatPrice } from "@/data/catalog"; +import { beginCheckout, toItem, track, viewItem } from "@/lib/analytics"; +import { useRouterState } from "@tanstack/react-router"; export const Route = createFileRoute("/products/$handle")({ loader: async ({ params }) => { @@ -23,6 +25,11 @@ export const Route = createFileRoute("/products/$handle")({ }); function ProductNotFound() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + useEffect(() => { + // the root PageViewTracker still records the page_view; this flags it + track("page_not_found", { page_path: pathname }); + }, [pathname]); return (
@@ -43,6 +50,10 @@ function ProductNotFound() { function ProductPage() { const product = Route.useLoaderData(); + useEffect(() => { + viewItem(toItem(product)); + }, [product]); + return (
@@ -96,6 +107,14 @@ function ProductPage() { target="_blank" rel="noopener noreferrer" className="btn btn-dark" + onClick={() => + beginCheckout( + toItem(product, { + listId: "product_page", + listName: "Product page", + }), + ) + } > {product.available ? "Order this — checkout on Shopify" diff --git a/src/client.tsx b/src/client.tsx new file mode 100644 index 0000000..621f8b5 --- /dev/null +++ b/src/client.tsx @@ -0,0 +1,33 @@ +import { StrictMode, startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { StartClient } from "@tanstack/react-start/client"; +import { init as sentryInit } from "@sentry/tanstackstart-react"; + +/** + * Custom client entry (auto-detected by the TanStack Start plugin) — the + * framework default hydration sequence plus client-side Sentry. + * + * Sentry only activates in a production build with VITE_SENTRY_DSN set. + * Performance tracing stays off client-side; field performance is reported + * through Web Vitals instead (see src/app/-analytics.tsx). + */ +if (import.meta.env.PROD && import.meta.env.VITE_SENTRY_DSN) { + sentryInit({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT || "production", + ...(import.meta.env.VITE_SENTRY_RELEASE + ? { release: import.meta.env.VITE_SENTRY_RELEASE } + : {}), + tracesSampleRate: 0, + sendDefaultPii: false, + }); +} + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/src/components/ConsentBanner.tsx b/src/components/ConsentBanner.tsx new file mode 100644 index 0000000..de6c782 --- /dev/null +++ b/src/components/ConsentBanner.tsx @@ -0,0 +1,53 @@ +/** + * GDPR / DPDP consent bar — shown only to EU/EEA/UK/CH visitors (see + * `eeaVisitor()` in lib/analytics). Choice feeds Google Consent Mode v2; + * the first-party layer stays cookieless and needs no consent. + */ +export default function ConsentBanner({ + onChoice, +}: { + onChoice: (choice: "granted" | "denied") => void; +}) { + return ( +
+
+
+

+ Cookies & measurement +

+

+ We use cookies to understand how the atelier’s site is used + and how our chocolate reaches you. Analytics stay optional — accept + or decline, and browse freely either way.{" "} + + Learn more + +

+
+
+ + +
+
+
+ ); +} diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx index 575fa4a..7405881 100644 --- a/src/components/ContactForm.tsx +++ b/src/components/ContactForm.tsx @@ -1,5 +1,6 @@ import { useState, type FormEvent, type ReactNode } from "react"; import { submitEnquiry, type EnquiryResult } from "@/data/enquiry"; +import { generateLead } from "@/lib/analytics"; /** * Editorial form used in two places: @@ -103,6 +104,13 @@ export default function ContactForm({ setPending(false); if (result.ok) { + // no PII in the event — only shape-of-enquiry facts + generateLead(variant, { + topic: bespoke ? get("enquiryType") : get("topic"), + budget: get("budget") || undefined, + hasPhone: Boolean(get("phone")), + hasCompany: Boolean(get("company")), + }); form.reset(); setDone(true); } else { diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 338b0ff..6d56486 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -68,6 +68,10 @@ const POLICIES = [ label: "Privacy Policy", href: "https://shop.mozimo.in/policies/privacy-policy", }, + { + label: "Cookies & Analytics", + href: "/privacy", + }, { label: "Refund & Return Policy", href: "https://shop.mozimo.in/policies/refund-policy", @@ -164,14 +168,23 @@ export default function Footer() { diff --git a/src/components/GiftingTabs.tsx b/src/components/GiftingTabs.tsx index 64e2130..cd3c311 100644 --- a/src/components/GiftingTabs.tsx +++ b/src/components/GiftingTabs.tsx @@ -3,6 +3,7 @@ import Link from "@/components/Link"; import Reveal from "@/components/Reveal"; import ProductCard from "@/components/ProductCard"; import { seasonal, tabSlug, type Product } from "@/data/catalog"; +import { toItem, useImpression, viewItemList } from "@/lib/analytics"; /** * "Who is it for?" — rounded-square tiles ordered price-wise, from @@ -30,6 +31,40 @@ export type GiftingTabView = { const slugOf = (t: GiftingTabView) => t.slug ?? tabSlug(t.name); +/** Product grid for a gifting tier — reports `view_item_list` on first view. */ +function ProductGrid({ + products, + listId, + listName, +}: { + products: Product[]; + listId: string; + listName: string; +}) { + const gridRef = useImpression(() => { + viewItemList( + listId, + listName, + products.map((p, i) => toItem(p, { listId, listName, index: i })), + ); + }); + return ( +
+ {products.map((p, i) => ( + + + + ))} +
+ ); +} + function TabCta({ tab }: { tab: GiftingTabView }) { if (!tab.cta) return null; return ( @@ -184,13 +219,11 @@ function ChildrenPanel({ children }: { children: GiftingTabView[] }) { {child.cta && }
{child.products?.length ? ( -
- {child.products.map((p, i) => ( - - - - ))} -
+ ) : (

This occasion is composed to order — the collection goes live @@ -301,13 +334,11 @@ export default function GiftingTabs({ tabs }: { tabs: GiftingTabView[] }) { {tab.children?.length ? ( ) : tab.mode === "products" && tab.products ? ( -

- {tab.products.map((p, i) => ( - - - - ))} -
+ ) : tab.mode === "seasonal" ? ( diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 925c4fb..0e4af41 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -1,5 +1,6 @@ import { Link as RouterLink } from "@tanstack/react-router"; -import type { AnchorHTMLAttributes, ReactNode } from "react"; +import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from "react"; +import { track } from "@/lib/analytics"; type Props = { href: string; @@ -12,8 +13,18 @@ type Props = { * Link that uses client-side routing for internal URLs and falls back to a * plain anchor (new tab, noopener) for external ones — so commerce links can * move between mozimo.in and shop.mozimo.in without touching every call site. + * + * It is also the single choke point for click analytics: every tel:/mailto: + * link reports a `contact` event, every external http(s) link a `click` + * event with outbound context. */ -export default function Link({ href, external, children, ...rest }: Props) { +export default function Link({ + href, + external, + children, + onClick, + ...rest +}: Props) { const isExternal = external || href.startsWith("http") || @@ -21,10 +32,33 @@ export default function Link({ href, external, children, ...rest }: Props) { href.startsWith("tel:") || href.startsWith("mailto:"); + const handleClick = (event: MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented) return; + if (href.startsWith("tel:")) { + track("contact", { channel: "phone", link_url: href }); + } else if (href.startsWith("mailto:")) { + track("contact", { channel: "email", link_url: href }); + } else if (isExternal) { + let domain = ""; + try { + domain = new URL(href, window.location.href).hostname; + } catch { + // malformed href — leave domain empty + } + track("click", { + link_url: href, + link_domain: domain || undefined, + outbound: true, + }); + } + }; + if (isExternal) { return ( + {children} ); diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index 92ff850..57f784f 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -1,8 +1,15 @@ import Link from "@/components/Link"; import { formatPrice, type Product } from "@/data/catalog"; +import { selectItem, toItem } from "@/lib/analytics"; + +type Props = { + product: Product; + /** list context for `select_item` (item_list_id/name/index) */ + list?: { id: string; name: string; index?: number }; +}; /** Product tile with image, name, price (+weight) and a link to the product page. */ -export default function ProductCard({ product }: { product: Product }) { +export default function ProductCard({ product, list }: Props) { const internal = Boolean(product.handle); return ( selectItem(toItem(product, list))} >
(); export default function SearchOverlay({ open, onClose, @@ -60,6 +67,10 @@ export default function SearchOverlay({ const res = await searchShop({ data: q }); setResults(res); setTried(true); + if (!searchedTerms.has(q)) { + searchedTerms.add(q); + trackSearch(q, res.products.length + res.collections.length); + } } catch { setResults({ products: [], collections: [] }); setTried(true); @@ -155,7 +166,15 @@ export default function SearchOverlay({ { + selectItem( + toItem(p, { + listId: "search", + listName: "Search results", + }), + ); + onClose(); + }} className="group block" > diff --git a/src/components/sections/CraftCinematic.tsx b/src/components/sections/CraftCinematic.tsx index e8b1658..800f3a0 100644 --- a/src/components/sections/CraftCinematic.tsx +++ b/src/components/sections/CraftCinematic.tsx @@ -1,8 +1,22 @@ import Reveal from "@/components/Reveal"; +import { track, useImpression } from "@/lib/analytics"; export default function CraftCinematic() { + // autoplaying ambience — count "engaged view" once the section is 30% seen + const sectionRef = useImpression(() => { + track("video_start", { + video_provider: "self-hosted", + video_title: "Made by Hand — tempering", + video_url: "/videos/tempering.mp4", + }); + }); + return ( -
+
diff --git a/src/components/sections/MozimoEdit.tsx b/src/components/sections/MozimoEdit.tsx index 720bb7e..1266c90 100644 --- a/src/components/sections/MozimoEdit.tsx +++ b/src/components/sections/MozimoEdit.tsx @@ -2,8 +2,22 @@ import Reveal from "@/components/Reveal"; import SectionHeading from "@/components/SectionHeading"; import ProductCard from "@/components/ProductCard"; import { bestsellers } from "@/data/catalog"; +import { toItem, useImpression, viewItemList } from "@/lib/analytics"; + +const LIST_ID = "home_edit"; +const LIST_NAME = "The Mozimo Edit"; export default function MozimoEdit() { + const gridRef = useImpression(() => { + viewItemList( + LIST_ID, + LIST_NAME, + bestsellers.map((p, i) => + toItem(p, { listId: LIST_ID, listName: LIST_NAME, index: i }), + ), + ); + }); + return (
@@ -26,13 +40,19 @@ export default function MozimoEdit() {
-
- {bestsellers.map((p) => ( +
+ {bestsellers.map((p, i) => (
- +
))}
diff --git a/src/components/sections/SeasonalTeaser.tsx b/src/components/sections/SeasonalTeaser.tsx index 4be3600..2dfe80f 100644 --- a/src/components/sections/SeasonalTeaser.tsx +++ b/src/components/sections/SeasonalTeaser.tsx @@ -20,8 +20,7 @@ export default function SeasonalTeaser({

- {title}{" "} - {script} + {title} {script}

diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..e699a16 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,364 @@ +import { useEffect, useRef, type RefObject } from "react"; + +/** + * Analytics core — GA4 (direct gtag.js) with Google Consent Mode v2. + * + * Design rules: + * - Analytics must never break the page: every call is fire-and-forget and + * wrapped, so a blocked script or missing measurement ID is a silent no-op. + * - The module is import-safe on the server: `window` is only touched inside + * functions that run client-side. + * - `VITE_GA4_MEASUREMENT_ID` is baked at build time (Vite replaces + * `import.meta.env.VITE_*`); an empty ID disables GA4 entirely. + * + * Event taxonomy mirrors GA4's recommended names so standard reports work: + * page_view, view_item_list, select_item, view_item, begin_checkout, + * search, view_search_results, generate_lead, click, contact, video_start, + * page_not_found, web_vitals. + * Purchases themselves are NOT fired here — checkout lives on + * shop.mozimo.in and Shopify's Google & YouTube channel reports + * `purchase` into the same GA4 property. + */ + +/* ── build-time configuration ─────────────────────────────────── */ + +export const GA4_ID: string = import.meta.env.VITE_GA4_MEASUREMENT_ID ?? ""; +export const GA4_DEBUG: boolean = import.meta.env.VITE_GA4_DEBUG === "1"; +/** Domain for the self-hosted first-party (Vince/Plausible-protocol) layer. */ +export const PLAUSIBLE_DOMAIN: string = + import.meta.env.VITE_PLAUSIBLE_DOMAIN ?? ""; + +/** GA4 cross-domain linking: mozimo.in ⇄ Shopify storefront/checkout. */ +export const CROSS_DOMAINS = ["shop.mozimo.in"]; + +type GtagFn = (...args: unknown[]) => void; + +declare global { + interface Window { + dataLayer?: unknown[]; + gtag?: GtagFn; + } +} + +/* ── consent (Google Consent Mode v2) ─────────────────────────── */ + +const CONSENT_KEY = "mozimo.consent"; + +export type ConsentChoice = "granted" | "denied"; + +/** Consent flags controlled by the banner. */ +const CONSENT_FIELDS = [ + "ad_storage", + "ad_user_data", + "ad_personalization", + "analytics_storage", +] as const; + +/** ISO-3166 regions that must default to denied: EU-27 + UK + EEA + CH. */ +const DENIED_REGIONS = [ + "AT", + "BE", + "BG", + "HR", + "CY", + "CZ", + "DK", + "EE", + "FI", + "FR", + "DE", + "GR", + "HU", + "IE", + "IT", + "LV", + "LT", + "LU", + "MT", + "NL", + "PL", + "PT", + "RO", + "SE", + "SI", + "SK", + "GB", + "IS", + "LI", + "NO", + "CH", +]; + +/** + * True for visitors whose IANA timezone is in the EU/EEA/UK/CH — used only to + * decide whether the consent banner should be offered. No network, no PII. + */ +export function eeaVisitor(): boolean { + if (typeof window === "undefined") return false; + let zone: string; + try { + zone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""; + } catch { + return false; + } + return ( + zone.startsWith("Europe/") || + zone === "Asia/Nicosia" || + zone === "Atlantic/Canary" || + zone === "Atlantic/Faeroe" || + zone === "Atlantic/Madeira" + ); +} + +function gtagFn(): GtagFn | null { + if (typeof window === "undefined") return null; + if (window.gtag) return window.gtag; + // gtag.js defines window.gtag; define it ourselves if the loader is + // blocked/late so queued calls still reach dataLayer for debugging. + window.dataLayer = window.dataLayer || []; + window.gtag = function gtag(...args: unknown[]) { + window.dataLayer?.push(args); + }; + return window.gtag; +} + +export function storedConsent(): ConsentChoice | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(CONSENT_KEY); + return raw === "granted" || raw === "denied" ? raw : null; + } catch { + return null; + } +} + +/** Banner buttons call this — persists the choice and updates Consent Mode. */ +export function saveConsent(choice: ConsentChoice): void { + try { + window.localStorage.setItem(CONSENT_KEY, choice); + } catch { + // private mode — consent still applies for this page view + } + updateConsent(choice); +} + +function updateConsent(choice: ConsentChoice): void { + const gtag = gtagFn(); + if (!gtag) return; + const fields = Object.fromEntries(CONSENT_FIELDS.map((f) => [f, choice])); + gtag("consent", "update", fields); +} + +/** + * Inline snippet run before the gtag loader: sets regional Consent Mode v2 + * defaults (denied in EU/EEA/UK/CH, granted elsewhere) and replays any stored + * banner choice. Rendered from `__root.tsx` head scripts. + */ +export function consentDefaultsSnippet(): string { + const denied = Object.fromEntries(CONSENT_FIELDS.map((f) => [f, "denied"])); + const granted = Object.fromEntries(CONSENT_FIELDS.map((f) => [f, "granted"])); + const regions = JSON.stringify(DENIED_REGIONS); + return [ + "window.dataLayer=window.dataLayer||[];", + "window.gtag=function(){dataLayer.push(arguments)};", + `gtag('consent','default',Object.assign(${JSON.stringify(denied)},{region:${regions}}));`, + `gtag('consent','default',${JSON.stringify(granted)});`, + "try{var c=localStorage.getItem('mozimo.consent');if(c==='denied'||c==='granted'){var u={};", + `${JSON.stringify(CONSENT_FIELDS)}.forEach(function(f){u[f]=c});gtag('consent','update',u)}}catch(e){}`, + ].join(""); +} + +/** GA4 loader + config snippets for `__root.tsx` head scripts. */ +export function ga4HeadScripts(): { src?: string; children?: string }[] { + if (!GA4_ID) return []; + const config = [ + "gtag('js', new Date());", + `gtag('config', ${JSON.stringify(GA4_ID)}, {`, + "send_page_view: false,", + "accept_incoming: true,", + `linker: { domains: ${JSON.stringify(CROSS_DOMAINS)} }`, + "});", + ].join(""); + return [ + { + src: `https://www.googletagmanager.com/gtag/js?id=${GA4_ID}`, + async: true, + }, + { children: config }, + ]; +} + +/* ── core event emitter ───────────────────────────────────────── */ + +export type EventParams = Record; + +export function track(event: string, params: EventParams = {}): void { + if (typeof window === "undefined") return; + const payload: EventParams = { ...params }; + if (GA4_DEBUG) payload.debug_mode = true; + try { + const gtag = gtagFn(); + gtag?.("event", event, payload); + } catch { + // analytics must never break the page + } + if (import.meta.env.DEV) { + console.info("[analytics]", event, params); + } +} + +/* ── e-commerce helpers (GA4 recommended event shapes) ────────── */ + +export type Item = { + item_id: string; + item_name: string; + price?: number; + item_category?: string; + item_variant?: string; + index?: number; + item_list_id?: string; + item_list_name?: string; +}; + +/** Map a catalog `Product` (or product-page data) onto a GA4 item. */ +export function toItem( + p: { + handle?: string; + name?: string; + title?: string; + price: number; + productType?: string; + weight?: string; + }, + context: { listId?: string; listName?: string; index?: number } = {}, +): Item { + return { + item_id: p.handle ?? p.name ?? p.title ?? "", + item_name: p.name ?? p.title ?? "", + price: Math.round(p.price), + item_brand: "Mozimo", + ...(p.productType ? { item_category: p.productType } : {}), + ...(p.weight ? { item_variant: p.weight } : {}), + ...(context.index !== undefined ? { index: context.index } : {}), + ...(context.listId ? { item_list_id: context.listId } : {}), + ...(context.listName ? { item_list_name: context.listName } : {}), + }; +} + +export function viewItemList( + listId: string, + listName: string, + items: Item[], +): void { + track("view_item_list", { + item_list_id: listId, + item_list_name: listName, + items, + }); +} + +export function selectItem(item: Item): void { + track("select_item", { items: [item] }); +} + +export function viewItem(item: Item): void { + track("view_item", { + currency: "INR", + value: item.price, + items: [item], + }); +} + +/** Leaving for Shopify to buy — the site-side half of the purchase funnel. */ +export function beginCheckout(item: Item): void { + track("begin_checkout", { + currency: "INR", + value: item.price, + items: [item], + }); +} + +export function search(term: string, resultCount: number): void { + track("search", { search_term: term, result_count: resultCount }); + track("view_search_results", { + search_term: term, + result_count: resultCount, + ...(resultCount + ? { item_list_id: "search", item_list_name: "Search results" } + : {}), + }); +} + +export type LeadVariant = "contact" | "bespoke"; + +/** Server-confirmed enquiry success. Never includes name/email/message. */ +export function generateLead( + variant: LeadVariant, + fields: { + topic?: string; + budget?: string; + hasPhone?: boolean; + hasCompany?: boolean; + } = {}, +): void { + track("generate_lead", { + currency: "INR", + lead_type: variant, + ...(fields.topic ? { lead_topic: fields.topic } : {}), + ...(fields.budget ? { lead_budget: fields.budget } : {}), + has_phone: fields.hasPhone ? "yes" : "no", + has_company: fields.hasCompany ? "yes" : "no", + value: 0, + }); +} + +export function pageView(path: string, title: string, notFound = false): void { + if (typeof window === "undefined") return; + track("page_view", { + page_path: path, + page_title: title, + page_location: window.location.origin + path, + ...(notFound ? { page_not_found: true } : {}), + }); +} + +/* ── impression hook (once per mount, 30% visible) ────────────── */ + +/** + * Fires `onImpression` the first time the referenced element is ≥ 30% visible. + * Used for `view_item_list` and `video_start` so below-the-fold lists only + * count when actually seen. + */ +export function useImpression( + onImpression: () => void, + threshold = 0.3, +): RefObject { + const ref = useRef(null); + const fired = useRef(false); + const cb = useRef(onImpression); + cb.current = onImpression; + + useEffect(() => { + const el = ref.current; + if (!el || fired.current) return; + if (typeof IntersectionObserver === "undefined") { + fired.current = true; + cb.current(); + return; + } + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting) && !fired.current) { + fired.current = true; + io.disconnect(); + cb.current(); + } + }, + { threshold }, + ); + io.observe(el); + return () => io.disconnect(); + }, [threshold]); + + return ref; +} diff --git a/src/lib/content.ts b/src/lib/content.ts index 1e186a0..7614bd6 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -127,7 +127,11 @@ export type GiftingSections = { }; const fallbackSections: GiftingSections = { - tabs: { eyebrow: "Who is it for?", title: "Gifts,", script: "price by price" }, + tabs: { + eyebrow: "Who is it for?", + title: "Gifts,", + script: "price by price", + }, hampers: { title: "Hampers &", script: "boxes" }, seasonal: { eyebrow: "The Seasonal Edit", @@ -272,9 +276,11 @@ export const getGiftingPageData = createServerFn({ method: "GET" }).handler( script: cmsHero.hampersScript ?? fallbackSections.hampers.script, }, seasonal: { - eyebrow: cmsHero.seasonalEyebrow ?? fallbackSections.seasonal.eyebrow, + eyebrow: + cmsHero.seasonalEyebrow ?? fallbackSections.seasonal.eyebrow, title: cmsHero.seasonalTitle ?? fallbackSections.seasonal.title, - script: cmsHero.seasonalScript ?? fallbackSections.seasonal.script, + script: + cmsHero.seasonalScript ?? fallbackSections.seasonal.script, }, } : fallbackSections, @@ -375,12 +381,18 @@ export const getHomeCopy = createServerFn({ method: "GET" }).handler( const pick = (key: string, fb: string) => settings[key]?.trim() || fb; return { seasonal: { - eyebrow: pick("home_seasonal_eyebrow", fallbackHomeCopy.seasonal.eyebrow), + eyebrow: pick( + "home_seasonal_eyebrow", + fallbackHomeCopy.seasonal.eyebrow, + ), title: pick("home_seasonal_title", fallbackHomeCopy.seasonal.title), script: pick("home_seasonal_script", fallbackHomeCopy.seasonal.script), }, universe: { - eyebrow: pick("home_universe_eyebrow", fallbackHomeCopy.universe.eyebrow), + eyebrow: pick( + "home_universe_eyebrow", + fallbackHomeCopy.universe.eyebrow, + ), title: pick("home_universe_title", fallbackHomeCopy.universe.title), script: pick("home_universe_script", fallbackHomeCopy.universe.script), cta: pick("home_universe_cta", fallbackHomeCopy.universe.cta), diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 60283f1..04508ef 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ContactRouteImport } from './app/contact' import { Route as ExperiencesRouteImport } from './app/experiences' import { Route as GiftingRouteImport } from './app/gifting' import { Route as OurWorldRouteImport } from './app/our-world' +import { Route as PrivacyRouteImport } from './app/privacy' import { Route as CollectionsHandleRouteImport } from './app/collections.$handle' import { Route as JournalIndexRouteImport } from './app/journal.index' import { Route as JournalSlugRouteImport } from './app/journal.$slug' @@ -62,6 +63,11 @@ const OurWorldRoute = OurWorldRouteImport.update({ path: '/our-world', getParentRoute: () => rootRouteImport, } as any) +const PrivacyRoute = PrivacyRouteImport.update({ + id: '/privacy', + path: '/privacy', + getParentRoute: () => rootRouteImport, +} as any) const CollectionsHandleRoute = CollectionsHandleRouteImport.update({ id: '/collections/$handle', path: '/collections/$handle', @@ -92,6 +98,7 @@ export interface FileRoutesByFullPath { '/experiences': typeof ExperiencesRoute '/gifting': typeof GiftingRoute '/our-world': typeof OurWorldRoute + '/privacy': typeof PrivacyRoute '/collections/$handle': typeof CollectionsHandleRoute '/journal/$slug': typeof JournalSlugRoute '/products/$handle': typeof ProductsHandleRoute @@ -106,6 +113,7 @@ export interface FileRoutesByTo { '/experiences': typeof ExperiencesRoute '/gifting': typeof GiftingRoute '/our-world': typeof OurWorldRoute + '/privacy': typeof PrivacyRoute '/collections/$handle': typeof CollectionsHandleRoute '/journal/$slug': typeof JournalSlugRoute '/products/$handle': typeof ProductsHandleRoute @@ -121,6 +129,7 @@ export interface FileRoutesById { '/experiences': typeof ExperiencesRoute '/gifting': typeof GiftingRoute '/our-world': typeof OurWorldRoute + '/privacy': typeof PrivacyRoute '/collections/$handle': typeof CollectionsHandleRoute '/journal/$slug': typeof JournalSlugRoute '/products/$handle': typeof ProductsHandleRoute @@ -137,6 +146,7 @@ export interface FileRouteTypes { | '/experiences' | '/gifting' | '/our-world' + | '/privacy' | '/collections/$handle' | '/journal/$slug' | '/products/$handle' @@ -151,6 +161,7 @@ export interface FileRouteTypes { | '/experiences' | '/gifting' | '/our-world' + | '/privacy' | '/collections/$handle' | '/journal/$slug' | '/products/$handle' @@ -165,6 +176,7 @@ export interface FileRouteTypes { | '/experiences' | '/gifting' | '/our-world' + | '/privacy' | '/collections/$handle' | '/journal/$slug' | '/products/$handle' @@ -180,6 +192,7 @@ export interface RootRouteChildren { ExperiencesRoute: typeof ExperiencesRoute GiftingRoute: typeof GiftingRoute OurWorldRoute: typeof OurWorldRoute + PrivacyRoute: typeof PrivacyRoute CollectionsHandleRoute: typeof CollectionsHandleRoute JournalSlugRoute: typeof JournalSlugRoute ProductsHandleRoute: typeof ProductsHandleRoute @@ -244,6 +257,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OurWorldRouteImport parentRoute: typeof rootRouteImport } + '/privacy': { + id: '/privacy' + path: '/privacy' + fullPath: '/privacy' + preLoaderRoute: typeof PrivacyRouteImport + parentRoute: typeof rootRouteImport + } '/collections/$handle': { id: '/collections/$handle' path: '/collections/$handle' @@ -284,6 +304,7 @@ const rootRouteChildren: RootRouteChildren = { ExperiencesRoute: ExperiencesRoute, GiftingRoute: GiftingRoute, OurWorldRoute: OurWorldRoute, + PrivacyRoute: PrivacyRoute, CollectionsHandleRoute: CollectionsHandleRoute, JournalSlugRoute: JournalSlugRoute, ProductsHandleRoute: ProductsHandleRoute, diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..860f253 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,35 @@ +import { + createStartHandler, + defaultStreamHandler, +} from "@tanstack/react-start/server"; +import { init as sentryInit } from "@sentry/tanstackstart-react"; + +/** + * Custom server entry (auto-detected by the TanStack Start plugin) — the + * framework default SSR handler plus server-side Sentry. + * + * The SDK's main entry is environment-switched: under the SSR/node build it + * resolves to the Node SDK, in the browser bundle to the React SDK — so both + * entries import the same specifier. + * + * Sentry only activates when SENTRY_DSN is set in the runtime environment. + * This runs inside the SSR bundle, so loader / server-function errors are + * captured; the outer `server.mjs` wrapper needs no Sentry of its own. + */ +if (process.env.NODE_ENV === "production" && process.env.SENTRY_DSN) { + sentryInit({ + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENVIRONMENT || "production", + ...(process.env.SENTRY_RELEASE + ? { release: process.env.SENTRY_RELEASE } + : {}), + tracesSampleRate: 0.1, + sendDefaultPii: false, + }); +} + +const fetch = createStartHandler(defaultStreamHandler); + +export default { + fetch, +}; diff --git a/vite.config.ts b/vite.config.ts index 879eac1..180b749 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,14 @@ import { defineConfig } from "vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { sentryTanstackStart } from "@sentry/tanstackstart-react/vite"; + +/** + * Sentry build-time integration (sourcemap generation + upload) only runs + * when SENTRY_AUTH_TOKEN is present — local and CI builds without + * credentials are completely unaffected. + */ +const sentryUpload = Boolean(process.env.SENTRY_AUTH_TOKEN); export default defineConfig({ server: { @@ -11,6 +19,7 @@ export default defineConfig({ resolve: { tsconfigPaths: true, }, + ...(sentryUpload ? { build: { sourcemap: true } } : {}), plugins: [ tailwindcss(), tanstackStart({ @@ -21,5 +30,12 @@ export default defineConfig({ }, }), viteReact(), + ...(sentryUpload + ? [ + sentryTanstackStart({ + sourcemaps: { filesToDeleteAfterUpload: ["dist/**/*.map"] }, + }), + ] + : []), ], });