diff --git a/apps/basic-integration/react-router/shopper/.env.example b/apps/basic-integration/react-router/shopper/.env.example new file mode 100644 index 000000000..486aab85d --- /dev/null +++ b/apps/basic-integration/react-router/shopper/.env.example @@ -0,0 +1,2 @@ +VITE_PUBLIC_POSTHOG_PROJECT_TOKEN= +VITE_PUBLIC_POSTHOG_HOST= diff --git a/apps/basic-integration/react-router/shopper/app/components/PostHogClientProvider.tsx b/apps/basic-integration/react-router/shopper/app/components/PostHogClientProvider.tsx new file mode 100644 index 000000000..f92cfd8ff --- /dev/null +++ b/apps/basic-integration/react-router/shopper/app/components/PostHogClientProvider.tsx @@ -0,0 +1,70 @@ +import { PostHogErrorBoundary, PostHogProvider } from "@posthog/react"; +import { useEffect, useState, type ReactNode } from "react"; +import type { PostHog } from "posthog-js"; + +let posthogClient: PostHog | null = null; +let initializationStarted = false; + +export default function PostHogClientProvider({ + children, +}: { + children: ReactNode; +}) { + const [client, setClient] = useState(posthogClient); + + useEffect(() => { + const token = import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN; + const host = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; + + if (!token || !host) { + if (import.meta.env.DEV) { + if (!token) { + console.error( + new Error( + "VITE_PUBLIC_POSTHOG_PROJECT_TOKEN variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once VITE_PUBLIC_POSTHOG_PROJECT_TOKEN is configured" + ) + ); + } + + if (!host) { + console.error( + new Error( + "VITE_PUBLIC_POSTHOG_HOST variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once VITE_PUBLIC_POSTHOG_HOST is configured" + ) + ); + } + } + + return; + } + + if (posthogClient) { + setClient(posthogClient); + return; + } + + if (initializationStarted) return; + initializationStarted = true; + + void import("posthog-js").then(({ default: posthog }) => { + posthog.init(token, { + api_host: host, + defaults: "2026-08-30", + logs: { + serviceName: "shopper-web", + environment: import.meta.env.MODE, + }, + }); + posthogClient = posthog; + setClient(posthog); + }); + }, []); + + if (!client) return <>{children}; + + return ( + + {children} + + ); +} diff --git a/apps/basic-integration/react-router/shopper/app/root.tsx b/apps/basic-integration/react-router/shopper/app/root.tsx index 95d59accd..dccfb8686 100644 --- a/apps/basic-integration/react-router/shopper/app/root.tsx +++ b/apps/basic-integration/react-router/shopper/app/root.tsx @@ -9,8 +9,9 @@ import { import type { Route } from "./+types/root"; import "./app.css"; -import { CartProvider } from "./context/CartContext"; import Navbar from "./components/Navbar"; +import PostHogClientProvider from "./components/PostHogClientProvider"; +import { CartProvider } from "./context/CartContext"; export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, @@ -45,12 +46,14 @@ export function Layout({ children }: { children: React.ReactNode }) { export default function App() { return ( - -
- - -
-
+ + +
+ + +
+
+
); } diff --git a/apps/basic-integration/react-router/shopper/app/routes/cart.tsx b/apps/basic-integration/react-router/shopper/app/routes/cart.tsx index 987dc7bfc..24f3b1286 100644 --- a/apps/basic-integration/react-router/shopper/app/routes/cart.tsx +++ b/apps/basic-integration/react-router/shopper/app/routes/cart.tsx @@ -1,15 +1,42 @@ import { Link } from "react-router"; import { useCart, type CartItem } from "../context/CartContext"; +import { usePostHog } from "@posthog/react"; export default function Cart() { const { cart, removeFromCart, updateQuantity, getCartTotal } = useCart(); + const posthog = usePostHog(); const handleRemoveFromCart = (item: CartItem) => { removeFromCart(item.id); + posthog?.capture("cart_item_removed", { + product_id: item.id, + category: item.category, + quantity: item.quantity, + price: item.price, + }); }; const handleUpdateQuantity = (item: CartItem, newQuantity: number) => { + if (newQuantity <= 0) { + handleRemoveFromCart(item); + return; + } + updateQuantity(item.id, newQuantity); + posthog?.capture("cart_quantity_updated", { + product_id: item.id, + category: item.category, + previous_quantity: item.quantity, + quantity: newQuantity, + }); + }; + + const handleCheckoutStarted = () => { + posthog?.capture("checkout_started", { + cart_item_count: cart.reduce((count, item) => count + item.quantity, 0), + cart_value: getCartTotal(), + currency: "USD", + }); }; if (cart.length === 0) { @@ -154,6 +181,7 @@ export default function Cart() { Proceed to Checkout diff --git a/apps/basic-integration/react-router/shopper/app/routes/checkout.tsx b/apps/basic-integration/react-router/shopper/app/routes/checkout.tsx index c27d52cfc..fa1702734 100644 --- a/apps/basic-integration/react-router/shopper/app/routes/checkout.tsx +++ b/apps/basic-integration/react-router/shopper/app/routes/checkout.tsx @@ -1,9 +1,12 @@ import { useState } from "react"; import { useNavigate } from "react-router"; import { useCart } from "../context/CartContext"; +import { usePostHog } from "@posthog/react"; export default function Checkout() { const { cart, getCartTotal, clearCart } = useCart(); + const posthog = usePostHog(); + const posthogLogger = posthog?.logger; const navigate = useNavigate(); const [isProcessing, setIsProcessing] = useState(false); const [formData, setFormData] = useState({ @@ -40,9 +43,30 @@ export default function Checkout() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + const subtotal = getCartTotal(); + const itemCount = cart.reduce((count, item) => count + item.quantity, 0); + const orderTotal = subtotal * 1.1; + posthogLogger?.info("checkout order submitted", { + subtotal, + order_total: orderTotal, + item_count: itemCount, + currency: "USD", + }); setIsProcessing(true); setTimeout(() => { + posthog?.capture("order_completed", { + subtotal, + order_total: orderTotal, + item_count: itemCount, + currency: "USD", + }); + posthogLogger?.info("checkout order completed", { + subtotal, + order_total: orderTotal, + item_count: itemCount, + currency: "USD", + }); clearCart(); setIsProcessing(false); alert("Order placed successfully! Thank you for your purchase."); diff --git a/apps/basic-integration/react-router/shopper/app/routes/products.$productId.tsx b/apps/basic-integration/react-router/shopper/app/routes/products.$productId.tsx index bce4efc7a..10c8d11fe 100644 --- a/apps/basic-integration/react-router/shopper/app/routes/products.$productId.tsx +++ b/apps/basic-integration/react-router/shopper/app/routes/products.$productId.tsx @@ -3,6 +3,7 @@ import type { Route } from "./+types/products.$productId"; import { getProductById } from "../data/products"; import { useCart } from "../context/CartContext"; import { useState } from "react"; +import { usePostHog } from "@posthog/react"; export async function clientLoader({ params }: Route.LoaderArgs) { const productId = parseInt(params.productId); @@ -18,12 +19,20 @@ export async function clientLoader({ params }: Route.LoaderArgs) { export default function ProductDetail({ loaderData }: Route.ComponentProps) { const { product } = loaderData; const { addToCart } = useCart(); + const posthog = usePostHog(); const [quantity, setQuantity] = useState(1); const handleAddToCart = () => { for (let i = 0; i < quantity; i++) { addToCart(product); } + posthog?.capture("product_added_to_cart", { + product_id: product.id, + category: product.category, + price: product.price, + quantity, + source: "product_detail", + }); }; return ( diff --git a/apps/basic-integration/react-router/shopper/app/routes/products.tsx b/apps/basic-integration/react-router/shopper/app/routes/products.tsx index 34409c852..d3a373c2e 100644 --- a/apps/basic-integration/react-router/shopper/app/routes/products.tsx +++ b/apps/basic-integration/react-router/shopper/app/routes/products.tsx @@ -3,6 +3,7 @@ import type { Route } from "./+types/products"; import { getProducts, getCategories, type Product } from "../data/products"; import { useState } from "react"; import { useCart } from "../context/CartContext"; +import { usePostHog } from "@posthog/react"; export async function clientLoader() { return { @@ -14,11 +15,19 @@ export async function clientLoader() { export default function Products({ loaderData }: Route.ComponentProps) { const { products, categories } = loaderData; const { addToCart } = useCart(); + const posthog = usePostHog(); const [selectedCategory, setSelectedCategory] = useState(""); const [searchTerm, setSearchTerm] = useState(""); const handleAddToCart = (product: Product) => { addToCart(product); + posthog?.capture("product_added_to_cart", { + product_id: product.id, + category: product.category, + price: product.price, + quantity: 1, + source: "product_catalog", + }); }; const handleSearch = (term: string) => { diff --git a/apps/basic-integration/react-router/shopper/package-lock.json b/apps/basic-integration/react-router/shopper/package-lock.json index 2ceae079a..eb737044a 100644 --- a/apps/basic-integration/react-router/shopper/package-lock.json +++ b/apps/basic-integration/react-router/shopper/package-lock.json @@ -6,9 +6,11 @@ "": { "name": "shopper", "dependencies": { + "@posthog/react": "^1.11.3", "@react-router/node": "7.10.1", "@react-router/serve": "7.10.1", "isbot": "^5.1.31", + "posthog-js": "^1.441.2", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router": "7.10.1" @@ -986,6 +988,48 @@ "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", "license": "MIT" }, + "node_modules/@posthog/browser-common": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.10.1.tgz", + "integrity": "sha512-gKtBGEsjxNExO/19trnkiXTHFX0075vXn69zZ+lPlXy6P/DkED4644WqjDN/GIWr1Y5en2dcVJPOqUmVO+drkg==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.61.1", + "@posthog/types": "^1.418.0", + "preact": "^10.29.3" + } + }, + "node_modules/@posthog/core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.61.1.tgz", + "integrity": "sha512-qyMTxPfjeJkHIhpcukOgBN3BWXbCyFRPz8/Voy5qZtVh9NZqj3EhC6LLeYZuJhL3WpqhQTgn2FMIwAo/ag6fmw==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.417.0" + } + }, + "node_modules/@posthog/react": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.11.3.tgz", + "integrity": "sha512-YqvZb2y9jh/yW0ojUtAns0l4yHWqpu+5UeIBxZlXpyGuWlh8M4siXRMtqhEh1BKfqp6Sq2usdsQCuoUzx1QFWQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=16.8.0", + "posthog-js": ">=1.257.2", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@posthog/types": { + "version": "1.418.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.418.0.tgz", + "integrity": "sha512-WqDXUWH1R8OilBp2PfDpQIRZSCs3QyOnw7H3YK7lvZLCMIsOqSZAKJrrqYWt4f7FvnVCACRuNGCC0HKh3J/HWw==", + "license": "MIT" + }, "node_modules/@react-router/dev": { "version": "7.10.1", "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.10.1.tgz", @@ -1769,7 +1813,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1785,6 +1829,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2120,11 +2171,25 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.51.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.51.0.tgz", + "integrity": "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2189,6 +2254,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2442,6 +2516,12 @@ } } }, + "node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -3311,6 +3391,54 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-js": { + "version": "1.441.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.441.2.tgz", + "integrity": "sha512-PsELSUSVCx80BfbtMtir8fW6scxVj+2CvsG+Vj2j9eTQYQL1AzsaZr73pqJk1XWEcSt+B1hEfirz3iFVQGVQkQ==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.10.1", + "@posthog/core": "^1.61.1", + "@posthog/types": "^1.418.0", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^6.2.1", + "web-vitals-soft-navs": "npm:web-vitals@6.2.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prettier": { "version": "3.7.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", @@ -3355,6 +3483,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4025,6 +4159,19 @@ } } }, + "node_modules/web-vitals": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz", + "integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/apps/basic-integration/react-router/shopper/package.json b/apps/basic-integration/react-router/shopper/package.json index 60d25de15..c0d61e2f7 100644 --- a/apps/basic-integration/react-router/shopper/package.json +++ b/apps/basic-integration/react-router/shopper/package.json @@ -9,9 +9,11 @@ "typecheck": "react-router typegen && tsc" }, "dependencies": { + "@posthog/react": "^1.11.3", "@react-router/node": "7.10.1", "@react-router/serve": "7.10.1", "isbot": "^5.1.31", + "posthog-js": "^1.441.2", "react": "^19.2.3", "react-dom": "^19.2.3", "react-router": "7.10.1"