7 Commits

5 changed files with 143 additions and 3 deletions
+68
View File
@@ -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.
+46 -3
View File
@@ -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";
@@ -355,6 +356,8 @@ function GunbuilderPageContent() {
// Parts data state
// -----------------------------
const [parts, setParts] = useState<Part[]>([]);
const previousPartIdsRef = useRef<Set<string>>(new Set());
const isFirstPartsHydrationRef = useRef(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -369,20 +372,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);
}, []);
@@ -602,6 +618,7 @@ function GunbuilderPageContent() {
if (ids.length === 0) {
lastHydrateKeyRef.current = "";
setParts([]);
previousPartIdsRef.current = new Set(); // keep in sync so re-selecting fires the event
setError(null);
setLoading(false);
return;
@@ -654,6 +671,21 @@ function GunbuilderPageContent() {
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
lastHydrateKeyRef.current = key;
} catch (err: any) {
@@ -842,6 +874,7 @@ function GunbuilderPageContent() {
type: "success",
message: "Saved to your Vault ✅ ",
});
trackBuildSaved();
// Close modal + reset inputs for next variant
setSaveAsOpen(false);
@@ -1055,6 +1088,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 +1108,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 +1739,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
</a>
@@ -6,6 +6,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { BuilderSlotKey } from "@/types/builderSlots";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
import { trackAffiliateLinkClicked } from "@/lib/analytics";
/**
* API Shapes
@@ -699,6 +700,7 @@ export default function ProductDetailsPage() {
target="_blank"
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"
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(o))}
>
Buy
</a>
@@ -726,6 +728,7 @@ export default function ProductDetailsPage() {
target="_blank"
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"
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(bestOffer))}
>
Buy from {merchantLabel(bestOffer)}
</a>
+21
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
interface Window {
umami?: {
track: (eventName: string, properties?: Record<string, string | number | boolean>) => void;
};
}