diff --git a/.env.example b/.env.example index ecfe735e..dcc294ba 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,8 @@ CRON_SECRET="" # Get your keys from: https://posthog.com NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_HOST="https://eu.i.posthog.com" +# Optional override; Docker builds generate one automatically when omitted +NEXT_PUBLIC_APP_VERSION="" # ----------------------------------------- # Umami Analytics (Required) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index fb8addd9..b93964ee 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -51,6 +51,8 @@ jobs: platforms: linux/amd64 load: true tags: louez-web:smoke + build-args: | + NEXT_PUBLIC_APP_VERSION=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max @@ -175,5 +177,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + NEXT_PUBLIC_APP_VERSION=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/apps/web/.env.example b/apps/web/.env.example index 975161fa..50078eba 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -146,6 +146,8 @@ CRON_SECRET="" # Get your keys from: https://posthog.com NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_HOST="https://eu.i.posthog.com" +# Optional override; Docker builds generate one automatically when omitted +NEXT_PUBLIC_APP_VERSION="" # ----------------------------------------- # Umami Analytics (Required) diff --git a/apps/web/app/(storefront)/[slug]/rental/rental-content.tsx b/apps/web/app/(storefront)/[slug]/rental/rental-content.tsx index 2e136ac9..17b9b044 100644 --- a/apps/web/app/(storefront)/[slug]/rental/rental-content.tsx +++ b/apps/web/app/(storefront)/[slug]/rental/rental-content.tsx @@ -46,6 +46,7 @@ import { } from "@/lib/utils/duration"; import { useStorefrontUrl } from "@/hooks/use-storefront-url"; +import { useBrowserTimezoneCity } from "@/hooks/use-browser-timezone-city"; import { useCart } from "@/contexts/cart-context"; @@ -208,15 +209,7 @@ export function RentalContent({ [endDate, storeTimezone], ); - // Detect if user's browser timezone differs from the store's timezone - const timezoneCity = useMemo(() => { - if (!storeTimezone) return null; - const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (browserTimezone === storeTimezone) return null; - // Extract city name from IANA timezone (e.g., "Europe/Paris" → "Paris") - const city = storeTimezone.split("/").pop()?.replace(/_/g, " "); - return city || storeTimezone; - }, [storeTimezone]); + const timezoneCity = useBrowserTimezoneCity(storeTimezone); // Set global dates in cart context useEffect(() => { diff --git a/apps/web/components/storefront/date-selection-hero.tsx b/apps/web/components/storefront/date-selection-hero.tsx index bdff6a70..4f57c73f 100644 --- a/apps/web/components/storefront/date-selection-hero.tsx +++ b/apps/web/components/storefront/date-selection-hero.tsx @@ -14,6 +14,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@louez/ui"; import { ScrollArea } from "@louez/ui"; import { cn } from "@louez/utils"; import { useCart } from "@/contexts/cart-context"; +import { useBrowserTimezoneCity } from "@/hooks/use-browser-timezone-city"; import { useStorefrontUrl } from "@/hooks/use-storefront-url"; import { getMinStartDate, isTimeSlotAvailable, type PricingMode } from "@/lib/utils/duration"; import { isCalendarDateBeforeSelectedDate } from "@/components/storefront/date-picker/core/use-rental-date-core"; @@ -332,13 +333,7 @@ export function DateSelectionHero({ return `${days} ${days > 1 ? t("durationDays") : t("durationDay")} ${t("and")} ${hours}h`; }; - const timezoneCity = useMemo(() => { - if (!timezone) return null; - const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (browserTimezone === timezone) return null; - const city = timezone.split("/").pop()?.replace(/_/g, " "); - return city || timezone; - }, [timezone]); + const timezoneCity = useBrowserTimezoneCity(timezone); // Check if we can submit - always require times const canSubmit = startDate && endDate && startTime && endTime; diff --git a/apps/web/components/storefront/embed-date-picker.tsx b/apps/web/components/storefront/embed-date-picker.tsx index 66e7ed6f..8fe843f1 100644 --- a/apps/web/components/storefront/embed-date-picker.tsx +++ b/apps/web/components/storefront/embed-date-picker.tsx @@ -31,6 +31,7 @@ import { isCalendarDateBeforeSelectedDate, useRentalDateCore, } from '@/components/storefront/date-picker/core/use-rental-date-core' +import { useBrowserTimezoneCity } from '@/hooks/use-browser-timezone-city' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -344,13 +345,7 @@ export function EmbedDatePicker({ tEmbed, ]) - const timezoneCity = useMemo(() => { - if (!timezone) return null - const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone - if (browserTimezone === timezone) return null - const city = timezone.split('/').pop()?.replace(/_/g, ' ') - return city || timezone - }, [timezone]) + const timezoneCity = useBrowserTimezoneCity(timezone) const handleSubmit = () => { if (validationError) { diff --git a/apps/web/components/storefront/hero-date-picker.tsx b/apps/web/components/storefront/hero-date-picker.tsx index 55287b2f..bba22fd6 100644 --- a/apps/web/components/storefront/hero-date-picker.tsx +++ b/apps/web/components/storefront/hero-date-picker.tsx @@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@louez/ui"; import { ScrollArea } from "@louez/ui"; import { cn } from "@louez/utils"; import { useCart } from "@/contexts/cart-context"; +import { useBrowserTimezoneCity } from "@/hooks/use-browser-timezone-city"; import { useStorefrontUrl } from "@/hooks/use-storefront-url"; import { type PricingMode } from "@/lib/utils/duration"; import { @@ -267,13 +268,7 @@ export function HeroDatePicker({ return true; }, [startDate, endDate, startTime, endTime, isSameDay, minRentalMinutes, timezone]); - const timezoneCity = useMemo(() => { - if (!timezone) return null; - const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - if (browserTimezone === timezone) return null; - const city = timezone.split("/").pop()?.replace(/_/g, " "); - return city || timezone; - }, [timezone]); + const timezoneCity = useBrowserTimezoneCity(timezone); const durationWarning = useMemo(() => { if (!startDate || !endDate || !startTime || !endTime) return null; diff --git a/apps/web/env.ts b/apps/web/env.ts index 12dcd747..8dcb7c84 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -234,6 +234,7 @@ export const env = createEnv({ .string() .min(1, 'NEXT_PUBLIC_POSTHOG_KEY is required'), NEXT_PUBLIC_POSTHOG_HOST: z.url().default('https://eu.i.posthog.com'), + NEXT_PUBLIC_APP_VERSION: z.string().min(1).max(100).optional(), // ===== Umami Analytics (Required) ===== NEXT_PUBLIC_UMAMI_SCRIPT_URL: z @@ -324,6 +325,7 @@ export const env = createEnv({ NEXT_PUBLIC_VAPID_PUBLIC_KEY: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, + NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION, NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL, NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, NEXT_PUBLIC_GLEAP_API_KEY: process.env.NEXT_PUBLIC_GLEAP_API_KEY, diff --git a/apps/web/hooks/use-browser-timezone-city.ts b/apps/web/hooks/use-browser-timezone-city.ts new file mode 100644 index 00000000..7d6274e8 --- /dev/null +++ b/apps/web/hooks/use-browser-timezone-city.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function useBrowserTimezoneCity(timezone?: string): string | null { + const [timezoneCity, setTimezoneCity] = useState(null); + + useEffect(() => { + if (!timezone) { + setTimezoneCity(null); + return; + } + + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + if (browserTimezone === timezone) { + setTimezoneCity(null); + return; + } + + setTimezoneCity(timezone.split("/").pop()?.replace(/_/g, " ") || timezone); + }, [timezone]); + + return timezoneCity; +} diff --git a/apps/web/instrumentation-client.ts b/apps/web/instrumentation-client.ts index 7dc300ba..a6aa351d 100644 --- a/apps/web/instrumentation-client.ts +++ b/apps/web/instrumentation-client.ts @@ -17,9 +17,11 @@ import posthog from 'posthog-js' +import { env } from '@/env' + function resolveSurface(): { isDashboard: boolean; storeSlug: string | null } { const hostname = window.location.hostname.toLowerCase() - const baseDomain = (process.env.NEXT_PUBLIC_APP_DOMAIN || 'localhost:3000') + const baseDomain = (env.NEXT_PUBLIC_APP_DOMAIN || 'localhost:3000') .split(':')[0] .toLowerCase() @@ -45,10 +47,10 @@ function resolveSurface(): { isDashboard: boolean; storeSlug: string | null } { return { isDashboard: false, storeSlug: null } } -if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) { +if (typeof window !== 'undefined' && env.NEXT_PUBLIC_POSTHOG_KEY) { const { isDashboard, storeSlug } = resolveSurface() - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { // Route analytics through our reverse proxy to avoid CORS and ad blockers // The proxy is configured in next.config.ts rewrites api_host: '/ingest', @@ -95,6 +97,9 @@ if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) { posthog.register({ surface: isDashboard ? 'dashboard' : storeSlug ? 'storefront' : 'marketing', ...(storeSlug ? { store_slug: storeSlug } : {}), + ...(env.NEXT_PUBLIC_APP_VERSION + ? { $app_version: env.NEXT_PUBLIC_APP_VERSION } + : {}), }) } diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web index 65212ed6..b33bbd2d 100644 --- a/docker/Dockerfile.web +++ b/docker/Dockerfile.web @@ -68,6 +68,7 @@ ARG DATABASE_URL ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_APP_DOMAIN ARG NEXT_PUBLIC_DASHBOARD_SUBDOMAIN +ARG NEXT_PUBLIC_APP_VERSION ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ARG NEXT_PUBLIC_POSTHOG_KEY ARG NEXT_PUBLIC_POSTHOG_HOST @@ -114,8 +115,11 @@ ENV NEXT_PUBLIC_FROMHELLO_API_URL=${NEXT_PUBLIC_FROMHELLO_API_URL} ENV NEXT_PUBLIC_FROMHELLO_KEY=${NEXT_PUBLIC_FROMHELLO_KEY} ENV NEXT_PUBLIC_FROMHELLO_COOKIE_DOMAIN=${NEXT_PUBLIC_FROMHELLO_COOKIE_DOMAIN} -# Build the application using Turborepo -RUN corepack enable pnpm && pnpm turbo run build --filter=@louez/web --env-mode=loose +# Build the application using Turborepo. CI supplies the commit SHA; direct +# EasyPanel builds fall back to a unique build identifier without manual input. +RUN corepack enable pnpm && \ + APP_VERSION="${NEXT_PUBLIC_APP_VERSION:-build-$(date -u +%Y%m%d%H%M%S)}" && \ + NEXT_PUBLIC_APP_VERSION="${APP_VERSION}" pnpm turbo run build --filter=@louez/web --env-mode=loose # ----------------------------------------------------------------------------- # Stage 3: Migrator diff --git a/docs/analytics/setup-review-2026-07.md b/docs/analytics/setup-review-2026-07.md index 516f84a1..61c86991 100644 --- a/docs/analytics/setup-review-2026-07.md +++ b/docs/analytics/setup-review-2026-07.md @@ -27,6 +27,7 @@ Instrumentation code (`apps/web`) : - **Fix `/ingest` sur les storefronts** : `proxy.ts` reecrivait `/ingest/*` vers `/{slug}/ingest/*` sur les sous-domaines boutique, donc chaque appel posthog-js storefront finissait en 404. `/ingest` passe maintenant en pass-through comme `/api` — c'est la cause racine du "zero pageview storefront". - **Config storefront exemptee de consentement** (`instrumentation-client.ts`) : init host-aware. Dashboard (`app.{domaine}`) = config complete (cookies scoped au host via `cross_subdomain_cookie: false`, replay, autocapture). Storefronts (`{slug}.{domaine}`) et pages marketing = mesure d'audience exemptee CNIL : `persistence: memory` (aucun cookie/localStorage), events anonymes (`person_profiles: identified_only`), pas d'autocapture, pas de replay, pas de heatmaps, pas de dead clicks, pas de surveys. Super props `surface` (`storefront` | `marketing`) et `store_slug` enregistrees a chaque chargement. **Elargir une de ces options cote storefront = banniere de consentement obligatoire.** - **Surface dashboard explicite** (`instrumentation-client.ts`) : a partir du prochain deploy apres le 2026-07-03, les pageviews dashboard portent aussi `surface: dashboard`. Les pageviews dashboard historiques restent souvent `surface = unknown`. +- **Version applicative sur les events client** (`instrumentation-client.ts`) : PostHog recoit `$app_version` sur les events client pour relier les exceptions a une version. Le workflow GitHub fournit le SHA exact du commit ; un build Docker direct (dont EasyPanel) genere automatiquement un identifiant `build-YYYYMMDDHHMMSS` si aucune version n'est fournie. `NEXT_PUBLIC_APP_VERSION` reste disponible comme override optionnel. ## Roadmap des manques (prioritee)