Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9498f8aca |
@@ -74,7 +74,8 @@ const isUuid = (v: string) =>
|
|||||||
v
|
v
|
||||||
);
|
);
|
||||||
|
|
||||||
// API routes now handled by Next.js /api routes (server-side)
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
const STORAGE_KEY = "gunbuilder-build-state";
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
@@ -295,7 +296,7 @@ function buildAssemblyRows(params: {
|
|||||||
rows.push({
|
rows.push({
|
||||||
key: `${groupLabel}-${paths.complete}`,
|
key: `${groupLabel}-${paths.complete}`,
|
||||||
kind: "category",
|
kind: "category",
|
||||||
label: "Completed Lower (Fastest)",
|
label: "Complete Assembly (Fastest)",
|
||||||
categoryId: paths.complete,
|
categoryId: paths.complete,
|
||||||
indent: 0,
|
indent: 0,
|
||||||
pill: mode === "complete" ? "Selected" : undefined,
|
pill: mode === "complete" ? "Selected" : undefined,
|
||||||
@@ -336,8 +337,8 @@ function GunbuilderPageContent() {
|
|||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
// ✅ Auth: check if user is logged in
|
// ✅ Auth: we need the token ready before calling /builds/me/*
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { token, loading: authLoading, getAuthHeaders } = useAuth();
|
||||||
|
|
||||||
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15");
|
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15");
|
||||||
|
|
||||||
@@ -615,7 +616,7 @@ function GunbuilderPageContent() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/catalog/products/by-ids`,
|
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -727,7 +728,7 @@ function GunbuilderPageContent() {
|
|||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
|
|
||||||
// If not logged in, don't spam the backend; show a helpful message instead.
|
// If not logged in, don't spam the backend; show a helpful message instead.
|
||||||
if (!user) {
|
if (!token) {
|
||||||
setShareStatus("Please log in to load builds from your Vault.");
|
setShareStatus("Please log in to load builds from your Vault.");
|
||||||
window.setTimeout(() => setShareStatus(null), 4500);
|
window.setTimeout(() => setShareStatus(null), 4500);
|
||||||
return;
|
return;
|
||||||
@@ -737,12 +738,14 @@ function GunbuilderPageContent() {
|
|||||||
try {
|
try {
|
||||||
setShareStatus("Loading build…");
|
setShareStatus("Loading build…");
|
||||||
|
|
||||||
// NOTE: using fetch here goes through our Next.js API routes with HTTP-only cookies
|
// NOTE: using fetch here avoids any “stale api instance” issues and lets us
|
||||||
|
// explicitly attach JWT headers.
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/builds/${encodeURIComponent(uuidToLoad)}`,
|
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...getAuthHeaders(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -784,7 +787,9 @@ function GunbuilderPageContent() {
|
|||||||
searchParams,
|
searchParams,
|
||||||
router,
|
router,
|
||||||
authLoading,
|
authLoading,
|
||||||
user,
|
token,
|
||||||
|
getAuthHeaders,
|
||||||
|
// API_BASE_URL is a module constant; no need to include
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
@@ -1195,64 +1200,53 @@ function GunbuilderPageContent() {
|
|||||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div>
|
||||||
<div>
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
BATTL BUILDERS
|
||||||
BATTL BUILDERS
|
</p>
|
||||||
</p>
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
Builder <span className="text-amber-300">Early Access</span>
|
||||||
Builder <span className="text-amber-300">Early Access</span>
|
</h1>
|
||||||
</h1>
|
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
Explore components from trusted brands, choose one part per
|
||||||
Explore components from trusted brands, choose one part per
|
category, track live prices, and watch your total build cost
|
||||||
category, track live prices, and watch your total build cost
|
update as you go. This early-access builder keeps your setup saved
|
||||||
update as you go. This early-access builder keeps your setup saved
|
locally so you can come back and refine it anytime.
|
||||||
locally so you can come back and refine it anytime.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user?.role === "ADMIN" ? (
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
<Link
|
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||||
href="/admin"
|
Platform
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-200 hover:border-amber-300 hover:text-amber-100"
|
<select
|
||||||
>
|
value={platform}
|
||||||
Admin Toolbar →
|
onChange={(e) => {
|
||||||
</Link>
|
const next = e.target.value as (typeof PLATFORMS)[number];
|
||||||
) : null}
|
setPlatform(next);
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
// Keep URL in sync, and clear transient action params
|
||||||
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
Platform
|
qp.set("platform", normalizePlatformKey(next));
|
||||||
<select
|
qp.delete("select");
|
||||||
value={platform}
|
qp.delete("remove");
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value as (typeof PLATFORMS)[number];
|
|
||||||
setPlatform(next);
|
|
||||||
|
|
||||||
// Keep URL in sync, and clear transient action params
|
router.replace(`/builder?${qp.toString()}`, {
|
||||||
const qp = new URLSearchParams(searchParams.toString());
|
scroll: false,
|
||||||
qp.set("platform", normalizePlatformKey(next));
|
});
|
||||||
qp.delete("select");
|
}}
|
||||||
qp.delete("remove");
|
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
>
|
||||||
router.replace(`/builder?${qp.toString()}`, {
|
{PLATFORMS.map((p) => (
|
||||||
scroll: false,
|
<option key={p} value={p}>
|
||||||
});
|
{PLATFORM_LABEL[p]}
|
||||||
}}
|
</option>
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
))}
|
||||||
>
|
</select>
|
||||||
{PLATFORMS.map((p) => (
|
</label>
|
||||||
<option key={p} value={p}>
|
<p className="text-[0.7rem] text-zinc-500">
|
||||||
{PLATFORM_LABEL[p]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<p className="text-[0.7rem] text-zinc-500">
|
|
||||||
Parts list updates automatically when you change platforms.
|
Parts list updates automatically when you change platforms.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Build summary panel */}
|
{/* Build summary panel */}
|
||||||
@@ -1481,11 +1475,6 @@ function GunbuilderPageContent() {
|
|||||||
|
|
||||||
if (groupCategories.length === 0) return null;
|
if (groupCategories.length === 0) return null;
|
||||||
|
|
||||||
// Check if there are locked/conflicting parts in this group
|
|
||||||
const hasConflicts = tableRows.some(
|
|
||||||
(row) => row.kind === "category" && row.locked
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`group-${group.id}`}
|
id={`group-${group.id}`}
|
||||||
@@ -1505,23 +1494,6 @@ function GunbuilderPageContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasConflicts && (
|
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-amber-400 text-sm">⚠</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-amber-300">
|
|
||||||
Part Conflict Detected
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-zinc-300 mt-1">
|
|
||||||
Some parts below are disabled because you've selected a complete assembly that already includes them. To customize individual parts, remove the complete assembly first.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
|
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
|
||||||
<table className="min-w-full text-xs md:text-sm">
|
<table className="min-w-full text-xs md:text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -1636,15 +1608,9 @@ function GunbuilderPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedPart ? (
|
{selectedPart ? (
|
||||||
<Link
|
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
||||||
href={`/parts/p/${canonicalPlatform}/${category.id}/${selectedPart.id}-${selectedPart.name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/(^-|-$)/g, "")}`}
|
|
||||||
className="text-[0.7rem] text-zinc-500 hover:text-amber-300 hover:underline line-clamp-1 transition-colors"
|
|
||||||
>
|
|
||||||
{selectedPart.name}
|
{selectedPart.name}
|
||||||
</Link>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[0.7rem] text-zinc-600">
|
<div className="text-[0.7rem] text-zinc-600">
|
||||||
No part selected
|
No part selected
|
||||||
@@ -1674,27 +1640,8 @@ function GunbuilderPageContent() {
|
|||||||
<td className="px-3 py-2 align-top">
|
<td className="px-3 py-2 align-top">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{isLocked ? (
|
{isLocked ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-[0.7rem] text-zinc-500">
|
||||||
<span className="text-[0.7rem] text-amber-400/70">
|
—
|
||||||
⚠ Disabled
|
|
||||||
</span>
|
|
||||||
<div className="group relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-zinc-500 hover:text-zinc-300 text-xs"
|
|
||||||
title="Why is this disabled?"
|
|
||||||
>
|
|
||||||
ⓘ
|
|
||||||
</button>
|
|
||||||
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs text-zinc-300 shadow-xl">
|
|
||||||
<p className="font-semibold text-amber-300 mb-1">
|
|
||||||
Part Conflict
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
You've selected a complete assembly which already includes this part. Remove the complete assembly to select individual parts.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : selectedPart ? (
|
) : selectedPart ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type OfferFromApi = {
|
|||||||
price?: number | null;
|
price?: number | null;
|
||||||
originalPrice?: number | null;
|
originalPrice?: number | null;
|
||||||
buyUrl?: string | null;
|
buyUrl?: string | null;
|
||||||
buyShortUrl?: string | null;
|
|
||||||
inStock?: boolean | null;
|
inStock?: boolean | null;
|
||||||
lastUpdated?: string | null;
|
lastUpdated?: string | null;
|
||||||
};
|
};
|
||||||
@@ -30,7 +29,6 @@ type GunbuilderProductFromApi = {
|
|||||||
platform: string;
|
platform: string;
|
||||||
partRole: string;
|
partRole: string;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
caliber?: string | null;
|
|
||||||
|
|
||||||
// Optional (legacy fallback label if product.offers is missing)
|
// Optional (legacy fallback label if product.offers is missing)
|
||||||
merchantName?: string | null;
|
merchantName?: string | null;
|
||||||
@@ -39,7 +37,6 @@ type GunbuilderProductFromApi = {
|
|||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
|
|
||||||
buyUrl?: string | null;
|
buyUrl?: string | null;
|
||||||
buyShortUrl?: string | null;
|
|
||||||
inStock?: boolean | null;
|
inStock?: boolean | null;
|
||||||
|
|
||||||
shortDescription?: string | null;
|
shortDescription?: string | null;
|
||||||
@@ -241,23 +238,14 @@ export default function ProductDetailsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const url = `/api/catalog/products/${numericId}`;
|
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
signal: controller.signal,
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load product (${res.status})`);
|
throw new Error(`Failed to load product (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: GunbuilderProductFromApi = await res.json();
|
const data: GunbuilderProductFromApi = await res.json();
|
||||||
console.log('[DEBUG] Product from API:', {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
buyUrl: data.buyUrl,
|
|
||||||
buyShortUrl: data.buyShortUrl
|
|
||||||
});
|
|
||||||
setProduct(data);
|
setProduct(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") return;
|
if (err?.name === "AbortError") return;
|
||||||
@@ -284,18 +272,14 @@ export default function ProductDetailsPage() {
|
|||||||
try {
|
try {
|
||||||
setOffersLoading(true);
|
setOffersLoading(true);
|
||||||
|
|
||||||
const url = `/api/catalog/products/${numericId}/offers`;
|
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
signal: controller.signal,
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load offers (${res.status})`);
|
throw new Error(`Failed to load offers (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: OfferFromApi[] = await res.json();
|
const data: OfferFromApi[] = await res.json();
|
||||||
console.log('[DEBUG] Offers from API:', data);
|
|
||||||
setOffersFromApi(Array.isArray(data) ? data : []);
|
setOffersFromApi(Array.isArray(data) ? data : []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") return;
|
if (err?.name === "AbortError") return;
|
||||||
@@ -346,23 +330,14 @@ export default function ProductDetailsPage() {
|
|||||||
* - Fallback to legacy single-offer derived from product if needed
|
* - Fallback to legacy single-offer derived from product if needed
|
||||||
*/
|
*/
|
||||||
const offers = useMemo(() => {
|
const offers = useMemo(() => {
|
||||||
if (offersFromApi.length > 0) {
|
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
|
||||||
console.log('[DEBUG] Using offers from API, first offer:', offersFromApi[0]);
|
|
||||||
const fallbackShortUrl = product?.buyShortUrl ?? null;
|
|
||||||
const normalized = offersFromApi.map((offer) => ({
|
|
||||||
...offer,
|
|
||||||
buyShortUrl: offer.buyShortUrl || fallbackShortUrl,
|
|
||||||
}));
|
|
||||||
return sortOffers(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (product?.buyUrl || product?.buyShortUrl) {
|
if (product?.buyUrl) {
|
||||||
console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
|
|
||||||
return sortOffers([
|
return sortOffers([
|
||||||
{
|
{
|
||||||
merchantName: product.merchantName ?? "Retailer",
|
merchantName: product.merchantName ?? "Retailer",
|
||||||
price: product.price,
|
price: product.price,
|
||||||
buyUrl: product.buyShortUrl || product.buyUrl,
|
buyUrl: product.buyUrl,
|
||||||
inStock: product.inStock ?? true,
|
inStock: product.inStock ?? true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -584,14 +559,6 @@ export default function ProductDetailsPage() {
|
|||||||
{product.platform || platform}
|
{product.platform || platform}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{product.caliber && (
|
|
||||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
|
||||||
Caliber:{" "}
|
|
||||||
<span className="font-semibold text-zinc-100">
|
|
||||||
{product.caliber}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
Role:{" "}
|
Role:{" "}
|
||||||
<span className="font-semibold text-zinc-100">
|
<span className="font-semibold text-zinc-100">
|
||||||
@@ -693,9 +660,9 @@ export default function ProductDetailsPage() {
|
|||||||
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{(o.buyShortUrl || o.buyUrl) ? (
|
{o.buyUrl ? (
|
||||||
<a
|
<a
|
||||||
href={o.buyShortUrl || o.buyUrl}
|
href={o.buyUrl}
|
||||||
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"
|
||||||
@@ -719,10 +686,10 @@ export default function ProductDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Best Offer CTA */}
|
{/* Best Offer CTA */}
|
||||||
{(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
|
{bestOffer?.buyUrl ? (
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<a
|
<a
|
||||||
href={bestOffer.buyShortUrl || bestOffer.buyUrl}
|
href={bestOffer.buyUrl}
|
||||||
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"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
||||||
|
|
||||||
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
|
export default async function Page(props: { params: Promise<{ platform: string; partRole: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import MailingAddressComponent from "@/components/MailingAddressComponent";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Privacy Policy | Battl Builder",
|
title: "Privacy Policy | Battl Builder",
|
||||||
@@ -7,6 +8,8 @@ export const metadata = {
|
|||||||
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
|
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const lastUpdated = "January 9, 2026";
|
const lastUpdated = "January 9, 2026";
|
||||||
|
|
||||||
@@ -22,7 +25,8 @@ export default function PrivacyPolicy() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
|
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
|
||||||
<p>
|
<p>
|
||||||
Battl Builder ("we," "us," "our," or "Company") respects your privacy
|
Battl Builder ("we," "us," "our," or "Company") respects
|
||||||
|
your privacy
|
||||||
and is committed to protecting it through this Privacy Policy. This
|
and is committed to protecting it through this Privacy Policy. This
|
||||||
policy explains our practices regarding the collection, use, and
|
policy explains our practices regarding the collection, use, and
|
||||||
protection of your personal information when you access our website
|
protection of your personal information when you access our website
|
||||||
@@ -412,15 +416,7 @@ export default function PrivacyPolicy() {
|
|||||||
<strong>Email:</strong>{" "}
|
<strong>Email:</strong>{" "}
|
||||||
<span className="text-amber-300">privacy@battlbuilder.com</span>
|
<span className="text-amber-300">privacy@battlbuilder.com</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<MailingAddressComponent/>
|
||||||
<strong>Mailing Address:</strong>
|
|
||||||
<br />
|
|
||||||
Battl Builder, LLC.
|
|
||||||
<br />
|
|
||||||
[Your Address]
|
|
||||||
<br />
|
|
||||||
[City, State, ZIP]
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm">
|
<p className="mt-4 text-sm">
|
||||||
We will respond to all privacy inquiries within 30 days of receipt.
|
We will respond to all privacy inquiries within 30 days of receipt.
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ type BuildDto = {
|
|||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// API routes now handled by Next.js /api routes (server-side)
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
// UUID validator (defensive)
|
// UUID validator (defensive)
|
||||||
const isUuid = (v: string) =>
|
const isUuid = (v: string) =>
|
||||||
@@ -39,14 +40,14 @@ export default function VaultPage() {
|
|||||||
|
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// - `loading` here is AuthContext hydration, not network.
|
// - `loading` here is AuthContext hydration, not network.
|
||||||
// - Auth is handled by HTTP-only cookies automatically.
|
// - `token` is the real gate for /me endpoints.
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, token, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const authed = !!user;
|
const authed = !!user && !!token;
|
||||||
|
|
||||||
// Redirect if not logged in (after auth hydrates)
|
// Redirect if not logged in (after auth hydrates)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,12 +56,12 @@ export default function VaultPage() {
|
|||||||
}
|
}
|
||||||
}, [authLoading, authed, router]);
|
}, [authLoading, authed, router]);
|
||||||
|
|
||||||
// When auth identity changes, clear stale list to avoid "ghost builds"
|
// When auth identity changes, clear stale list to avoid “ghost builds”
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
setBuilds([]);
|
setBuilds([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [authLoading, user?.uuid]);
|
}, [authLoading, user?.uuid, token]);
|
||||||
|
|
||||||
// Fetch user builds
|
// Fetch user builds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -74,8 +75,8 @@ export default function VaultPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// Use api wrapper which includes credentials (cookies) automatically.
|
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
|
||||||
const res = await api.get("/api/builds");
|
const res = await api.get("/api/v1/builds/me");
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type AdminBetaRequestDto = {
|
type AdminBetaRequestDto = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -36,12 +40,17 @@ type AdminInviteResponse = {
|
|||||||
token?: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchJson(path: string, init: RequestInit = {}) {
|
async function fetchJson(
|
||||||
const res = await fetch(path, {
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
authHeaders: HeadersInit = {}
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(init.headers ?? {}),
|
...(init.headers ?? {}),
|
||||||
|
...authHeaders,
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
@@ -92,6 +101,9 @@ function Badge({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminBetaInvitesPage() {
|
export default function AdminBetaInvitesPage() {
|
||||||
|
const { getAuthHeaders, user } = useAuth();
|
||||||
|
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
|
||||||
|
|
||||||
// ------------------------------
|
// ------------------------------
|
||||||
// Batch invites
|
// Batch invites
|
||||||
// ------------------------------
|
// ------------------------------
|
||||||
@@ -114,9 +126,11 @@ export default function AdminBetaInvitesPage() {
|
|||||||
tokenMinutes: String(tokenMinutes || "30"),
|
tokenMinutes: String(tokenMinutes || "30"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
|
const data = await fetchJson(
|
||||||
method: "POST",
|
`/api/v1/admin/beta/invites/send?${qs.toString()}`,
|
||||||
});
|
{ method: "POST" },
|
||||||
|
authHeaders
|
||||||
|
);
|
||||||
|
|
||||||
setBatchResult(data);
|
setBatchResult(data);
|
||||||
await loadRequests();
|
await loadRequests();
|
||||||
@@ -152,8 +166,9 @@ export default function AdminBetaInvitesPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = (await fetchJson(
|
const data = (await fetchJson(
|
||||||
`/api/admin/beta/requests?page=${page}&size=${size}`,
|
`/api/v1/admin/beta/requests?page=${page}&size=${size}`,
|
||||||
{ method: "GET" }
|
{ method: "GET" },
|
||||||
|
authHeaders
|
||||||
)) as PageResponse<AdminBetaRequestDto>;
|
)) as PageResponse<AdminBetaRequestDto>;
|
||||||
|
|
||||||
setRequests(data);
|
setRequests(data);
|
||||||
@@ -166,9 +181,11 @@ export default function AdminBetaInvitesPage() {
|
|||||||
|
|
||||||
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
|
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
|
||||||
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
|
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
|
||||||
return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
|
return (await fetchJson(
|
||||||
method: "POST",
|
`/api/v1/admin/beta/requests/${userId}/invite`,
|
||||||
})) as AdminInviteResponse;
|
{ method: "POST" },
|
||||||
|
authHeaders
|
||||||
|
)) as AdminInviteResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSendInviteEmail(userId: number) {
|
async function onSendInviteEmail(userId: number) {
|
||||||
@@ -224,6 +241,19 @@ export default function AdminBetaInvitesPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
|
const isAdmin = (user?.role ?? "").toUpperCase() === "ADMIN";
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-xl font-semibold">Admin</h1>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
You don’t have access to this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
fetchEmailRequests,
|
fetchEmailRequests,
|
||||||
createEmailRequest,
|
createEmailRequest,
|
||||||
@@ -16,6 +17,7 @@ import { formatDate } from "@/lib/dateFormattingUtility";
|
|||||||
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
|
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
|
||||||
|
|
||||||
export default function AdminEmailPage() {
|
export default function AdminEmailPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -31,9 +33,11 @@ export default function AdminEmailPage() {
|
|||||||
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
|
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
|
||||||
|
|
||||||
const loadEmails = useCallback(async () => {
|
const loadEmails = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchEmailRequests();
|
const data = await fetchEmailRequests(token);
|
||||||
setEmails(data);
|
setEmails(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -42,7 +46,7 @@ export default function AdminEmailPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEmails();
|
loadEmails();
|
||||||
@@ -104,10 +108,11 @@ export default function AdminEmailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!token) return;
|
||||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteEmailRequest(id);
|
await deleteEmailRequest(token, id);
|
||||||
await loadEmails();
|
await loadEmails();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e?.message ?? "Failed to delete email request");
|
alert(e?.message ?? "Failed to delete email request");
|
||||||
@@ -116,15 +121,17 @@ export default function AdminEmailPage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
if (modalMode === "create") {
|
if (modalMode === "create") {
|
||||||
await createEmailRequest(formData);
|
await createEmailRequest(token, formData);
|
||||||
} else if (selectedEmail) {
|
} else if (selectedEmail) {
|
||||||
const updateData: UpdateEmailRequestDto = {};
|
const updateData: UpdateEmailRequestDto = {};
|
||||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||||
await updateEmailRequest(selectedEmail.id, updateData);
|
await updateEmailRequest(token, selectedEmail.id, updateData);
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
await loadEmails();
|
await loadEmails();
|
||||||
@@ -135,6 +142,14 @@ export default function AdminEmailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-sm text-red-500">
|
||||||
|
You must be logged in to view this page.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
fetchEmailRequests,
|
fetchEmailRequests,
|
||||||
createEmailRequest,
|
createEmailRequest,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/dateFormattingUtility";
|
import { formatDate } from "@/lib/dateFormattingUtility";
|
||||||
export default function AdminEmailPage() {
|
export default function AdminEmailPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -26,9 +28,11 @@ export default function AdminEmailPage() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const loadEmails = useCallback(async () => {
|
const loadEmails = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchEmailRequests();
|
const data = await fetchEmailRequests(token);
|
||||||
setEmails(data);
|
setEmails(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -37,7 +41,7 @@ export default function AdminEmailPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEmails();
|
loadEmails();
|
||||||
@@ -58,10 +62,11 @@ export default function AdminEmailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!token) return;
|
||||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteEmailRequest(id);
|
await deleteEmailRequest(token, id);
|
||||||
await loadEmails();
|
await loadEmails();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e?.message ?? "Failed to delete email request");
|
alert(e?.message ?? "Failed to delete email request");
|
||||||
@@ -70,15 +75,17 @@ export default function AdminEmailPage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
if (modalMode === "create") {
|
if (modalMode === "create") {
|
||||||
await createEmailRequest(formData);
|
await createEmailRequest(token, formData);
|
||||||
} else if (selectedEmail) {
|
} else if (selectedEmail) {
|
||||||
const updateData: UpdateEmailRequestDto = {};
|
const updateData: UpdateEmailRequestDto = {};
|
||||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||||
await updateEmailRequest(selectedEmail.id, updateData);
|
await updateEmailRequest(token, selectedEmail.id, updateData);
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
await loadEmails();
|
await loadEmails();
|
||||||
@@ -89,6 +96,14 @@ export default function AdminEmailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-sm text-red-500">
|
||||||
|
You must be logged in to view this page.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, useEffect, useRef, useState} from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export default function ImportStatusPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const [summaryRes, byMerchantRes] = await Promise.all([
|
const [summaryRes, byMerchantRes] = await Promise.all([
|
||||||
fetch('/api/admin/import-status/summary', { credentials: 'include' }),
|
fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
|
||||||
fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
|
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!summaryRes.ok || !byMerchantRes.ok) {
|
if (!summaryRes.ok || !byMerchantRes.ok) {
|
||||||
@@ -108,10 +108,9 @@ export default function ImportStatusPage() {
|
|||||||
try {
|
try {
|
||||||
setImportingId(merchantId);
|
setImportingId(merchantId);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/merchants/${merchantId}/import`,
|
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: 'include',
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export default function AdminLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// ✅ prevent browser-level horizontal scroll from any wide children
|
// ✅ prevent browser-level horizontal scroll from any wide children
|
||||||
<div className="h-screen bg-black text-zinc-50 overflow-hidden">
|
<div className="min-h-screen bg-black text-zinc-50 overflow-x-hidden pt-[52px]">
|
||||||
<AdminLeftNavigation
|
<AdminLeftNavigation
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||||
@@ -158,7 +158,7 @@ export default function AdminLayout({
|
|||||||
|
|
||||||
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
|
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
|
||||||
<div
|
<div
|
||||||
className="flex h-full flex-1 min-w-0 flex-col transition-all duration-300"
|
className="flex min-h-screen flex-1 min-w-0 flex-col transition-all duration-300"
|
||||||
style={{ marginLeft: collapsed ? '68px' : '280px' }}
|
style={{ marginLeft: collapsed ? '68px' : '280px' }}
|
||||||
>
|
>
|
||||||
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm">
|
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm">
|
||||||
@@ -173,11 +173,14 @@ export default function AdminLayout({
|
|||||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
|
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
|
||||||
Internal • v0.1
|
Internal • v0.1
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
|
||||||
|
ADMIN
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
|
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
|
||||||
<main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
|
<main className="flex w-full flex-1 min-w-0 flex-col px-4 py-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -198,9 +198,8 @@ export default function MappingAdminPage() {
|
|||||||
|
|
||||||
// EXPECTED backend endpoint (new):
|
// EXPECTED backend endpoint (new):
|
||||||
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
|
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
|
||||||
const res = await fetch('/api/admin/mapping/options', {
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
credentials: 'include',
|
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,9 +243,8 @@ export default function MappingAdminPage() {
|
|||||||
if (tab === "roles") {
|
if (tab === "roles") {
|
||||||
// Existing endpoint you already have:
|
// Existing endpoint you already have:
|
||||||
// GET /api/admin/mapping/pending-buckets
|
// GET /api/admin/mapping/pending-buckets
|
||||||
const res = await fetch('/api/admin/mapping/pending-buckets', {
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
credentials: 'include',
|
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,8 +277,8 @@ export default function MappingAdminPage() {
|
|||||||
if (q?.trim()) params.set("q", q.trim());
|
if (q?.trim()) params.set("q", q.trim());
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/mapping/raw-categories?${params.toString()}`,
|
`${API_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`,
|
||||||
{ headers: { Accept: "application/json" }, credentials: 'include', cache: "no-store" }
|
{ headers: { Accept: "application/json" }, cache: "no-store" }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -338,10 +336,9 @@ export default function MappingAdminPage() {
|
|||||||
|
|
||||||
// Existing endpoint you already have:
|
// Existing endpoint you already have:
|
||||||
// POST /api/admin/mapping/apply
|
// POST /api/admin/mapping/apply
|
||||||
const res = await fetch('/api/admin/mapping/apply', {
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
merchantId: row.merchantId,
|
merchantId: row.merchantId,
|
||||||
rawCategoryKey: row.rawCategoryKey,
|
rawCategoryKey: row.rawCategoryKey,
|
||||||
@@ -393,10 +390,9 @@ export default function MappingAdminPage() {
|
|||||||
|
|
||||||
// EXPECTED new endpoint:
|
// EXPECTED new endpoint:
|
||||||
// POST /api/admin/mapping/upsert
|
// POST /api/admin/mapping/upsert
|
||||||
const res = await fetch('/api/admin/mapping/upsert', {
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
merchantId: row.merchantId,
|
merchantId: row.merchantId,
|
||||||
platform: row.platform ?? platform ?? null,
|
platform: row.platform ?? platform ?? null,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
// API routes now handled by Next.js /api routes (server-side)
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type MerchantAdminDto = {
|
type MerchantAdminDto = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -90,7 +91,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const url = `/api/admin/merchants`;
|
const url = `${API_BASE_URL}/api/admin/merchants`;
|
||||||
console.log("Loading merchants from:", url);
|
console.log("Loading merchants from:", url);
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -149,7 +150,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/merchants/${id}`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -188,7 +189,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/merchants/${id}/import`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,7 +218,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/merchants/${id}/offer-sync`,
|
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
|
||||||
{ method: "POST" }
|
{ method: "POST" }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -246,7 +247,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/merchants`, {
|
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
+1
-3
@@ -24,9 +24,7 @@ export default function AdminLandingPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await fetch('/api/admin/dashboard/overview', {
|
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`);
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load dashboard (${res.status})`);
|
throw new Error(`Failed to load dashboard (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import {JSX, useCallback, useMemo, useState} from "react";
|
||||||
import type { CaliberDto, PlatformDto } from "../_lib/types";
|
import type { CaliberDto, PlatformDto } from "../_lib/types";
|
||||||
import { RightDrawer } from "./RightDrawer";
|
import { RightDrawer } from "./RightDrawer";
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const API_BASE_URL =
|
|||||||
|
|
||||||
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const token = cookies().get("session_token")?.value;
|
const token = (await cookies()).get("bb_access_token")?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get("session_token")?.value;
|
|
||||||
if (!token) throw new Error("Unauthorized");
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
_req: Request,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/admin/beta/requests/${params.id}/invite`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
cache: "no-store",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get("session_token")?.value;
|
|
||||||
if (!token) throw new Error("Unauthorized");
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const search = url.searchParams.toString();
|
|
||||||
|
|
||||||
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
|
|
||||||
search ? `?${search}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const res = await fetch(upstream, {
|
|
||||||
headers,
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`, {
|
|
||||||
headers,
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get("session_token")?.value;
|
|
||||||
if (!token) throw new Error("Unauthorized");
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { ...headers, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get("session_token")?.value;
|
|
||||||
if (!token) throw new Error("Unauthorized");
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
|
||||||
headers,
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...headers, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
|
|
||||||
headers,
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
|
|
||||||
headers,
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
|
|
||||||
headers,
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
|
|
||||||
headers,
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
|
|
||||||
{
|
|
||||||
headers,
|
|
||||||
cache: 'no-store',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/import`,
|
|
||||||
{ method: 'POST', headers }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/offer-sync`,
|
|
||||||
{ method: 'POST', headers }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
|
||||||
{ headers }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
|
||||||
{ method: 'DELETE', headers }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Forward to backend API
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: text || 'Login failed' },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
// Set HTTP-only cookie instead of returning token
|
|
||||||
cookies().set('session_token', data.token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return user data without token
|
|
||||||
return NextResponse.json({
|
|
||||||
user: data.user,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Login failed' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
cookies().delete('session_token');
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
cookies().delete('session_token');
|
|
||||||
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Failed to fetch user' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Forward to backend API
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: text || 'Registration failed' },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
// Set HTTP-only cookie on successful registration
|
|
||||||
if (data.token) {
|
|
||||||
cookies().set('session_token', data.token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return user data without token
|
|
||||||
return NextResponse.json({
|
|
||||||
user: data.user,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Registration failed' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
|
|
||||||
{ headers }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
|
|
||||||
{ method: 'DELETE', headers }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
// Public build detail - no auth required
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/builds/${id}`,
|
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Failed to fetch build' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
// Public builds endpoint - no auth required
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/builds?${searchParams}`,
|
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Failed to fetch builds' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
async function getAuthHeader() {
|
|
||||||
const token = cookies().get('session_token')?.value;
|
|
||||||
if (!token) throw new Error('Unauthorized');
|
|
||||||
return { Authorization: `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/gunbuilder/builds?${searchParams}`,
|
|
||||||
{ headers }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const headers = await getAuthHeader();
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/gunbuilder/builds`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json(), { status: res.status });
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
// No auth required for public catalog
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/catalog/options?${searchParams}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Failed to fetch catalog' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
// No auth required for public catalog
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/products/${id}/offers`,
|
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Failed to fetch product offers' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: { id: string } }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
// No auth required for public catalog
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/products/${id}`,
|
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Failed to fetch product' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// No auth required for public catalog
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Failed to fetch products' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
|
|
||||||
// No auth required for public catalog
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/products?${searchParams}`,
|
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
cache: 'no-store',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: await res.text() },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(await res.json());
|
|
||||||
} catch (err: any) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: err?.message || 'Failed to fetch products' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { springProxy } from "@/lib/springProxy";
|
||||||
|
import type {
|
||||||
|
MerchantAdminResponse,
|
||||||
|
MerchantAdminUpdateRequest,
|
||||||
|
} from "@/types/merchant-admin";
|
||||||
|
|
||||||
|
export async function PUT(req: Request, props: { params: Promise<{ id: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const body = (await req.json()) as MerchantAdminUpdateRequest;
|
||||||
|
|
||||||
|
return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>(
|
||||||
|
req,
|
||||||
|
`/api/v1/admin/merchants/${params.id}`,
|
||||||
|
{ method: "PUT", body }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { springProxy } from "@/lib/springProxy";
|
||||||
|
import type {
|
||||||
|
MerchantAdminCreateRequest,
|
||||||
|
MerchantAdminResponse,
|
||||||
|
} from "@/types/merchant-admin";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
return springProxy<MerchantAdminResponse[]>(req, "/api/v1/admin/merchants");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = (await req.json()) as MerchantAdminCreateRequest;
|
||||||
|
|
||||||
|
return springProxy<MerchantAdminResponse, MerchantAdminCreateRequest>(
|
||||||
|
req,
|
||||||
|
"/api/v1/admin/merchants",
|
||||||
|
{ method: "POST", body }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||||
|
type Caliber = string;
|
||||||
|
|
||||||
|
type BuildCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
creator: string;
|
||||||
|
caliber: Caliber;
|
||||||
|
buildClass: BuildClass;
|
||||||
|
price: number;
|
||||||
|
votes: number;
|
||||||
|
tags: string[];
|
||||||
|
coverImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
||||||
|
|
||||||
|
const PRICE_FILTERS = [
|
||||||
|
{ id: "all", label: "All", min: 0, max: Infinity },
|
||||||
|
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
|
||||||
|
{ id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 },
|
||||||
|
{ id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 },
|
||||||
|
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
|
||||||
|
];
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
interface BuildsClientProps {
|
||||||
|
initialBuilds: BuildCard[];
|
||||||
|
initialError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side component that keeps all the previous interactivity:
|
||||||
|
* - filters
|
||||||
|
* - optimistic votes
|
||||||
|
*
|
||||||
|
* It receives the builds from the server but can optionally re-fetch on mount
|
||||||
|
* if you want truly live data (kept here as an example but can be removed).
|
||||||
|
*/
|
||||||
|
export default function BuildsClient({
|
||||||
|
initialBuilds,
|
||||||
|
initialError,
|
||||||
|
}: BuildsClientProps) {
|
||||||
|
// --- Remote data state (initialized from server) ---
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(initialError);
|
||||||
|
const [builds, setBuilds] = useState<BuildCard[]>(initialBuilds);
|
||||||
|
|
||||||
|
// --- UI state (filters/votes) ---
|
||||||
|
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
||||||
|
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
||||||
|
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
||||||
|
|
||||||
|
const [votes, setVotes] = useState<Record<string, number>>(() =>
|
||||||
|
Object.fromEntries(initialBuilds.map((b) => [b.id, b.votes])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activePriceFilter =
|
||||||
|
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
||||||
|
|
||||||
|
// Optional: client re-fetch to keep data fresh (can be removed if not needed)
|
||||||
|
useEffect(() => {
|
||||||
|
// If you don't want client refetching, just return early here.
|
||||||
|
if (!API_BASE_URL) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as BuildCard[];
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setBuilds(data);
|
||||||
|
setVotes(Object.fromEntries(data.map((b) => [b.id, b.votes])));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message || "Failed to load builds feed");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment this out if you want *only* server-side fetching:
|
||||||
|
// run();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
|
||||||
|
const uniq = new Set<string>();
|
||||||
|
for (const b of builds) {
|
||||||
|
const c = (b.caliber ?? "").trim();
|
||||||
|
if (c && c !== "—") uniq.add(c);
|
||||||
|
}
|
||||||
|
return ["all", ...Array.from(uniq).sort()];
|
||||||
|
}, [builds]);
|
||||||
|
|
||||||
|
const filteredBuilds = useMemo(() => {
|
||||||
|
return builds.filter((build) => {
|
||||||
|
const matchesCaliber =
|
||||||
|
caliberFilter === "all" || build.caliber === caliberFilter;
|
||||||
|
|
||||||
|
const matchesClass =
|
||||||
|
classFilter === "all" || build.buildClass === classFilter;
|
||||||
|
|
||||||
|
const matchesPrice =
|
||||||
|
build.price >= activePriceFilter.min &&
|
||||||
|
build.price < activePriceFilter.max;
|
||||||
|
|
||||||
|
return matchesCaliber && matchesClass && matchesPrice;
|
||||||
|
});
|
||||||
|
}, [builds, caliberFilter, classFilter, activePriceFilter]);
|
||||||
|
|
||||||
|
const handleVote = useCallback((id: string, delta: 1 | -1) => {
|
||||||
|
setVotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: (prev[id] ?? 0) + delta,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Loading / Error (uses same UI as before) */}
|
||||||
|
{loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
||||||
|
Loading community builds…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 text-xs text-red-200/80">
|
||||||
|
Dev tip: confirm backend route{" "}
|
||||||
|
<span className="font-mono">GET /api/v1/builds</span> is
|
||||||
|
implemented + CORS is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
||||||
|
Filter Builds
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Filter by caliber, class, and price range. (Client-side for MVP.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">
|
||||||
|
{filteredBuilds.length}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
||||||
|
builds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{/* Caliber filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Caliber
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CALIBER_FILTERS.map((caliber) => (
|
||||||
|
<button
|
||||||
|
key={caliber}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
caliberFilter === caliber
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{caliber === "all" ? "All" : caliber}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Class
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CLASS_FILTERS.map((cls) => (
|
||||||
|
<button
|
||||||
|
key={cls}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setClassFilter(cls === "all" ? "all" : cls)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
classFilter === cls
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cls === "all" ? "All" : cls}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Price Range
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRICE_FILTERS.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceFilterId(range.id)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
priceFilterId === range.id
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Build list */}
|
||||||
|
<section>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredBuilds.map((build) => (
|
||||||
|
<article
|
||||||
|
key={build.id}
|
||||||
|
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
||||||
|
>
|
||||||
|
{/* Votes column */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Upvote"
|
||||||
|
title="Upvote"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<div className="text-xs font-semibold text-amber-200">
|
||||||
|
{votes[build.id] ?? build.votes}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, -1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Downvote"
|
||||||
|
title="Downvote"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
build.coverImageUrl ||
|
||||||
|
"https://placehold.co/320x200/png?text=Build+Photo"
|
||||||
|
}
|
||||||
|
alt={`${build.title} cover`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
{build.buildClass} · {build.caliber}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
||||||
|
<Link
|
||||||
|
href={`/builds/${build.slug}`}
|
||||||
|
className="hover:text-amber-200 transition-colors"
|
||||||
|
>
|
||||||
|
{build.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Posted by{" "}
|
||||||
|
<span className="text-zinc-300">b/{build.creator}</span>{" "}
|
||||||
|
· Build detail page will show the parts list +
|
||||||
|
comments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right mt-1 md:mt-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Est. Build Cost
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-300">
|
||||||
|
{build.price > 0 ? (
|
||||||
|
<span>
|
||||||
|
{`$${Math.floor(build.price / 100).toLocaleString()}`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{build.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||||
|
Comments + save coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && !error && filteredBuilds.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
||||||
|
No builds match those filters yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
the page-use-client.tsx is the old client side page, this is a separation of the server side page and the client side
|
||||||
@@ -26,14 +26,14 @@ function timeAgo(iso?: string | null) {
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BuildBreakdownPage({
|
export default async function BuildBreakdownPage(
|
||||||
params,
|
props: {
|
||||||
}: {
|
params: Promise<{ buildId: string }>;
|
||||||
params: { buildId: string };
|
}
|
||||||
}) {
|
) {
|
||||||
// Public detail endpoint - use Next.js API route
|
const params = await props.params;
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
// Public detail endpoint
|
||||||
const res = await fetch(`${baseUrl}/api/builds/public/${params.buildId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +74,6 @@ export default async function BuildBreakdownPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Layout */}
|
{/* Layout */}
|
||||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
|
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
{/* Post column */}
|
{/* Post column */}
|
||||||
@@ -169,11 +168,11 @@ export default async function BuildBreakdownPage({
|
|||||||
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
||||||
{it.productImageUrl ? (
|
{it.productImageUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
(<img
|
||||||
src={it.productImageUrl}
|
src={it.productImageUrl}
|
||||||
alt={it.productName ?? "Part image"}
|
alt={it.productName ?? "Part image"}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
|
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
|
||||||
IMG
|
IMG
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app/builds/page.tsx
|
||||||
|
* Community Builds feed (public)
|
||||||
|
*
|
||||||
|
* Dev Notes:
|
||||||
|
* - We are moving to Option B:
|
||||||
|
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
|
||||||
|
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
|
||||||
|
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
|
||||||
|
*
|
||||||
|
* Backend (target contract):
|
||||||
|
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
|
||||||
|
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
|
||||||
|
* We can tighten once the controlled vocab is finalized.
|
||||||
|
*/
|
||||||
|
type Caliber = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This matches what the UI needs (card format).
|
||||||
|
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
|
||||||
|
*/
|
||||||
|
type BuildCard = {
|
||||||
|
id: string; // we use uuid here (public identifier)
|
||||||
|
title: string;
|
||||||
|
slug: string; // can be uuid for now; keep property so UI does not change
|
||||||
|
creator: string; // placeholder until user profiles are real
|
||||||
|
caliber: Caliber;
|
||||||
|
buildClass: BuildClass;
|
||||||
|
price: number; // optional server-side later; for now allow 0 fallback
|
||||||
|
votes: number;
|
||||||
|
tags: string[];
|
||||||
|
coverImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildFeedCardDto = {
|
||||||
|
uuid: string;
|
||||||
|
title?: string | null;
|
||||||
|
slug?: string | null; // optional (we can generate from title later)
|
||||||
|
creator?: string | null; // optional
|
||||||
|
caliber?: string | null;
|
||||||
|
buildClass?: BuildClass | null;
|
||||||
|
price?: number | null;
|
||||||
|
votes?: number | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
coverImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
// --- UI Filter Sets (keep; we’ll generate options from data too) ---
|
||||||
|
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
||||||
|
|
||||||
|
const PRICE_FILTERS = [
|
||||||
|
{ id: "all", label: "All", min: 0, max: Infinity },
|
||||||
|
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
|
||||||
|
{ id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 },
|
||||||
|
{ id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 },
|
||||||
|
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
|
||||||
|
];
|
||||||
|
|
||||||
|
function safeArray(v: unknown): string[] {
|
||||||
|
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackSlug(dto: BuildFeedCardDto) {
|
||||||
|
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
|
||||||
|
return dto.slug?.trim() || dto.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCard(dto: BuildFeedCardDto): BuildCard {
|
||||||
|
const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dto.uuid,
|
||||||
|
title,
|
||||||
|
slug: fallbackSlug(dto),
|
||||||
|
|
||||||
|
// Until we wire real users: keep a placeholder creator string.
|
||||||
|
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
||||||
|
|
||||||
|
caliber: (dto.caliber ?? "—").trim() || "—",
|
||||||
|
|
||||||
|
// Default to Rifle for display if missing; backend should set this via profile.
|
||||||
|
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
||||||
|
|
||||||
|
// Price is optional (we can compute later from BuildItems + offers).
|
||||||
|
price: typeof dto.price === "number" ? dto.price : 0,
|
||||||
|
|
||||||
|
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
||||||
|
|
||||||
|
tags: safeArray(dto.tags),
|
||||||
|
|
||||||
|
coverImageUrl: dto.coverImageUrl ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildsPage() {
|
||||||
|
// --- Remote data state ---
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [builds, setBuilds] = useState<BuildCard[]>([]);
|
||||||
|
|
||||||
|
// --- UI state (filters/votes) ---
|
||||||
|
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
||||||
|
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
||||||
|
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
||||||
|
|
||||||
|
// Local vote state for optimistic UI
|
||||||
|
const [votes, setVotes] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const activePriceFilter =
|
||||||
|
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
||||||
|
|
||||||
|
// --- Fetch public builds feed ---
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as BuildFeedCardDto[];
|
||||||
|
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setBuilds(cards);
|
||||||
|
|
||||||
|
// Initialize vote UI from payload totals
|
||||||
|
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message || "Failed to load builds feed");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build the caliber filters dynamically from data, but keep "all" first.
|
||||||
|
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
|
||||||
|
const uniq = new Set<string>();
|
||||||
|
for (const b of builds) {
|
||||||
|
const c = (b.caliber ?? "").trim();
|
||||||
|
if (c && c !== "—") uniq.add(c);
|
||||||
|
}
|
||||||
|
return ["all", ...Array.from(uniq).sort()];
|
||||||
|
}, [builds]);
|
||||||
|
|
||||||
|
const filteredBuilds = useMemo(() => {
|
||||||
|
return builds.filter((build) => {
|
||||||
|
const matchesCaliber =
|
||||||
|
caliberFilter === "all" || build.caliber === caliberFilter;
|
||||||
|
|
||||||
|
const matchesClass =
|
||||||
|
classFilter === "all" || build.buildClass === classFilter;
|
||||||
|
|
||||||
|
const matchesPrice =
|
||||||
|
build.price >= activePriceFilter.min &&
|
||||||
|
build.price < activePriceFilter.max;
|
||||||
|
|
||||||
|
return matchesCaliber && matchesClass && matchesPrice;
|
||||||
|
});
|
||||||
|
}, [builds, caliberFilter, classFilter, activePriceFilter]);
|
||||||
|
|
||||||
|
const handleVote = useCallback((id: string, delta: 1 | -1) => {
|
||||||
|
// MVP: optimistic local vote only.
|
||||||
|
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
|
||||||
|
setVotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: (prev[id] ?? 0) + delta,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builder · Build Book
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
|
||||||
|
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
|
||||||
|
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
|
||||||
|
>
|
||||||
|
Community Builds
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
|
||||||
|
Browse community builds, vote them up or down, and steal good
|
||||||
|
ideas without shame. This feed is backed by public builds
|
||||||
|
(isPublic=true).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 md:gap-3">
|
||||||
|
<Link
|
||||||
|
href="/builder"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/60 px-4 py-2 text-xs md:text-sm font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
Open Builder
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
|
||||||
|
<Link
|
||||||
|
href="/vault"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
|
>
|
||||||
|
+ Submit Build
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Loading / Error */}
|
||||||
|
{loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
||||||
|
Loading community builds…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 text-xs text-red-200/80">
|
||||||
|
Dev tip: confirm backend route{" "}
|
||||||
|
<span className="font-mono">GET /api/v1/builds</span> is
|
||||||
|
implemented + CORS is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
||||||
|
Filter Builds
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Filter by caliber, class, and price range. (Client-side for
|
||||||
|
MVP.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">
|
||||||
|
{filteredBuilds.length}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
||||||
|
builds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{/* Caliber filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Caliber
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CALIBER_FILTERS.map((caliber) => (
|
||||||
|
<button
|
||||||
|
key={caliber}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
caliberFilter === caliber
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{caliber === "all" ? "All" : caliber}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Class
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CLASS_FILTERS.map((cls) => (
|
||||||
|
<button
|
||||||
|
key={cls}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
classFilter === cls
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cls === "all" ? "All" : cls}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Price Range
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRICE_FILTERS.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceFilterId(range.id)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
priceFilterId === range.id
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Build list */}
|
||||||
|
<section>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredBuilds.map((build) => {
|
||||||
|
const dollars = Math.floor((build.price ?? 0) / 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={build.id}
|
||||||
|
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
||||||
|
>
|
||||||
|
{/* Votes column */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Upvote"
|
||||||
|
title="Upvote"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<div className="text-xs font-semibold text-amber-200">
|
||||||
|
{votes[build.id] ?? build.votes}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, -1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Downvote"
|
||||||
|
title="Downvote"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
build.coverImageUrl ||
|
||||||
|
"https://placehold.co/320x200/png?text=Build+Photo"
|
||||||
|
}
|
||||||
|
alt={`${build.title} cover`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
{build.buildClass} · {build.caliber}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
||||||
|
<Link
|
||||||
|
href={`/builds/${build.slug}`}
|
||||||
|
className="hover:text-amber-200 transition-colors"
|
||||||
|
>
|
||||||
|
{build.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Posted by{" "}
|
||||||
|
<span className="text-zinc-300">
|
||||||
|
b/{build.creator}
|
||||||
|
</span>{" "}
|
||||||
|
· Build detail page will show the parts list +
|
||||||
|
comments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right mt-1 md:mt-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Est. Build Cost
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-300">
|
||||||
|
{build.price > 0 ? (
|
||||||
|
<span>
|
||||||
|
${Math.floor(build.price / 100).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{build.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||||
|
Comments + save coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!loading && !error && filteredBuilds.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
||||||
|
No builds match those filters yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-382
@@ -1,43 +1,17 @@
|
|||||||
"use client";
|
// app/builds/page.tsx
|
||||||
|
|
||||||
/**
|
|
||||||
* app/builds/page.tsx
|
|
||||||
* Community Builds feed (public)
|
|
||||||
*
|
|
||||||
* Dev Notes:
|
|
||||||
* - We are moving to Option B:
|
|
||||||
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
|
|
||||||
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
|
|
||||||
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
|
|
||||||
*
|
|
||||||
* Backend (target contract):
|
|
||||||
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
|
|
||||||
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
|
|
||||||
* We can tighten once the controlled vocab is finalized.
|
|
||||||
*/
|
|
||||||
type Caliber = string;
|
type Caliber = string;
|
||||||
|
|
||||||
/**
|
|
||||||
* This matches what the UI needs (card format).
|
|
||||||
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
|
|
||||||
*/
|
|
||||||
type BuildCard = {
|
type BuildCard = {
|
||||||
id: string; // we use uuid here (public identifier)
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string; // can be uuid for now; keep property so UI does not change
|
slug: string;
|
||||||
creator: string; // placeholder until user profiles are real
|
creator: string;
|
||||||
caliber: Caliber;
|
caliber: Caliber;
|
||||||
buildClass: BuildClass;
|
buildClass: BuildClass;
|
||||||
price: number; // optional server-side later; for now allow 0 fallback
|
price: number;
|
||||||
votes: number;
|
votes: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
coverImageUrl?: string | null;
|
coverImageUrl?: string | null;
|
||||||
@@ -46,8 +20,8 @@ type BuildCard = {
|
|||||||
type BuildFeedCardDto = {
|
type BuildFeedCardDto = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
slug?: string | null; // optional (we can generate from title later)
|
slug?: string | null;
|
||||||
creator?: string | null; // optional
|
creator?: string | null;
|
||||||
caliber?: string | null;
|
caliber?: string | null;
|
||||||
buildClass?: BuildClass | null;
|
buildClass?: BuildClass | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
@@ -56,26 +30,13 @@ type BuildFeedCardDto = {
|
|||||||
coverImageUrl?: string | null;
|
coverImageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
|
||||||
|
|
||||||
// --- UI Filter Sets (keep; we’ll generate options from data too) ---
|
|
||||||
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
|
||||||
|
|
||||||
const PRICE_FILTERS = [
|
|
||||||
{ id: "all", label: "All", min: 0, max: Infinity },
|
|
||||||
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
|
|
||||||
{ id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 },
|
|
||||||
{ id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 },
|
|
||||||
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
|
|
||||||
];
|
|
||||||
|
|
||||||
function safeArray(v: unknown): string[] {
|
function safeArray(v: unknown): string[] {
|
||||||
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackSlug(dto: BuildFeedCardDto) {
|
function fallbackSlug(dto: BuildFeedCardDto) {
|
||||||
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
|
|
||||||
return dto.slug?.trim() || dto.uuid;
|
return dto.slug?.trim() || dto.uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,119 +47,41 @@ function normalizeCard(dto: BuildFeedCardDto): BuildCard {
|
|||||||
id: dto.uuid,
|
id: dto.uuid,
|
||||||
title,
|
title,
|
||||||
slug: fallbackSlug(dto),
|
slug: fallbackSlug(dto),
|
||||||
|
|
||||||
// Until we wire real users: keep a placeholder creator string.
|
|
||||||
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
||||||
|
|
||||||
caliber: (dto.caliber ?? "—").trim() || "—",
|
caliber: (dto.caliber ?? "—").trim() || "—",
|
||||||
|
|
||||||
// Default to Rifle for display if missing; backend should set this via profile.
|
|
||||||
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
||||||
|
|
||||||
// Price is optional (we can compute later from BuildItems + offers).
|
|
||||||
price: typeof dto.price === "number" ? dto.price : 0,
|
price: typeof dto.price === "number" ? dto.price : 0,
|
||||||
|
|
||||||
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
||||||
|
|
||||||
tags: safeArray(dto.tags),
|
tags: safeArray(dto.tags),
|
||||||
|
|
||||||
coverImageUrl: dto.coverImageUrl ?? null,
|
coverImageUrl: dto.coverImageUrl ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BuildsPage() {
|
// Import the client component that will handle filters & voting
|
||||||
// --- Remote data state ---
|
import BuildsClient from "./BuildsClient";
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [builds, setBuilds] = useState<BuildCard[]>([]);
|
|
||||||
|
|
||||||
// --- UI state (filters/votes) ---
|
export default async function BuildsPage() {
|
||||||
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
let cards: BuildCard[] = [];
|
||||||
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
let error: string | null = null;
|
||||||
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
|
||||||
|
|
||||||
// Local vote state for optimistic UI
|
try {
|
||||||
const [votes, setVotes] = useState<Record<string, number>>({});
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||||
|
method: "GET",
|
||||||
const activePriceFilter =
|
// Disable full-page static caching; you can adjust this per your needs:
|
||||||
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
cache: "no-store",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
// --- Fetch public builds feed ---
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const res = await fetch('/api/builds/public?limit=50', {
|
|
||||||
method: "GET",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text().catch(() => "");
|
|
||||||
throw new Error(txt || `Failed to load builds (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as BuildFeedCardDto[];
|
|
||||||
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
|
||||||
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
setBuilds(cards);
|
|
||||||
|
|
||||||
// Initialize vote UI from payload totals
|
|
||||||
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!cancelled) setError(e?.message || "Failed to load builds feed");
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Build the caliber filters dynamically from data, but keep "all" first.
|
|
||||||
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
|
|
||||||
const uniq = new Set<string>();
|
|
||||||
for (const b of builds) {
|
|
||||||
const c = (b.caliber ?? "").trim();
|
|
||||||
if (c && c !== "—") uniq.add(c);
|
|
||||||
}
|
|
||||||
return ["all", ...Array.from(uniq).sort()];
|
|
||||||
}, [builds]);
|
|
||||||
|
|
||||||
const filteredBuilds = useMemo(() => {
|
|
||||||
return builds.filter((build) => {
|
|
||||||
const matchesCaliber =
|
|
||||||
caliberFilter === "all" || build.caliber === caliberFilter;
|
|
||||||
|
|
||||||
const matchesClass =
|
|
||||||
classFilter === "all" || build.buildClass === classFilter;
|
|
||||||
|
|
||||||
const matchesPrice =
|
|
||||||
build.price >= activePriceFilter.min &&
|
|
||||||
build.price < activePriceFilter.max;
|
|
||||||
|
|
||||||
return matchesCaliber && matchesClass && matchesPrice;
|
|
||||||
});
|
});
|
||||||
}, [builds, caliberFilter, classFilter, activePriceFilter]);
|
|
||||||
|
|
||||||
const handleVote = useCallback((id: string, delta: 1 | -1) => {
|
if (!res.ok) {
|
||||||
// MVP: optimistic local vote only.
|
const txt = await res.text().catch(() => "");
|
||||||
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
|
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||||
setVotes((prev) => ({
|
}
|
||||||
...prev,
|
|
||||||
[id]: (prev[id] ?? 0) + delta,
|
const data = (await res.json()) as BuildFeedCardDto[];
|
||||||
}));
|
cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
||||||
}, []);
|
} catch (e: any) {
|
||||||
|
error = e?.message || "Failed to load builds feed";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
@@ -230,8 +113,6 @@ export default function BuildsPage() {
|
|||||||
>
|
>
|
||||||
Open Builder
|
Open Builder
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
|
|
||||||
<Link
|
<Link
|
||||||
href="/vault"
|
href="/vault"
|
||||||
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
@@ -241,240 +122,8 @@ export default function BuildsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Loading / Error */}
|
{/* Pass initial data + any load error into the client component */}
|
||||||
{loading && (
|
<BuildsClient initialBuilds={cards} initialError={error} />
|
||||||
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
|
||||||
Loading community builds…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && !loading && (
|
|
||||||
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
<div className="mt-2 text-xs text-red-200/80">
|
|
||||||
Dev tip: confirm backend route{" "}
|
|
||||||
<span className="font-mono">GET /api/v1/builds</span> is
|
|
||||||
implemented + CORS is configured.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
|
||||||
Filter Builds
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
|
||||||
Filter by caliber, class, and price range. (Client-side for
|
|
||||||
MVP.)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-zinc-500">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="text-zinc-200 font-medium">
|
|
||||||
{filteredBuilds.length}
|
|
||||||
</span>{" "}
|
|
||||||
of{" "}
|
|
||||||
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
|
||||||
builds
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{/* Caliber filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Caliber
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CALIBER_FILTERS.map((caliber) => (
|
|
||||||
<button
|
|
||||||
key={caliber}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
|
||||||
}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
caliberFilter === caliber
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{caliber === "all" ? "All" : caliber}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Class filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Class
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CLASS_FILTERS.map((cls) => (
|
|
||||||
<button
|
|
||||||
key={cls}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
classFilter === cls
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cls === "all" ? "All" : cls}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Price Range
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{PRICE_FILTERS.map((range) => (
|
|
||||||
<button
|
|
||||||
key={range.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPriceFilterId(range.id)}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
priceFilterId === range.id
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Build list */}
|
|
||||||
<section>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredBuilds.map((build) => {
|
|
||||||
const dollars = Math.floor((build.price ?? 0) / 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={build.id}
|
|
||||||
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
|
||||||
>
|
|
||||||
{/* Votes column */}
|
|
||||||
<div className="flex flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleVote(build.id, 1)}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
|
||||||
aria-label="Upvote"
|
|
||||||
title="Upvote"
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<div className="text-xs font-semibold text-amber-200">
|
|
||||||
{votes[build.id] ?? build.votes}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleVote(build.id, -1)}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
|
||||||
aria-label="Downvote"
|
|
||||||
title="Downvote"
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
build.coverImageUrl ||
|
|
||||||
"https://placehold.co/320x200/png?text=Build+Photo"
|
|
||||||
}
|
|
||||||
alt={`${build.title} cover`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
{build.buildClass} · {build.caliber}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
|
||||||
<Link
|
|
||||||
href={`/builds/${build.slug}`}
|
|
||||||
className="hover:text-amber-200 transition-colors"
|
|
||||||
>
|
|
||||||
{build.title}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
|
||||||
Posted by{" "}
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
b/{build.creator}
|
|
||||||
</span>{" "}
|
|
||||||
· Build detail page will show the parts list +
|
|
||||||
comments.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right mt-1 md:mt-0">
|
|
||||||
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
|
||||||
Est. Build Cost
|
|
||||||
</div>
|
|
||||||
<div className="text-lg font-semibold text-amber-300">
|
|
||||||
{build.price > 0 ? (
|
|
||||||
<span>
|
|
||||||
${Math.floor(build.price / 100).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
{build.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
|
||||||
Comments + save coming soon.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!loading && !error && filteredBuilds.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
|
||||||
No builds match those filters yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
|
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
|
||||||
import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
|
||||||
|
|
||||||
export type AdminNavItem = {
|
export type AdminNavItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -43,21 +42,14 @@ export default function AdminLeftNavigation({
|
|||||||
groups: AdminNavGroup[];
|
groups: AdminNavGroup[];
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname() || "/admin";
|
const pathname = usePathname() || "/admin";
|
||||||
const { user } = useAuth();
|
|
||||||
const identity =
|
|
||||||
user?.displayName?.trim() ||
|
|
||||||
user?.username?.trim() ||
|
|
||||||
user?.email?.trim() ||
|
|
||||||
user?.uuid ||
|
|
||||||
"Admin";
|
|
||||||
const secondary = user?.email && user.email !== identity ? user.email : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cx(
|
className={cx(
|
||||||
"fixed inset-y-0 left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40",
|
"fixed left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40",
|
||||||
collapsed ? "w-[68px]" : "w-[280px]"
|
collapsed ? "w-[68px]" : "w-[280px]"
|
||||||
)}
|
)}
|
||||||
|
style={{ top: '52px', height: 'calc(100vh - 52px)' }}
|
||||||
>
|
>
|
||||||
{/* Header / Brand */}
|
{/* Header / Brand */}
|
||||||
<div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}>
|
<div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}>
|
||||||
@@ -185,30 +177,20 @@ export default function AdminLeftNavigation({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className={cx("border-t border-zinc-900 p-3", collapsed && "px-2")}>
|
<div className={cx("border-t border-zinc-900 p-3", collapsed && "px-2")}>
|
||||||
<Link
|
|
||||||
href="/builder"
|
|
||||||
className={cx(
|
|
||||||
"mb-3 inline-flex w-full items-center justify-center rounded-md border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900/70",
|
|
||||||
collapsed && "px-2"
|
|
||||||
)}
|
|
||||||
title="Back to Builder"
|
|
||||||
>
|
|
||||||
{collapsed ? "↩" : "Back to Builder"}
|
|
||||||
</Link>
|
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex items-center gap-3 rounded-md bg-zinc-900/30 px-3 py-2 ring-1 ring-zinc-800",
|
"flex items-center gap-3 rounded-md bg-zinc-900/30 px-3 py-2 ring-1 ring-zinc-800",
|
||||||
collapsed && "justify-center px-2"
|
collapsed && "justify-center px-2"
|
||||||
)}
|
)}
|
||||||
title={secondary ?? identity}
|
title="Admin user"
|
||||||
>
|
>
|
||||||
<div className="h-8 w-8 rounded-full bg-zinc-900 ring-1 ring-zinc-800" />
|
<div className="h-8 w-8 rounded-full bg-zinc-900 ring-1 ring-zinc-800" />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm text-zinc-200">{identity}</div>
|
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
||||||
<div className="truncate text-xs text-zinc-500">
|
Internal
|
||||||
{secondary ?? "Authenticated user"}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="truncate text-sm text-zinc-200">ADMIN</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
|
|
||||||
export function Banner() {
|
export function Banner() {
|
||||||
const pathname = usePathname();
|
|
||||||
if (pathname?.startsWith("/admin")) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="fixed top-0 left-0 right-0 z-50">
|
||||||
{/* Early access bar */}
|
{/* Early access bar */}
|
||||||
<div className="border-b border-amber-500/20 bg-amber-500/5 backdrop-blur-sm">
|
<div className="border-b border-amber-500/20 bg-amber-500/5 backdrop-blur-sm">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
|
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export default function MailingAddressComponent() {
|
||||||
|
return <p>
|
||||||
|
<strong>Mailing Address:</strong>
|
||||||
|
<br/>
|
||||||
|
Battl Builder, LLC.
|
||||||
|
<br/>
|
||||||
|
[Your Address]
|
||||||
|
<br/>
|
||||||
|
[City, State, ZIP]
|
||||||
|
</p>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, useEffect, useRef, useState} from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, useEffect, useRef, useState} from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
import RichTextEditor from "@/components/ui/RichTextEditor";
|
import RichTextEditor from "@/components/ui/RichTextEditor";
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ type GunbuilderProductFromApi = {
|
|||||||
inStock?: boolean | null;
|
inStock?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// API routes now handled by Next.js /api routes (server-side)
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/catalog/options?${search.toString()}`,
|
`${API_BASE_URL}/api/v1/catalog/options?${search.toString()}`,
|
||||||
{
|
{
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,12 +50,9 @@ export default function PartsListPageClient(props: {
|
|||||||
|
|
||||||
// Your current list endpoint:
|
// Your current list endpoint:
|
||||||
// GET /api/v1/products?platform=AR-15&partRoles=upper-receiver
|
// GET /api/v1/products?platform=AR-15&partRoles=upper-receiver
|
||||||
const url = `/api/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
|
const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text().catch(() => "");
|
const txt = await res.text().catch(() => "");
|
||||||
throw new Error(`Failed to load products (${res.status}): ${txt}`);
|
throw new Error(`Failed to load products (${res.status}): ${txt}`);
|
||||||
|
|||||||
@@ -41,9 +41,8 @@ async function fetchProductDetail(params: {
|
|||||||
// ---- Preferred (if you add it): GET /api/v1/products/{id}
|
// ---- Preferred (if you add it): GET /api/v1/products/{id}
|
||||||
// If it 404s, we fall back.
|
// If it 404s, we fall back.
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/catalog/products/${encodeURIComponent(id)}`, {
|
const res = await fetch(`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}`, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
credentials: 'include',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -55,8 +54,8 @@ async function fetchProductDetail(params: {
|
|||||||
|
|
||||||
// ---- Fallback: use list endpoint + find by id (works right now with your current API)
|
// ---- Fallback: use list endpoint + find by id (works right now with your current API)
|
||||||
const listRes = await fetch(
|
const listRes = await fetch(
|
||||||
`/api/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
|
`${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
|
||||||
{ headers: { Accept: "application/json" }, credentials: 'include' }
|
{ headers: { Accept: "application/json" } }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!listRes.ok) {
|
if (!listRes.ok) {
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useMemo } from "react";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
import "react-quill/dist/quill.snow.css";
|
import "react-quill/dist/quill.snow.css";
|
||||||
|
|
||||||
|
// Import Quill modules after React Quill is loaded
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const Quill = require('quill');
|
||||||
|
const QuillImageResize = require('quill-image-resize-module').default;
|
||||||
|
Quill.register("modules/imageResize", QuillImageResize);
|
||||||
|
}
|
||||||
|
|
||||||
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
|
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
|
||||||
|
|
||||||
type RichTextEditorProps = {
|
type RichTextEditorProps = {
|
||||||
@@ -20,21 +26,6 @@ export default function RichTextEditor({
|
|||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
const modules = useMemo(
|
|
||||||
() => ({
|
|
||||||
toolbar: [
|
|
||||||
[{ header: [1, 2, 3, false] }],
|
|
||||||
["bold", "italic", "underline", "strike"],
|
|
||||||
[{ color: [] }, { background: [] }],
|
|
||||||
[{ list: "ordered" }, { list: "bullet" }],
|
|
||||||
["blockquote", "code-block"],
|
|
||||||
["link"],
|
|
||||||
["clean"],
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="bb-quill">
|
<div className="bb-quill">
|
||||||
@@ -43,7 +34,17 @@ export default function RichTextEditor({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
modules={modules}
|
modules={{
|
||||||
|
toolbar: [
|
||||||
|
[{ header: [1, 2, 3, false] }],
|
||||||
|
["bold", "italic", "underline", "strike"],
|
||||||
|
[{ color: [] }, { background: [] }],
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
|
["blockquote", "code-block"],
|
||||||
|
["link"],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, useEffect, useRef, useState} from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
import RichTextEditor from "@/components/ui/RichTextEditor";
|
import RichTextEditor from "@/components/ui/RichTextEditor";
|
||||||
|
|||||||
+128
-86
@@ -9,6 +9,9 @@ import {
|
|||||||
ReactNode,
|
ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type AuthUser = {
|
type AuthUser = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -26,137 +29,174 @@ export type RegisterParams = {
|
|||||||
tosVersion: string;
|
tosVersion: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 1) update the type
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
|
token: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (params: { email: string; password: string }) => Promise<void>;
|
login: (params: { email: string; password: string }) => Promise<void>;
|
||||||
register: (params: RegisterParams) => Promise<void>;
|
register: (params: RegisterParams) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshUser: () => Promise<void>;
|
|
||||||
getAuthHeaders: () => HeadersInit;
|
getAuthHeaders: () => HeadersInit;
|
||||||
|
setSession: (token: string, user: NonNullable<AuthUser>) => Promise<void>;
|
||||||
|
refreshMeAndSession: () => Promise<void>;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
const TOKEN_KEY = "ballistic_auth_token";
|
||||||
const USER_KEY = "ballistic_auth_user";
|
const USER_KEY = "ballistic_auth_user";
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [user, setUser] = useState<AuthUser>(null);
|
const [user, setUser] = useState<AuthUser>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist user to localStorage (for quick initial render)
|
* Persist auth to localStorage
|
||||||
*/
|
*/
|
||||||
const persistUser = useCallback((nextUser: AuthUser) => {
|
const persistAuth = useCallback(
|
||||||
if (typeof window === "undefined") return;
|
(nextToken: string | null, nextUser: AuthUser) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
if (nextUser) {
|
if (nextToken) {
|
||||||
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
|
window.localStorage.setItem(TOKEN_KEY, nextToken);
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(USER_KEY);
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
if (nextUser) {
|
||||||
|
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate from localStorage and refresh from server
|
* ✅ Hydrate from localStorage ONCE
|
||||||
|
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const storedToken = window.localStorage.getItem(TOKEN_KEY);
|
||||||
const storedUser = window.localStorage.getItem(USER_KEY);
|
const storedUser = window.localStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
// Quick hydration from cached user
|
if (storedToken && storedUser) {
|
||||||
if (storedUser) {
|
|
||||||
try {
|
try {
|
||||||
const parsedUser = JSON.parse(storedUser);
|
const parsedUser = JSON.parse(storedUser);
|
||||||
|
|
||||||
|
setToken(storedToken);
|
||||||
setUser(parsedUser);
|
setUser(parsedUser);
|
||||||
|
|
||||||
|
// ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav
|
||||||
|
fetch("/api/auth/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: storedToken,
|
||||||
|
role: parsedUser?.role,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
window.localStorage.removeItem(USER_KEY);
|
window.localStorage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify session with server
|
setLoading(false);
|
||||||
fetch("/api/auth/me", { credentials: 'include' })
|
}, []);
|
||||||
.then((res) => {
|
|
||||||
if (res.ok) {
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
throw new Error("Not authenticated");
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
const nextUser: AuthUser = {
|
|
||||||
uuid: data.uuid,
|
|
||||||
email: data.email,
|
|
||||||
username: data.username || null,
|
|
||||||
displayName: data.displayName || null,
|
|
||||||
role: data.role || "USER",
|
|
||||||
passwordSetAt: data.passwordSetAt || null,
|
|
||||||
};
|
|
||||||
setUser(nextUser);
|
|
||||||
persistUser(nextUser);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setUser(null);
|
|
||||||
persistUser(null);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [persistUser]);
|
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
/**
|
||||||
const res = await fetch("/api/auth/me", {
|
* Used by login/register/magic-link
|
||||||
|
*/
|
||||||
|
const setSession = useCallback(
|
||||||
|
async (nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||||
|
setToken(nextToken);
|
||||||
|
setUser(nextUser);
|
||||||
|
persistAuth(nextToken, nextUser);
|
||||||
|
|
||||||
|
// ✅ await so middleware sees cookie before navigation
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: nextToken,
|
||||||
|
role: nextUser.role,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[persistAuth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshMeAndSession = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
credentials: 'include',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => res.statusText);
|
const text = await res.text().catch(() => res.statusText);
|
||||||
throw new Error(text || "Failed to refresh user");
|
throw new Error(text || "Failed to refresh session");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const me = await res.json();
|
||||||
|
|
||||||
const nextUser: AuthUser = {
|
const nextUser = {
|
||||||
uuid: data.uuid,
|
uuid: me.uuid,
|
||||||
email: data.email,
|
email: me.email,
|
||||||
username: data.username || null,
|
username: me.username || null,
|
||||||
displayName: data.displayName || null,
|
displayName: me.displayName || null,
|
||||||
role: data.role || "USER",
|
role: me.role || "USER",
|
||||||
passwordSetAt: data.passwordSetAt || null,
|
passwordSetAt: me.passwordSetAt || null,
|
||||||
};
|
} as NonNullable<AuthUser>;
|
||||||
|
|
||||||
setUser(nextUser);
|
await setSession(token, nextUser);
|
||||||
persistUser(nextUser);
|
}, [token, setSession]);
|
||||||
}, [persistUser]);
|
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async ({ email, password }: { email: string; password: string }) => {
|
async ({ email, password }: { email: string; password: string }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/login", {
|
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({}));
|
const text = await res.text().catch(() => res.statusText);
|
||||||
throw new Error(errorData.error || "Login failed");
|
throw new Error(text || "Login failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const nextUser: AuthUser = data.user;
|
const nextToken: string = data.token ?? data.accessToken;
|
||||||
|
const nextUser: AuthUser =
|
||||||
|
data.user ??
|
||||||
|
({
|
||||||
|
uuid: data.uuid,
|
||||||
|
email: data.email ?? email,
|
||||||
|
displayName: data.displayName ?? null,
|
||||||
|
passwordSetAt: data.passwordSetAt ?? null,
|
||||||
|
role: data.role ?? "USER",
|
||||||
|
} as AuthUser);
|
||||||
|
|
||||||
setUser(nextUser);
|
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||||
persistUser(nextUser);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[persistUser]
|
[setSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
@@ -169,10 +209,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}: RegisterParams) => {
|
}: RegisterParams) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/register", {
|
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@@ -183,51 +222,54 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorData = await res.json().catch(() => ({}));
|
const text = await res.text().catch(() => res.statusText);
|
||||||
throw new Error(errorData.error || "Registration failed");
|
throw new Error(text || "Registration failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const nextUser: AuthUser = data.user;
|
const nextToken: string = data.token ?? data.accessToken;
|
||||||
|
const nextUser: AuthUser =
|
||||||
|
data.user ??
|
||||||
|
({
|
||||||
|
uuid: data.uuid,
|
||||||
|
email: data.email ?? email,
|
||||||
|
displayName: data.displayName ?? displayName ?? null,
|
||||||
|
passwordSetAt: data.passwordSetAt ?? null,
|
||||||
|
role: data.role ?? "USER",
|
||||||
|
} as AuthUser);
|
||||||
|
|
||||||
setUser(nextUser);
|
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||||
persistUser(nextUser);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[persistUser]
|
[setSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
persistUser(null);
|
persistAuth(null, null);
|
||||||
|
|
||||||
// Clear server session cookies
|
// Clear server session cookies
|
||||||
fetch("/api/auth/logout", {
|
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
|
||||||
method: "POST",
|
}, [persistAuth]);
|
||||||
credentials: 'include',
|
|
||||||
}).catch(() => {});
|
|
||||||
}, [persistUser]);
|
|
||||||
|
|
||||||
const getAuthHeaders = useCallback((): HeadersInit => {
|
const getAuthHeaders = useCallback((): HeadersInit => {
|
||||||
if (typeof window === "undefined") return {};
|
|
||||||
const token =
|
|
||||||
localStorage.getItem("token") ||
|
|
||||||
localStorage.getItem("jwt") ||
|
|
||||||
localStorage.getItem("accessToken");
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}, []);
|
}, [token]);
|
||||||
|
|
||||||
const value: AuthContextValue = {
|
const value: AuthContextValue = {
|
||||||
user,
|
user,
|
||||||
|
token,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
refreshUser,
|
|
||||||
getAuthHeaders,
|
getAuthHeaders,
|
||||||
|
setSession,
|
||||||
|
refreshMeAndSession
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
export default defineConfig([{
|
||||||
|
extends: [...nextCoreWebVitals],
|
||||||
|
}]);
|
||||||
+18
-8
@@ -1,23 +1,33 @@
|
|||||||
|
# ---------- deps ----------
|
||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --only=production
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---------- build ----------
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
RUN npm ci
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
# Build-time public env (baked into client bundle). You can override by rebuilding.
|
||||||
|
ARG NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------- runner ----------
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
# If you want slightly better security:
|
||||||
COPY --from=builder /app/.next ./.next
|
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
||||||
|
|
||||||
|
# Standalone output includes server + minimal node_modules
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["npm", "start"]
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
RUN apk add curl
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "start"]
|
||||||
+23
-17
@@ -1,57 +1,63 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
// API routes now handled by Next.js /api routes (server-side)
|
const API_BASE_URL =
|
||||||
// Authentication happens via HTTP-only cookies automatically
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type JsonValue = any;
|
type JsonValue = any;
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
|
function authHeaders(extra?: HeadersInit): HeadersInit {
|
||||||
|
const base: Record<string, string> = {};
|
||||||
|
if (token) base.Authorization = `Bearer ${token}`;
|
||||||
|
return { ...base, ...(extra as any) };
|
||||||
|
}
|
||||||
|
|
||||||
async function get(path: string, init?: RequestInit) {
|
async function get(path: string, init?: RequestInit) {
|
||||||
// Path should now point to Next.js API routes (e.g., /api/builds)
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
return fetch(path, {
|
|
||||||
...init,
|
...init,
|
||||||
credentials: 'include', // Include cookies in requests
|
headers: authHeaders(init?.headers),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post(path: string, body: JsonValue, init?: RequestInit) {
|
async function post(path: string, body: JsonValue, init?: RequestInit) {
|
||||||
return fetch(path, {
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
...init,
|
...init,
|
||||||
credentials: 'include',
|
headers: authHeaders({
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(init?.headers as any),
|
...(init?.headers as any),
|
||||||
},
|
}),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function put(path: string, body: JsonValue, init?: RequestInit) {
|
async function put(path: string, body: JsonValue, init?: RequestInit) {
|
||||||
return fetch(path, {
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
...init,
|
...init,
|
||||||
credentials: 'include',
|
headers: authHeaders({
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(init?.headers as any),
|
...(init?.headers as any),
|
||||||
},
|
}),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(path: string, init?: RequestInit) {
|
async function del(path: string, init?: RequestInit) {
|
||||||
return fetch(path, {
|
return fetch(`${API_BASE_URL}${path}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
...init,
|
...init,
|
||||||
credentials: 'include',
|
headers: authHeaders(init?.headers),
|
||||||
headers: init?.headers,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ stable reference unless token changes
|
||||||
return { get, post, put, del };
|
return { get, post, put, del };
|
||||||
}, []);
|
}, [token]);
|
||||||
}
|
}
|
||||||
+25
-25
@@ -17,29 +17,28 @@ export interface UpdateEmailRequestDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEmailRequests(): Promise<EmailRequest[]> {
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||||
const response = await fetch("/api/admin/email", { cache: "no-store" });
|
|
||||||
|
export async function fetchEmailRequests(token: string): Promise<EmailRequest[]> {
|
||||||
|
const response = await fetch(`${API_BASE}/api/email`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch email requests: ${response.statusText}`);
|
throw new Error(`Failed to fetch email requests: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as unknown;
|
return response.json();
|
||||||
if (Array.isArray(data)) return data as EmailRequest[];
|
|
||||||
|
|
||||||
const wrapped = data as { data?: unknown; content?: unknown };
|
|
||||||
if (Array.isArray(wrapped.data)) return wrapped.data as EmailRequest[];
|
|
||||||
if (Array.isArray(wrapped.content)) return wrapped.content as EmailRequest[];
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEmailRequest(
|
export async function createEmailRequest(token: string, data: CreateEmailRequestDto): Promise<EmailRequest> {
|
||||||
data: CreateEmailRequestDto
|
const response = await fetch(`${API_BASE}/api/email`, {
|
||||||
): Promise<EmailRequest> {
|
method: 'POST',
|
||||||
const response = await fetch("/api/admin/email", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@@ -51,14 +50,12 @@ export async function createEmailRequest(
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmailRequest(
|
export async function updateEmailRequest(token: string, id: number, data: UpdateEmailRequestDto): Promise<EmailRequest> {
|
||||||
id: number,
|
const response = await fetch(`${API_BASE}/api/email/${id}`, {
|
||||||
data: UpdateEmailRequestDto
|
method: 'PUT',
|
||||||
): Promise<EmailRequest> {
|
|
||||||
const response = await fetch(`/api/admin/email/${id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@@ -70,9 +67,12 @@ export async function updateEmailRequest(
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEmailRequest(id: number): Promise<void> {
|
export async function deleteEmailRequest(token: string, id: number): Promise<void> {
|
||||||
const response = await fetch(`/api/admin/email/${id}`, {
|
const response = await fetch(`${API_BASE}/api/email/${id}`, {
|
||||||
method: "DELETE",
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+4
-10
@@ -58,12 +58,9 @@ export async function fetchProducts(params: {
|
|||||||
|
|
||||||
if (sort) sp.set("sort", sort);
|
if (sort) sp.set("sort", sort);
|
||||||
|
|
||||||
const url = `/api/catalog/products?${sp.toString()}`;
|
const url = `${API_BASE_URL}/api/v1/products?${sp.toString()}`;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
cache: "no-store",
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
||||||
const data = (await res.json()) as ProductListItem[];
|
const data = (await res.json()) as ProductListItem[];
|
||||||
|
|
||||||
@@ -86,13 +83,10 @@ export async function fetchProductById(params: {
|
|||||||
const { id, platform } = params;
|
const { id, platform } = params;
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
`/api/catalog/products/${encodeURIComponent(id)}` +
|
`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}` +
|
||||||
(platform ? `?${new URLSearchParams({ platform })}` : "");
|
(platform ? `?${new URLSearchParams({ platform })}` : "");
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
cache: "no-store",
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Failed to load product (${res.status})`);
|
if (!res.ok) throw new Error(`Failed to load product (${res.status})`);
|
||||||
return (await res.json()) as ProductDetail;
|
return (await res.json()) as ProductDetail;
|
||||||
}
|
}
|
||||||
+54
-25
@@ -2,42 +2,71 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const LAUNCH_ONLY_ROOT = process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT === "true";
|
||||||
|
|
||||||
|
// ✅ Forever allow list (always reachable even during launch-only mode)
|
||||||
|
const ALWAYS_ALLOW = new Set<string>([
|
||||||
|
"/",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/robots.txt",
|
||||||
|
"/sitemap.xml",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PUBLIC_FILE =
|
||||||
|
/\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
const token = req.cookies.get("session_token")?.value;
|
|
||||||
|
|
||||||
// Protect admin API routes
|
console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT);
|
||||||
if (pathname.startsWith("/api/admin")) {
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protect builds API routes
|
|
||||||
if (pathname.startsWith("/api/builds")) {
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Protect admin pages
|
// =========================
|
||||||
if (pathname.startsWith("/admin")) {
|
// 1) Admin gate (always on)
|
||||||
if (!token) {
|
// =========================
|
||||||
const url = req.nextUrl.clone();
|
if (pathname.startsWith("/admin")) {
|
||||||
url.pathname = "/login";
|
const token = req.cookies.get("bb_access_token")?.value;
|
||||||
url.searchParams.set("next", pathname);
|
const role = req.cookies.get("bb_role")?.value; // optional
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
const ok = !!token && (!role || role === "ADMIN");
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.searchParams.set("next", pathname);
|
||||||
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================
|
||||||
|
// 2) Launch-only gate (your existing one)
|
||||||
|
// =====================================
|
||||||
|
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
|
||||||
|
|
||||||
|
// ✅ Always allow Next internals + static assets
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/_next") ||
|
||||||
|
pathname.startsWith("/favicon") ||
|
||||||
|
pathname === "/robots.txt" ||
|
||||||
|
pathname === "/sitemap.xml" ||
|
||||||
|
PUBLIC_FILE.test(pathname)
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
|
|
||||||
|
return NextResponse.rewrite(new URL("/404", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
` 1`
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/api/admin/:path*",
|
// run on everything except Next internals + obvious static files
|
||||||
"/api/builds",
|
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)",
|
||||||
"/api/builds/:path+",
|
|
||||||
"/admin/:path*",
|
|
||||||
],
|
],
|
||||||
|
|
||||||
};
|
};
|
||||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
+10
-8
@@ -3,6 +3,7 @@ const nextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
appDir: true,
|
appDir: true,
|
||||||
},
|
},
|
||||||
|
output: "standalone",
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
@@ -10,14 +11,15 @@ const nextConfig = {
|
|||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
/* async rewrites() {
|
// If you want rewrites, uncomment this block:
|
||||||
return [
|
// async rewrites() {
|
||||||
{
|
// return [
|
||||||
source: "/api/:path*",
|
// {
|
||||||
destination: "http://battlbuilder-api:8080/api/:path*",
|
// source: "/api/:path*",
|
||||||
},
|
// destination: "http://battlbuilder-api:8080/api/:path*",
|
||||||
];
|
// },
|
||||||
},*/
|
// ];
|
||||||
|
// },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
Generated
+863
-691
File diff suppressed because it is too large
Load Diff
+31
-11
@@ -1,34 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "gunbuilder-prototype",
|
"name": "battle-builder-webui",
|
||||||
"version": "0.1.0",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"author": {
|
||||||
|
"name": "Forward Group, LLC",
|
||||||
|
"email": "info@goforward.group",
|
||||||
|
"url": "https://goforward.group"
|
||||||
|
},
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "Sean Strawsburg",
|
||||||
|
"email": "sean@goforward.group"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Don Strawsburg",
|
||||||
|
"email": "don@goforward.group"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "14.2.3",
|
"next": "15.5.9",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"quill-image-resize-module": "^3.0.0",
|
"quill-image-resize-module": "^3.0.0",
|
||||||
"react": "18.3.1",
|
"react": "19.2.3",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.2.3",
|
||||||
"react-quill": "^2.0.0"
|
"react-quill": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "20.12.7",
|
"@types/node": "20.12.7",
|
||||||
"@types/react": "18.3.3",
|
"@types/react": "19.2.9",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "19.2.3",
|
||||||
"autoprefixer": "10.4.19",
|
"autoprefixer": "10.4.19",
|
||||||
"eslint": "8.57.0",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint-config-next": "15.5.9",
|
||||||
"postcss": "8.4.38",
|
"postcss": "8.4.38",
|
||||||
"tailwindcss": "3.4.3",
|
"tailwindcss": "3.4.3",
|
||||||
"typescript": "5.4.5"
|
"typescript": "5.4.5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "19.2.9",
|
||||||
|
"@types/react-dom": "19.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
REGISTRY="gitea.gofwd.group"
|
|
||||||
OWNER="sean/shadow-gunbuilder-ai-proto"
|
|
||||||
IMAGE="webui"
|
|
||||||
|
|
||||||
TAG=$(git rev-parse --short HEAD)
|
|
||||||
|
|
||||||
FULL_IMAGE="$REGISTRY/$OWNER/$IMAGE"
|
|
||||||
|
|
||||||
echo "Building $FULL_IMAGE:$TAG"
|
|
||||||
docker build -f frontend/Dockerfile --no-cache -t $FULL_IMAGE:$TAG .
|
|
||||||
|
|
||||||
echo "Tagging latest"
|
|
||||||
docker tag $FULL_IMAGE:$TAG $FULL_IMAGE:latest
|
|
||||||
|
|
||||||
echo "Pushing $TAG"
|
|
||||||
docker push $FULL_IMAGE:$TAG
|
|
||||||
|
|
||||||
echo "Pushing latest"
|
|
||||||
docker push $FULL_IMAGE:latest
|
|
||||||
|
|
||||||
echo "Done!"
|
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="gitea.gofwd.group"
|
||||||
|
OWNER="sean/shadow-gunbuilder-ai-proto"
|
||||||
|
IMAGE="webui"
|
||||||
|
|
||||||
|
TAG="$(git rev-parse --short HEAD)"
|
||||||
|
FULL_IMAGE="$REGISTRY/$OWNER/$IMAGE"
|
||||||
|
|
||||||
|
EXTRA_ARGS=()
|
||||||
|
if [[ "${NO_CACHE:-0}" == "1" ]]; then
|
||||||
|
EXTRA_ARGS+=(--no-cache)
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx build \
|
||||||
|
-f frontend/Dockerfile \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t "$FULL_IMAGE:$TAG" \
|
||||||
|
-t "$FULL_IMAGE:latest" \
|
||||||
|
--push \
|
||||||
|
"${EXTRA_ARGS[@]}" \
|
||||||
|
.
|
||||||
Reference in New Issue
Block a user