feat: wire Umami events in builder page (build_started, part_selected, affiliate_link_clicked, build_shared, build_saved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import { X, Link2, Square, CheckSquare } from "lucide-react";
|
|||||||
|
|
||||||
import { useApi } from "@/lib/api";
|
import { useApi } from "@/lib/api";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { trackBuildStarted, trackPartSelected, trackAffiliateLinkClicked, trackBuildShared, trackBuildSaved } from "@/lib/analytics";
|
||||||
|
|
||||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
|
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
|
||||||
@@ -369,20 +370,33 @@ function GunbuilderPageContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// Hydrate persisted build state only. URL actions (platform/select/remove)
|
// Intentional: Object.keys(...).length > 0 tightens the original condition.
|
||||||
// are handled by the dedicated useSearchParams effect later in this file.
|
// 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);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (parsed && typeof parsed === "object") {
|
if (parsed && typeof parsed === "object" && Object.keys(parsed).length > 0) {
|
||||||
setBuild(normalizeBuildState(parsed));
|
setBuild(normalizeBuildState(parsed));
|
||||||
|
hasBuild = true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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
|
// Signal that hydration is complete - this allows URL param processing to run
|
||||||
setIsHydrated(true);
|
setIsHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -842,6 +856,7 @@ function GunbuilderPageContent() {
|
|||||||
type: "success",
|
type: "success",
|
||||||
message: "Saved to your Vault ✅ ",
|
message: "Saved to your Vault ✅ ",
|
||||||
});
|
});
|
||||||
|
trackBuildSaved();
|
||||||
|
|
||||||
// Close modal + reset inputs for next variant
|
// Close modal + reset inputs for next variant
|
||||||
setSaveAsOpen(false);
|
setSaveAsOpen(false);
|
||||||
@@ -889,8 +904,10 @@ function GunbuilderPageContent() {
|
|||||||
}
|
}
|
||||||
return next;
|
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
|
// Handle URL query parameters (?select, ?remove) and keep platform synced + canonical
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1055,6 +1072,7 @@ function GunbuilderPageContent() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
trackBuildShared();
|
||||||
setShareStatus("Shareable link copied to clipboard.");
|
setShareStatus("Shareable link copied to clipboard.");
|
||||||
} catch {
|
} catch {
|
||||||
setShareStatus("Could not copy link to clipboard.");
|
setShareStatus("Could not copy link to clipboard.");
|
||||||
@@ -1074,6 +1092,7 @@ function GunbuilderPageContent() {
|
|||||||
text: "Check out my Battl Build.",
|
text: "Check out my Battl Build.",
|
||||||
url: shareUrl,
|
url: shareUrl,
|
||||||
});
|
});
|
||||||
|
trackBuildShared(); // fires only on successful share, not on cancel
|
||||||
setShareStatus("Share dialog opened.");
|
setShareStatus("Share dialog opened.");
|
||||||
} catch {
|
} catch {
|
||||||
setShareStatus(
|
setShareStatus(
|
||||||
@@ -1704,6 +1723,14 @@ function GunbuilderPageContent() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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
|
Buy
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user