6 Commits

Author SHA1 Message Date
sean 77c31ae4f9 fixed all admin email pages. added admin user info and button on builder page to route to admin page.
CI / test (push) Successful in 5s
2026-01-26 14:58:56 -05:00
sean fb2effca47 productSlug fixes
CI / test (push) Successful in 7s
2026-01-26 07:10:37 -05:00
sean b09ccc542e fixed login session for user name, and updated the api calls on the product detials pages.
CI / test (push) Successful in 6s
2026-01-25 20:20:48 -05:00
sean 3aee0e6755 fixing admin pages
CI / test (push) Successful in 6s
2026-01-25 14:22:58 -05:00
sean e49dc96522 clean up
CI / test (push) Successful in 5s
2026-01-25 14:05:36 -05:00
sean 50ef395a38 woof. killed useClient and use nextjs api
CI / test (push) Successful in 5s
2026-01-25 13:40:10 -05:00
56 changed files with 1691 additions and 531 deletions
+112 -59
View File
@@ -74,8 +74,7 @@ const isUuid = (v: string) =>
v v
); );
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const STORAGE_KEY = "gunbuilder-build-state"; const STORAGE_KEY = "gunbuilder-build-state";
@@ -296,7 +295,7 @@ function buildAssemblyRows(params: {
rows.push({ rows.push({
key: `${groupLabel}-${paths.complete}`, key: `${groupLabel}-${paths.complete}`,
kind: "category", kind: "category",
label: "Complete Assembly (Fastest)", label: "Completed Lower (Fastest)",
categoryId: paths.complete, categoryId: paths.complete,
indent: 0, indent: 0,
pill: mode === "complete" ? "Selected" : undefined, pill: mode === "complete" ? "Selected" : undefined,
@@ -337,8 +336,8 @@ function GunbuilderPageContent() {
const api = useApi(); const api = useApi();
// ✅ Auth: we need the token ready before calling /builds/me/* // ✅ Auth: check if user is logged in
const { token, loading: authLoading, getAuthHeaders } = useAuth(); const { user, loading: authLoading } = useAuth();
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15"); const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15");
@@ -616,7 +615,7 @@ function GunbuilderPageContent() {
try { try {
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/products/by-ids`, `/api/catalog/products/by-ids`,
{ {
method: "POST", method: "POST",
signal: controller.signal, signal: controller.signal,
@@ -728,7 +727,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 (!token) { if (!user) {
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;
@@ -738,14 +737,12 @@ function GunbuilderPageContent() {
try { try {
setShareStatus("Loading build…"); setShareStatus("Loading build…");
// NOTE: using fetch here avoids any “stale api instance” issues and lets us // NOTE: using fetch here goes through our Next.js API routes with HTTP-only cookies
// explicitly attach JWT headers.
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`, `/api/builds/${encodeURIComponent(uuidToLoad)}`,
{ {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...getAuthHeaders(),
}, },
} }
); );
@@ -787,9 +784,7 @@ function GunbuilderPageContent() {
searchParams, searchParams,
router, router,
authLoading, authLoading,
token, user,
getAuthHeaders,
// API_BASE_URL is a module constant; no need to include
]); ]);
// ----------------------------- // -----------------------------
@@ -1200,53 +1195,64 @@ 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> <div className="flex flex-wrap items-start justify-between gap-3">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500"> <div>
BATTL BUILDERS <p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
</p> BATTL BUILDERS
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight"> </p>
Builder <span className="text-amber-300">Early Access</span> <h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
</h1> Builder <span className="text-amber-300">Early Access</span>
<p className="mt-2 text-sm text-zinc-400 max-w-xl"> </h1>
Explore components from trusted brands, choose one part per <p className="mt-2 text-sm text-zinc-400 max-w-xl">
category, track live prices, and watch your total build cost Explore components from trusted brands, choose one part per
update as you go. This early-access builder keeps your setup saved category, track live prices, and watch your total build cost
locally so you can come back and refine it anytime. update as you go. This early-access builder keeps your setup saved
</p> locally so you can come back and refine it anytime.
</p>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3"> {user?.role === "ADMIN" ? (
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2"> <Link
Platform href="/admin"
<select 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"
value={platform} >
onChange={(e) => { Admin Toolbar
const next = e.target.value as (typeof PLATFORMS)[number]; </Link>
setPlatform(next); ) : null}
</div>
// Keep URL in sync, and clear transient action params <div className="mt-4 flex flex-wrap items-center gap-3">
const qp = new URLSearchParams(searchParams.toString()); <label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
qp.set("platform", normalizePlatformKey(next)); Platform
qp.delete("select"); <select
qp.delete("remove"); value={platform}
onChange={(e) => {
const next = e.target.value as (typeof PLATFORMS)[number];
setPlatform(next);
router.replace(`/builder?${qp.toString()}`, { // Keep URL in sync, and clear transient action params
scroll: false, const qp = new URLSearchParams(searchParams.toString());
}); qp.set("platform", normalizePlatformKey(next));
}} qp.delete("select");
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" qp.delete("remove");
>
{PLATFORMS.map((p) => ( router.replace(`/builder?${qp.toString()}`, {
<option key={p} value={p}> scroll: false,
{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> >
</label> {PLATFORMS.map((p) => (
<p className="text-[0.7rem] text-zinc-500"> <option key={p} value={p}>
{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 */}
@@ -1475,6 +1481,11 @@ 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}`}
@@ -1494,6 +1505,23 @@ 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>
@@ -1608,9 +1636,15 @@ function GunbuilderPageContent() {
</div> </div>
{selectedPart ? ( {selectedPart ? (
<div className="text-[0.7rem] text-zinc-500 line-clamp-1"> <Link
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}
</div> </Link>
) : ( ) : (
<div className="text-[0.7rem] text-zinc-600"> <div className="text-[0.7rem] text-zinc-600">
No part selected No part selected
@@ -1640,8 +1674,27 @@ 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="text-[0.7rem] text-zinc-500"> <div className="flex items-center gap-2">
<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,6 +18,7 @@ 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;
}; };
@@ -29,6 +30,7 @@ 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;
@@ -37,6 +39,7 @@ 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;
@@ -238,14 +241,23 @@ export default function ProductDetailsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/v1/products/${numericId}`; const url = `/api/catalog/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, {
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;
@@ -272,14 +284,18 @@ export default function ProductDetailsPage() {
try { try {
setOffersLoading(true); setOffersLoading(true);
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`; const url = `/api/catalog/products/${numericId}/offers`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, {
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;
@@ -330,14 +346,23 @@ 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) return sortOffers(offersFromApi); if (offersFromApi.length > 0) {
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) { if (product?.buyUrl || product?.buyShortUrl) {
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.buyUrl, buyUrl: product.buyShortUrl || product.buyUrl,
inStock: product.inStock ?? true, inStock: product.inStock ?? true,
}, },
]); ]);
@@ -559,6 +584,14 @@ 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">
@@ -660,9 +693,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.buyUrl ? ( {(o.buyShortUrl || o.buyUrl) ? (
<a <a
href={o.buyUrl} href={o.buyShortUrl || 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"
@@ -686,10 +719,10 @@ export default function ProductDetailsPage() {
)} )}
{/* Best Offer CTA */} {/* Best Offer CTA */}
{bestOffer?.buyUrl ? ( {(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<a <a
href={bestOffer.buyUrl} href={bestOffer.buyShortUrl || 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"
@@ -747,4 +780,4 @@ export default function ProductDetailsPage() {
</div> </div>
</main> </main>
); );
} }
+8 -9
View File
@@ -18,8 +18,7 @@ type BuildDto = {
updatedAt?: string | null; updatedAt?: string | null;
}; };
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// UUID validator (defensive) // UUID validator (defensive)
const isUuid = (v: string) => const isUuid = (v: string) =>
@@ -40,14 +39,14 @@ export default function VaultPage() {
// NOTE: // NOTE:
// - `loading` here is AuthContext hydration, not network. // - `loading` here is AuthContext hydration, not network.
// - `token` is the real gate for /me endpoints. // - Auth is handled by HTTP-only cookies automatically.
const { user, token, loading: authLoading } = useAuth(); const { user, 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 && !!token; const authed = !!user;
// Redirect if not logged in (after auth hydrates) // Redirect if not logged in (after auth hydrates)
useEffect(() => { useEffect(() => {
@@ -56,12 +55,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, token]); }, [authLoading, user?.uuid]);
// Fetch user builds // Fetch user builds
useEffect(() => { useEffect(() => {
@@ -75,8 +74,8 @@ export default function VaultPage() {
try { try {
// IMPORTANT: // IMPORTANT:
// Use api wrapper so Authorization: Bearer <token> is consistently applied. // Use api wrapper which includes credentials (cookies) automatically.
const res = await api.get("/api/v1/builds/me"); const res = await api.get("/api/builds");
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
+11 -41
View File
@@ -1,11 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, 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;
@@ -40,17 +36,12 @@ type AdminInviteResponse = {
token?: string; token?: string;
}; };
async function fetchJson( async function fetchJson(path: string, init: RequestInit = {}) {
path: string, const res = await fetch(path, {
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",
}); });
@@ -101,9 +92,6 @@ function Badge({
} }
export default function AdminBetaInvitesPage() { export default function AdminBetaInvitesPage() {
const { getAuthHeaders, user } = useAuth();
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
// ------------------------------ // ------------------------------
// Batch invites // Batch invites
// ------------------------------ // ------------------------------
@@ -126,11 +114,9 @@ export default function AdminBetaInvitesPage() {
tokenMinutes: String(tokenMinutes || "30"), tokenMinutes: String(tokenMinutes || "30"),
}); });
const data = await fetchJson( const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
`/api/v1/admin/beta/invites/send?${qs.toString()}`, method: "POST",
{ method: "POST" }, });
authHeaders
);
setBatchResult(data); setBatchResult(data);
await loadRequests(); await loadRequests();
@@ -166,9 +152,8 @@ export default function AdminBetaInvitesPage() {
try { try {
const data = (await fetchJson( const data = (await fetchJson(
`/api/v1/admin/beta/requests?page=${page}&size=${size}`, `/api/admin/beta/requests?page=${page}&size=${size}`,
{ method: "GET" }, { method: "GET" }
authHeaders
)) as PageResponse<AdminBetaRequestDto>; )) as PageResponse<AdminBetaRequestDto>;
setRequests(data); setRequests(data);
@@ -181,11 +166,9 @@ 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( return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
`/api/v1/admin/beta/requests/${userId}/invite`, method: "POST",
{ method: "POST" }, })) as AdminInviteResponse;
authHeaders
)) as AdminInviteResponse;
} }
async function onSendInviteEmail(userId: number) { async function onSendInviteEmail(userId: number) {
@@ -241,19 +224,6 @@ 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 dont have access to this page.
</p>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
+5 -20
View File
@@ -1,7 +1,6 @@
"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,
@@ -17,7 +16,6 @@ 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);
@@ -33,11 +31,9 @@ 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(token); const data = await fetchEmailRequests();
setEmails(data); setEmails(data);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
@@ -46,7 +42,7 @@ export default function AdminEmailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]); }, []);
useEffect(() => { useEffect(() => {
loadEmails(); loadEmails();
@@ -108,11 +104,10 @@ 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(token, id); await deleteEmailRequest(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");
@@ -121,17 +116,15 @@ 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(token, formData); await createEmailRequest(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(token, selectedEmail.id, updateData); await updateEmailRequest(selectedEmail.id, updateData);
} }
setShowModal(false); setShowModal(false);
await loadEmails(); await loadEmails();
@@ -142,14 +135,6 @@ 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 */}
+5 -20
View File
@@ -1,7 +1,6 @@
"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,
@@ -14,7 +13,6 @@ 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);
@@ -28,11 +26,9 @@ 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(token); const data = await fetchEmailRequests();
setEmails(data); setEmails(data);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
@@ -41,7 +37,7 @@ export default function AdminEmailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]); }, []);
useEffect(() => { useEffect(() => {
loadEmails(); loadEmails();
@@ -62,11 +58,10 @@ 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(token, id); await deleteEmailRequest(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");
@@ -75,17 +70,15 @@ 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(token, formData); await createEmailRequest(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(token, selectedEmail.id, updateData); await updateEmailRequest(selectedEmail.id, updateData);
} }
setShowModal(false); setShowModal(false);
await loadEmails(); await loadEmails();
@@ -96,14 +89,6 @@ 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 */}
+4 -3
View File
@@ -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_BASE_URL}/api/admin/import-status/summary`), fetch('/api/admin/import-status/summary', { credentials: 'include' }),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`), fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
]); ]);
if (!summaryRes.ok || !byMerchantRes.ok) { if (!summaryRes.ok || !byMerchantRes.ok) {
@@ -108,9 +108,10 @@ export default function ImportStatusPage() {
try { try {
setImportingId(merchantId); setImportingId(merchantId);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`, `/api/admin/merchants/${merchantId}/import`,
{ {
method: "POST", method: "POST",
credentials: 'include',
} }
); );
+4 -7
View File
@@ -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="min-h-screen bg-black text-zinc-50 overflow-x-hidden pt-[52px]"> <div className="h-screen bg-black text-zinc-50 overflow-hidden">
<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 min-h-screen flex-1 min-w-0 flex-col transition-all duration-300" className="flex h-full 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,17 +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 px-4 py-6"> <main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
{children} {children}
</main> </main>
</div> </div>
</div> </div>
); );
} }
+10 -6
View File
@@ -198,8 +198,9 @@ 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_BASE_URL}/api/admin/mapping/options`, { const res = await fetch('/api/admin/mapping/options', {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store", cache: "no-store",
}); });
@@ -243,8 +244,9 @@ 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_BASE_URL}/api/admin/mapping/pending-buckets`, { const res = await fetch('/api/admin/mapping/pending-buckets', {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store", cache: "no-store",
}); });
@@ -277,8 +279,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_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`, `/api/admin/mapping/raw-categories?${params.toString()}`,
{ headers: { Accept: "application/json" }, cache: "no-store" } { headers: { Accept: "application/json" }, credentials: 'include', cache: "no-store" }
); );
if (!res.ok) { if (!res.ok) {
@@ -336,9 +338,10 @@ 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_BASE_URL}/api/admin/mapping/apply`, { const res = await fetch('/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,
@@ -390,9 +393,10 @@ 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_BASE_URL}/api/admin/mapping/upsert`, { const res = await fetch('/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,
+6 -7
View File
@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type MerchantAdminDto = { type MerchantAdminDto = {
id: number; id: number;
@@ -91,7 +90,7 @@ export default function MerchantsAdminPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/admin/merchants`; const 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, {
@@ -150,7 +149,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, { const res = await fetch(`/api/admin/merchants/${id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -189,7 +188,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, { const res = await fetch(`/api/admin/merchants/${id}/import`, {
method: "POST", method: "POST",
}); });
@@ -218,7 +217,7 @@ export default function MerchantsAdminPage() {
setBanner(null); setBanner(null);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`, `/api/admin/merchants/${id}/offer-sync`,
{ method: "POST" } { method: "POST" }
); );
@@ -247,7 +246,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, { const res = await fetch(`/api/admin/merchants`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
+3 -1
View File
@@ -24,7 +24,9 @@ export default function AdminLandingPage() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`); const res = await fetch('/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})`);
} }
+2 -2
View File
@@ -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("bb_access_token")?.value; const token = cookies().get("session_token")?.value;
if (!token) { if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 }); return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
@@ -32,4 +32,4 @@ export async function POST(req: Request) {
status: res.status, status: res.status,
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" }, headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
}); });
} }
@@ -0,0 +1,39 @@
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 });
}
}
+39
View File
@@ -0,0 +1,39 @@
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 });
}
}
+35
View File
@@ -0,0 +1,35 @@
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 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
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 });
}
}
+56
View File
@@ -0,0 +1,56 @@
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 });
}
}
@@ -0,0 +1,35 @@
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 }
);
}
}
@@ -0,0 +1,35 @@
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 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
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 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
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 }
);
}
}
@@ -0,0 +1,35 @@
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 }
);
}
}
@@ -0,0 +1,39 @@
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 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
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 }
);
}
}
@@ -0,0 +1,35 @@
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 });
}
}
@@ -0,0 +1,35 @@
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 });
}
}
+90
View File
@@ -0,0 +1,90 @@
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 });
}
}
+54
View File
@@ -0,0 +1,54 @@
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 });
}
}
+46
View File
@@ -0,0 +1,46 @@
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 }
);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST() {
cookies().delete('session_token');
return NextResponse.json({ success: true });
}
+30
View File
@@ -0,0 +1,30 @@
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 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
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 }
);
}
}
+90
View File
@@ -0,0 +1,90 @@
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 });
}
}
+35
View File
@@ -0,0 +1,35 @@
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 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
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 }
);
}
}
+57
View File
@@ -0,0 +1,57 @@
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 });
}
}
+28
View File
@@ -0,0 +1,28 @@
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 }
);
}
}
@@ -0,0 +1,35 @@
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 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
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 }
);
}
}
+33
View File
@@ -0,0 +1,33 @@
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 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
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 }
);
}
}
-18
View File
@@ -1,18 +0,0 @@
import { springProxy } from "@/lib/springProxy";
import type {
MerchantAdminResponse,
MerchantAdminUpdateRequest,
} from "@/types/merchant-admin";
export async function PUT(
req: Request,
{ params }: { params: { id: string } }
) {
const body = (await req.json()) as MerchantAdminUpdateRequest;
return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>(
req,
`/api/v1/admin/merchants/${params.id}`,
{ method: "PUT", body }
);
}
-19
View File
@@ -1,19 +0,0 @@
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 }
);
}
+3 -2
View File
@@ -31,8 +31,9 @@ export default async function BuildBreakdownPage({
}: { }: {
params: { buildId: string }; params: { buildId: string };
}) { }) {
// Public detail endpoint // Public detail endpoint - use Next.js API route
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, { const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/builds/public/${params.buildId}`, {
cache: "no-store", cache: "no-store",
}); });
+1 -1
View File
@@ -132,7 +132,7 @@ export default function BuildsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, { const res = await fetch('/api/builds/public?limit=50', {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
+25 -7
View File
@@ -5,6 +5,7 @@ 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;
@@ -42,14 +43,21 @@ 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 left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40", "fixed inset-y-0 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")}>
@@ -177,24 +185,34 @@ 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="Admin user" title={secondary ?? identity}
> >
<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="text-xs uppercase tracking-[0.18em] text-zinc-500"> <div className="truncate text-sm text-zinc-200">{identity}</div>
Internal <div className="truncate text-xs text-zinc-500">
{secondary ?? "Authenticated user"}
</div> </div>
<div className="truncate text-sm text-zinc-200">ADMIN</div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</aside> </aside>
); );
} }
+7 -1
View File
@@ -1,8 +1,14 @@
"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="fixed top-0 left-0 right-0 z-50"> <div className="w-full">
{/* 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">
+2 -3
View File
@@ -58,8 +58,7 @@ type GunbuilderProductFromApi = {
inStock?: boolean | null; inStock?: boolean | null;
}; };
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const PAGE_SIZE = 24; const PAGE_SIZE = 24;
@@ -175,7 +174,7 @@ export default function PartsBrowseClient(props: {
} }
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/options?${search.toString()}`, `/api/catalog/options?${search.toString()}`,
{ {
signal: controller.signal, signal: controller.signal,
} }
+5 -2
View File
@@ -50,9 +50,12 @@ 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_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`; const url = `/api/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
const res = await fetch(url, { headers: { Accept: "application/json" } }); const res = await fetch(url, {
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}`);
+4 -3
View File
@@ -41,8 +41,9 @@ 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_BASE_URL}/api/v1/products/${encodeURIComponent(id)}`, { const res = await fetch(`/api/catalog/products/${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: 'include',
}); });
if (res.ok) { if (res.ok) {
@@ -54,8 +55,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_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`, `/api/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
{ headers: { Accept: "application/json" } } { headers: { Accept: "application/json" }, credentials: 'include' }
); );
if (!listRes.ok) { if (!listRes.ok) {
+43 -44
View File
@@ -1,17 +1,11 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import { useMemo } from "react";
import type React from "react"; import dynamic from "next/dynamic";
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 ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
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 });
type RichTextEditorProps = { type RichTextEditorProps = {
value: string; value: string;
@@ -20,33 +14,38 @@ type RichTextEditorProps = {
className?: string; className?: string;
}; };
export default function RichTextEditor({ export default function RichTextEditor({
value, value,
onChange, onChange,
placeholder, placeholder,
className, className,
}: RichTextEditorProps) { }: RichTextEditorProps) {
return ( const modules = useMemo(
<div className={className}> () => ({
<div className="bb-quill"> toolbar: [
<ReactQuill [{ header: [1, 2, 3, false] }],
theme="snow" ["bold", "italic", "underline", "strike"],
value={value} [{ color: [] }, { background: [] }],
onChange={onChange} [{ list: "ordered" }, { list: "bullet" }],
placeholder={placeholder} ["blockquote", "code-block"],
modules={{ ["link"],
toolbar: [ ["clean"],
[{ header: [1, 2, 3, false] }], ],
["bold", "italic", "underline", "strike"], }),
[{ color: [] }, { background: [] }], []
[{ list: "ordered" }, { list: "bullet" }], );
["blockquote", "code-block"],
["link"], return (
["clean"], <div className={className}>
], <div className="bb-quill">
}} <ReactQuill
/> theme="snow"
</div> value={value}
</div> onChange={onChange}
); placeholder={placeholder}
} modules={modules}
/>
</div>
</div>
);
}
+90 -132
View File
@@ -9,9 +9,6 @@ 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;
@@ -29,174 +26,137 @@ 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 auth to localStorage * Persist user to localStorage (for quick initial render)
*/ */
const persistAuth = useCallback( const persistUser = useCallback((nextUser: AuthUser) => {
(nextToken: string | null, nextUser: AuthUser) => { if (typeof window === "undefined") return;
if (typeof window === "undefined") return;
if (nextToken) { if (nextUser) {
window.localStorage.setItem(TOKEN_KEY, nextToken); window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else { } else {
window.localStorage.removeItem(TOKEN_KEY); window.localStorage.removeItem(USER_KEY);
} }
}, []);
if (nextUser) {
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else {
window.localStorage.removeItem(USER_KEY);
}
},
[]
);
/** /**
* Hydrate from localStorage ONCE * Hydrate from localStorage and refresh from server
* 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);
if (storedToken && storedUser) { // Quick hydration from cached user
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);
} }
} }
setLoading(false); // Verify session with server
}, []); 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 () => {
* Used by login/register/magic-link const res = await fetch("/api/auth/me", {
*/
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 session"); throw new Error(text || "Failed to refresh user");
} }
const me = await res.json(); const data = await res.json();
const nextUser = { const nextUser: AuthUser = {
uuid: me.uuid, uuid: data.uuid,
email: me.email, email: data.email,
username: me.username || null, username: data.username || null,
displayName: me.displayName || null, displayName: data.displayName || null,
role: me.role || "USER", role: data.role || "USER",
passwordSetAt: me.passwordSetAt || null, passwordSetAt: data.passwordSetAt || null,
} as NonNullable<AuthUser>; };
await setSession(token, nextUser); setUser(nextUser);
}, [token, setSession]); persistUser(nextUser);
}, [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_BASE_URL}/api/auth/login`, { const res = await fetch("/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 text = await res.text().catch(() => res.statusText); const errorData = await res.json().catch(() => ({}));
throw new Error(text || "Login failed"); throw new Error(errorData.error || "Login failed");
} }
const data = await res.json(); const data = await res.json();
const nextToken: string = data.token ?? data.accessToken; const nextUser: AuthUser = data.user;
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);
await setSession(nextToken, nextUser as NonNullable<AuthUser>); setUser(nextUser);
persistUser(nextUser);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[setSession] [persistUser]
); );
const register = useCallback( const register = useCallback(
@@ -209,9 +169,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}: RegisterParams) => { }: RegisterParams) => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, { const res = await fetch("/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,
@@ -222,54 +183,51 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => res.statusText); const errorData = await res.json().catch(() => ({}));
throw new Error(text || "Registration failed"); throw new Error(errorData.error || "Registration failed");
} }
const data = await res.json(); const data = await res.json();
const nextToken: string = data.token ?? data.accessToken; const nextUser: AuthUser = data.user;
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);
await setSession(nextToken, nextUser as NonNullable<AuthUser>); setUser(nextUser);
persistUser(nextUser);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, },
[setSession] [persistUser]
); );
const logout = useCallback(() => { const logout = useCallback(() => {
setToken(null);
setUser(null); setUser(null);
persistAuth(null, null); persistUser(null);
// Clear server session cookies // Clear server session cookies
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {}); fetch("/api/auth/logout", {
}, [persistAuth]); method: "POST",
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>;
+17 -23
View File
@@ -1,63 +1,57 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; // Authentication happens via HTTP-only cookies automatically
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) {
return fetch(`${API_BASE_URL}${path}`, { // Path should now point to Next.js API routes (e.g., /api/builds)
return fetch(path, {
...init, ...init,
headers: authHeaders(init?.headers), credentials: 'include', // Include cookies in requests
}); });
} }
async function post(path: string, body: JsonValue, init?: RequestInit) { async function post(path: string, body: JsonValue, init?: RequestInit) {
return fetch(`${API_BASE_URL}${path}`, { return fetch(path, {
method: "POST", method: "POST",
...init, ...init,
headers: authHeaders({ credentials: 'include',
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(`${API_BASE_URL}${path}`, { return fetch(path, {
method: "PUT", method: "PUT",
...init, ...init,
headers: authHeaders({ credentials: 'include',
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(`${API_BASE_URL}${path}`, { return fetch(path, {
method: "DELETE", method: "DELETE",
...init, ...init,
headers: authHeaders(init?.headers), credentials: 'include',
headers: init?.headers,
}); });
} }
// ✅ stable reference unless token changes
return { get, post, put, del }; return { get, post, put, del };
}, [token]); }, []);
} }
+31 -31
View File
@@ -17,64 +17,64 @@ export interface UpdateEmailRequestDto {
status?: string; status?: string;
} }
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || ''; export async function fetchEmailRequests(): Promise<EmailRequest[]> {
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}`);
} }
return response.json(); const data = (await response.json()) as unknown;
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(token: string, data: CreateEmailRequestDto): Promise<EmailRequest> { export async function createEmailRequest(
const response = await fetch(`${API_BASE}/api/email`, { data: CreateEmailRequestDto
method: 'POST', ): Promise<EmailRequest> {
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),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to create email request: ${response.statusText}`); throw new Error(`Failed to create email request: ${response.statusText}`);
} }
return response.json(); return response.json();
} }
export async function updateEmailRequest(token: string, id: number, data: UpdateEmailRequestDto): Promise<EmailRequest> { export async function updateEmailRequest(
const response = await fetch(`${API_BASE}/api/email/${id}`, { id: number,
method: 'PUT', data: UpdateEmailRequestDto
): 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),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to update email request: ${response.statusText}`); throw new Error(`Failed to update email request: ${response.statusText}`);
} }
return response.json(); return response.json();
} }
export async function deleteEmailRequest(token: string, id: number): Promise<void> { export async function deleteEmailRequest(id: number): Promise<void> {
const response = await fetch(`${API_BASE}/api/email/${id}`, { const response = await fetch(`/api/admin/email/${id}`, {
method: 'DELETE', method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to delete email request: ${response.statusText}`); throw new Error(`Failed to delete email request: ${response.statusText}`);
} }
+10 -4
View File
@@ -58,9 +58,12 @@ export async function fetchProducts(params: {
if (sort) sp.set("sort", sort); if (sort) sp.set("sort", sort);
const url = `${API_BASE_URL}/api/v1/products?${sp.toString()}`; const url = `/api/catalog/products?${sp.toString()}`;
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, {
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[];
@@ -83,10 +86,13 @@ export async function fetchProductById(params: {
const { id, platform } = params; const { id, platform } = params;
const url = const url =
`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}` + `/api/catalog/products/${encodeURIComponent(id)}` +
(platform ? `?${new URLSearchParams({ platform })}` : ""); (platform ? `?${new URLSearchParams({ platform })}` : "");
const res = await fetch(url, { cache: "no-store" }); const res = await fetch(url, {
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;
} }
+25 -54
View File
@@ -2,71 +2,42 @@
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;
console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT); // Protect admin API routes
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
// 1) Admin gate (always on) if (pathname.startsWith("/admin")) {
// ========================= if (!token) {
if (pathname.startsWith("/admin")) { const url = req.nextUrl.clone();
const token = req.cookies.get("bb_access_token")?.value; url.pathname = "/login";
const role = req.cookies.get("bb_role")?.value; // optional url.searchParams.set("next", pathname);
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: [
// run on everything except Next internals + obvious static files "/api/admin/:path*",
"/((?!_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",
"/api/builds/:path+",
"/admin/:path*",
], ],
}; };