Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb1dfef7b7 | |||
| 10148acaa9 | |||
| 019e1892de | |||
| 668398e66a | |||
| f12e2ffee7 | |||
| 3a345b6c26 | |||
| 754a378782 |
@@ -0,0 +1,68 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start dev server at localhost:3000
|
||||||
|
npm run build # Production build (TS errors and ESLint are suppressed — see next.config.mjs)
|
||||||
|
npm run lint # ESLint check
|
||||||
|
```
|
||||||
|
|
||||||
|
No test framework is configured.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
`.env.local` for development:
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||||
|
BACKEND_BASE_URL=http://localhost:8080
|
||||||
|
LAUNCH_ONLY_ROOT=false # Set to "true" to restrict unauthenticated users to root page only
|
||||||
|
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
In Docker, the Spring Boot service is resolved as `http://battlbuilder-api:8080` (the default in `lib/springProxy.ts` when `SPRING_BASE_URL` is unset).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Two backends
|
||||||
|
1. **Spring Boot** — primary data backend. All product catalog, builds, auth, merchant/admin data lives here.
|
||||||
|
2. **Sanity CMS** — content only (guides/blog posts). Project `zal102rv`, dataset `production`. Queries in `lib/sanity.ts`.
|
||||||
|
|
||||||
|
### API proxy pattern
|
||||||
|
Next.js API routes (`app/api/`) act as a thin proxy to Spring Boot. Client code never calls Spring Boot directly. The helper `lib/springProxy.ts` forwards cookies and auth headers to Spring Boot and handles response serialization.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- **Session cookie**: `session_token` HTTP-only cookie set by Spring Boot, forwarded by the proxy.
|
||||||
|
- **Client state**: `AuthContext` (`context/AuthContext.tsx`) hydrates from `localStorage` on mount, then verifies against `/api/auth/me`.
|
||||||
|
- **Middleware**: `middleware.ts` enforces route protection. Public paths are whitelisted; `/admin/*` requires login; `/builder`, `/builds`, `/vault` are gated only when `LAUNCH_ONLY_ROOT=true`.
|
||||||
|
|
||||||
|
### Route groups
|
||||||
|
- `app/(app)/` — main consumer app layout (TopNav + Footer)
|
||||||
|
- `app/(app)/(builder)/` — builder + parts browser (BuilderNav layout)
|
||||||
|
- `app/(account)/` — account settings pages (AccountChrome sidebar layout)
|
||||||
|
- `app/admin/` — internal admin tools (AdminLeftNavigation layout)
|
||||||
|
- `app/api/` — 50 server-side route handlers proxying to Spring Boot
|
||||||
|
|
||||||
|
### Builder domain model
|
||||||
|
The builder is AR-platform-centric. Key concepts:
|
||||||
|
|
||||||
|
- **`BuilderSlotKey`** (`types/builderSlots.ts`) — the canonical kebab-case category ID (e.g., `"complete-upper"`, `"barrel"`). This is the source of truth for localStorage keys, URL state, and backend part role mapping.
|
||||||
|
- **`CATEGORY_TO_PART_ROLES` / `PART_ROLE_TO_CATEGORY`** (`lib/catalogMappings.ts`) — bidirectional mapping between `BuilderSlotKey` and backend `partRole` strings. Update here when the backend adds new part roles.
|
||||||
|
- **`BUILDER_SLOTS`** (`data/builderSlots.ts`) — defines slot groups (LOWER, UPPER, SYSTEM, ACCESSORIES) and satisfaction patterns (e.g., a complete lower satisfies the lower assembly slot).
|
||||||
|
- **`BuildState`** — `Partial<Record<BuilderSlotKey, string>>` stored in `localStorage`. Product IDs are the values.
|
||||||
|
- **Overlap detection** (`lib/buildOverlaps.ts`) — warns users when they select both a complete assembly and individual sub-parts.
|
||||||
|
|
||||||
|
### Legacy key aliases
|
||||||
|
`types/builderSlots.ts` exports `normalizeSlotKey()` which maps camelCase legacy keys (e.g., `completeUpper`) to current kebab-case keys. This is needed for backwards-compatible URL/localStorage deserialization.
|
||||||
|
|
||||||
|
### API client pattern
|
||||||
|
- Client components use `useApi()` hook (`lib/api.ts`) for fetch calls with `credentials: 'include'`.
|
||||||
|
- Admin-specific API calls live in `lib/api/` (e.g., `adminCategoryMappings.ts`, `adminMerchantMappings.ts`).
|
||||||
|
- Product catalog helpers are in `lib/catalog.ts`.
|
||||||
|
|
||||||
|
### Sanity content
|
||||||
|
- `lib/sanity.ts` — Sanity client + GROQ queries for posts.
|
||||||
|
- `lib/sanityFetch.ts` — low-level HTTPS fetch to Sanity IP (bypasses CDN DNS issues in server/Docker environments).
|
||||||
|
- Guides at `/guides/[slug]` are the only public Sanity-driven pages.
|
||||||
@@ -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";
|
||||||
@@ -355,6 +356,8 @@ function GunbuilderPageContent() {
|
|||||||
// Parts data state
|
// Parts data state
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
const [parts, setParts] = useState<Part[]>([]);
|
const [parts, setParts] = useState<Part[]>([]);
|
||||||
|
const previousPartIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const isFirstPartsHydrationRef = useRef(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -369,20 +372,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);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -602,6 +618,7 @@ function GunbuilderPageContent() {
|
|||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
lastHydrateKeyRef.current = "";
|
lastHydrateKeyRef.current = "";
|
||||||
setParts([]);
|
setParts([]);
|
||||||
|
previousPartIdsRef.current = new Set(); // keep in sync so re-selecting fires the event
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -654,6 +671,21 @@ function GunbuilderPageContent() {
|
|||||||
|
|
||||||
setParts(hydrated);
|
setParts(hydrated);
|
||||||
|
|
||||||
|
// Track newly selected parts after API fetch — fires with real name, not a numeric ID.
|
||||||
|
// Skip the first hydration: this covers both localStorage build restoration and ?select=
|
||||||
|
// share-link arrivals. Neither represents a live user interaction in the current session.
|
||||||
|
if (isFirstPartsHydrationRef.current) {
|
||||||
|
isFirstPartsHydrationRef.current = false;
|
||||||
|
} else {
|
||||||
|
const prevIds = previousPartIdsRef.current;
|
||||||
|
for (const p of hydrated) {
|
||||||
|
if (!prevIds.has(String(p.id))) {
|
||||||
|
trackPartSelected(p.categoryId as BuilderSlotKey, p.name, String(p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousPartIdsRef.current = new Set(hydrated.map((p) => String(p.id)));
|
||||||
|
|
||||||
// ✅ Mark key as hydrated ONLY after success
|
// ✅ Mark key as hydrated ONLY after success
|
||||||
lastHydrateKeyRef.current = key;
|
lastHydrateKeyRef.current = key;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -842,6 +874,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);
|
||||||
@@ -1055,6 +1088,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 +1108,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 +1739,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>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|||||||
|
|
||||||
import type { BuilderSlotKey } from "@/types/builderSlots";
|
import type { BuilderSlotKey } from "@/types/builderSlots";
|
||||||
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||||
|
import { trackAffiliateLinkClicked } from "@/lib/analytics";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Shapes
|
* API Shapes
|
||||||
@@ -699,6 +700,7 @@ export default function ProductDetailsPage() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
||||||
|
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(o))}
|
||||||
>
|
>
|
||||||
Buy
|
Buy
|
||||||
</a>
|
</a>
|
||||||
@@ -726,6 +728,7 @@ export default function ProductDetailsPage() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||||
|
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(bestOffer))}
|
||||||
>
|
>
|
||||||
Buy from {merchantLabel(bestOffer)} →
|
Buy from {merchantLabel(bestOffer)} →
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { BuilderSlotKey } from "@/types/builderSlots";
|
||||||
|
|
||||||
|
export function trackBuildStarted() {
|
||||||
|
window.umami?.track("build_started");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackPartSelected(slot: BuilderSlotKey, partName: string, partId: string) {
|
||||||
|
window.umami?.track("part_selected", { slot, part_name: partName, part_id: partId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackAffiliateLinkClicked(partName: string, retailer: string) {
|
||||||
|
window.umami?.track("affiliate_link_clicked", { part_name: partName, retailer });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackBuildShared() {
|
||||||
|
window.umami?.track("build_shared");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackBuildSaved() {
|
||||||
|
window.umami?.track("build_saved");
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
interface Window {
|
||||||
|
umami?: {
|
||||||
|
track: (eventName: string, properties?: Record<string, string | number | boolean>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user