diff --git a/app/(app)/(builder)/builder/page.tsx b/app/(app)/(builder)/builder/page.tsx index 2aad7ad..5125302 100644 --- a/app/(app)/(builder)/builder/page.tsx +++ b/app/(app)/(builder)/builder/page.tsx @@ -28,6 +28,7 @@ import { X, Link2, Square, CheckSquare } from "lucide-react"; import { useApi } from "@/lib/api"; import { useAuth } from "@/context/AuthContext"; +import { trackBuildStarted, trackPartSelected, trackAffiliateLinkClicked, trackBuildShared, trackBuildSaved } from "@/lib/analytics"; import { CATEGORIES } from "@/data/gunbuilderParts"; import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots"; @@ -369,20 +370,33 @@ function GunbuilderPageContent() { useEffect(() => { if (typeof window === "undefined") return; - // Hydrate persisted build state only. URL actions (platform/select/remove) - // are handled by the dedicated useSearchParams effect later in this file. + // Intentional: Object.keys(...).length > 0 tightens the original condition. + // Previously, an empty {} from localStorage would call setBuild(normalizeBuildState({})), + // which is a no-op. The new check skips that call, which is safe and correct. + let hasBuild = false; const stored = localStorage.getItem(STORAGE_KEY); if (stored) { try { const parsed = JSON.parse(stored); - if (parsed && typeof parsed === "object") { + if (parsed && typeof parsed === "object" && Object.keys(parsed).length > 0) { setBuild(normalizeBuildState(parsed)); + hasBuild = true; } } catch { // ignore } } + // Fire build_started only for genuinely new builds: + // - No existing parts in localStorage + // - No ?select= param (share-link arrivals have empty localStorage but are not starting fresh) + // Use window.location.search directly: this effect runs at mount before any router + // processing, making window.location.search the authoritative source of the landing URL. + const hasSelectParam = new URLSearchParams(window.location.search).has("select"); + if (!hasBuild && !hasSelectParam) { + trackBuildStarted(); + } + // Signal that hydration is complete - this allows URL param processing to run setIsHydrated(true); }, []); @@ -842,6 +856,7 @@ function GunbuilderPageContent() { type: "success", message: "Saved to your Vault ✅ ", }); + trackBuildSaved(); // Close modal + reset inputs for next variant setSaveAsOpen(false); @@ -889,8 +904,10 @@ function GunbuilderPageContent() { } return next; }); + const partName = parts.find((p) => String(p.id) === String(partId))?.name ?? String(partId); + trackPartSelected(categoryId, partName, String(partId)); }, - [build] + [build, parts] ); // Handle URL query parameters (?select, ?remove) and keep platform synced + canonical useEffect(() => { @@ -1055,6 +1072,7 @@ function GunbuilderPageContent() { try { await navigator.clipboard.writeText(shareUrl); + trackBuildShared(); setShareStatus("Shareable link copied to clipboard."); } catch { setShareStatus("Could not copy link to clipboard."); @@ -1074,6 +1092,7 @@ function GunbuilderPageContent() { text: "Check out my Battl Build.", url: shareUrl, }); + trackBuildShared(); // fires only on successful share, not on cancel setShareStatus("Share dialog opened."); } catch { setShareStatus( @@ -1704,6 +1723,14 @@ function GunbuilderPageContent() { target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-amber-600 bg-amber-500 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-400 transition-colors" + onClick={() => { + try { + const retailer = new URL(selectedPart.affiliateUrl).hostname.replace(/^www\./, ""); + trackAffiliateLinkClicked(selectedPart.name, retailer); + } catch { + // ignore malformed URLs — if affiliateUrl is malformed, the link href is also broken + } + }} > Buy