Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a5fe0e7a |
@@ -0,0 +1,102 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Terms of Service | BattlBuilder",
|
||||||
|
description: "Terms of Service and legal disclaimers for rifle assembly and part compatibility.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TermsOfService() {
|
||||||
|
const lastUpdated = "December 26, 2024";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-12">
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">Terms of Service</h1>
|
||||||
|
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-10 text-sm leading-relaxed text-white/90">
|
||||||
|
{/* 1. Acceptance */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-white">1. Acceptance of Terms</h2>
|
||||||
|
<p>
|
||||||
|
By accessing or using Shadow GunBuilder AI (the "Site"), you agree to be bound by these Terms of Service.
|
||||||
|
This Site is intended for informational and entertainment purposes only, providing a platform to
|
||||||
|
visualize and plan AR-platform rifle builds using third-party components.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2. Legal Compliance */}
|
||||||
|
<section className="rounded-xl border border-orange-500/30 bg-orange-500/5 p-6">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-orange-200">2. Legal Compliance & Firearms Safety</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="font-medium text-orange-100">
|
||||||
|
IMPORTANT: It is your sole responsibility to ensure that any firearm configuration you assemble
|
||||||
|
complies with all applicable local, state, federal, and international laws.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc space-y-2 pl-5">
|
||||||
|
<li>Laws regarding "assault weapons," magazine capacity, barrel length (NFA regulations), and specific features vary significantly by jurisdiction.</li>
|
||||||
|
<li>The Site does not provide legal advice regarding the legality of any specific build in your area.</li>
|
||||||
|
<li>Always consult with a qualified gunsmith or legal professional before commencing a physical build.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 3. No Guarantee of Compatibility */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-white">3. Compatibility Disclaimer</h2>
|
||||||
|
<p>
|
||||||
|
While we strive for accuracy, the Site is a digital simulation. Compatibility data between parts
|
||||||
|
(e.g., gas block diameters, handguard clearances, or buffer weights) is based on manufacturer
|
||||||
|
specifications which are subject to change.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
Shadow GunBuilder AI does <strong>not</strong> guarantee that parts selected in the digital builder
|
||||||
|
will fit together physically or function safely. Mechanical tolerances and manufacturer variations
|
||||||
|
may exist.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 4. User Generated Content */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-white">4. User Builds and the "Vault"</h2>
|
||||||
|
<p>
|
||||||
|
When you save a build to your Vault or publish it to the community, you grant us a non-exclusive
|
||||||
|
license to display and store that data. You are responsible for the content of your descriptions
|
||||||
|
and ensuring they do not violate any laws or third-party rights.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 5. Affiliate and Pricing Disclosure */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-white">5. Affiliate & Pricing Data</h2>
|
||||||
|
<p>
|
||||||
|
Pricing and availability data are pulled from third-party retailers. We are not responsible
|
||||||
|
for pricing errors or out-of-stock items. We may receive commissions for purchases made
|
||||||
|
through links on the Site.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 6. Limitation of Liability */}
|
||||||
|
<section className="border-t border-white/10 pt-10">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-white">6. Limitation of Liability</h2>
|
||||||
|
<p className="uppercase text-xs opacity-70">
|
||||||
|
SHADOW GUNBUILDER AI AND ITS OPERATORS SHALL NOT BE LIABLE FOR ANY DAMAGES, INJURIES, OR LEGAL
|
||||||
|
CONSEQUENCES ARISING FROM THE USE OF THIS SITE OR THE PHYSICAL ASSEMBLY OF FIREARMS BASED ON
|
||||||
|
PLANS CREATED HEREIN. USE AT YOUR OWN RISK.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="pt-10">
|
||||||
|
<Link
|
||||||
|
href="/public"
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
← Return to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
|
||||||
|
|
||||||
export default function AdminBetaInvitesPage() {
|
|
||||||
const [dryRun, setDryRun] = useState(true);
|
|
||||||
const [limit, setLimit] = useState("1");
|
|
||||||
const [tokenMinutes, setTokenMinutes] = useState("30");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [result, setResult] = useState<any>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function runInvites() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const qs = new URLSearchParams({
|
|
||||||
dryRun: String(dryRun),
|
|
||||||
limit: String(limit || "0"),
|
|
||||||
tokenMinutes: String(tokenMinutes || "30"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
|
|
||||||
|
|
||||||
setResult(data);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message ?? "Failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
|
||||||
Runs the backend batch invite sender (uses your email templates + tracking).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
|
||||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={dryRun}
|
|
||||||
onChange={(e) => setDryRun(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Dry run (do everything except actually send)
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Field label="Limit" htmlFor="limit">
|
|
||||||
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
|
||||||
<Input
|
|
||||||
id="tokenMinutes"
|
|
||||||
value={tokenMinutes}
|
|
||||||
onChange={(e) => setTokenMinutes(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={runInvites} disabled={loading}>
|
|
||||||
{loading ? "Running…" : "Send Beta Invites"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
|
||||||
{JSON.stringify(result, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Check, X, Wand2, RefreshCw } from "lucide-react";
|
|
||||||
|
|
||||||
type EnrichmentStatus = "PENDING_REVIEW" | "APPROVED" | "REJECTED" | "APPLIED";
|
|
||||||
type EnrichmentType = "CALIBER" | "CALIBER_GROUP";
|
|
||||||
type EnrichmentSource = "RULES" | "AI";
|
|
||||||
|
|
||||||
type QueueItem = {
|
|
||||||
id: number;
|
|
||||||
productId: number;
|
|
||||||
productName?: string;
|
|
||||||
productSlug?: string;
|
|
||||||
mainImageUrl?: string;
|
|
||||||
brandName?: string;
|
|
||||||
enrichmentType: EnrichmentType;
|
|
||||||
source: EnrichmentSource;
|
|
||||||
status: EnrichmentStatus;
|
|
||||||
attributes: Record<string, any>;
|
|
||||||
confidence: number;
|
|
||||||
rationale?: string;
|
|
||||||
createdAt: string;
|
|
||||||
|
|
||||||
// NEW: current value on products table (so we can prevent bad applies)
|
|
||||||
productCaliber?: string | null;
|
|
||||||
productCaliberGroup?: string | null;
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const API_BASE =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
function getAuthHeaders(): HeadersInit {
|
|
||||||
if (typeof window === "undefined") return {};
|
|
||||||
const token =
|
|
||||||
localStorage.getItem("token") ||
|
|
||||||
localStorage.getItem("jwt") ||
|
|
||||||
localStorage.getItem("accessToken");
|
|
||||||
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiFetch(path: string, init?: RequestInit) {
|
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...getAuthHeaders(),
|
|
||||||
...(init?.headers ?? {}),
|
|
||||||
},
|
|
||||||
// If you're using cookie auth instead of bearer, flip this on:
|
|
||||||
// credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(text || `Request failed (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ct = res.headers.get("content-type") ?? "";
|
|
||||||
if (ct.includes("application/json")) return res.json();
|
|
||||||
return res.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasProductCaliber(it: QueueItem) {
|
|
||||||
const c = it.productCaliber;
|
|
||||||
return typeof c === "string" && c.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAlreadySet(it: QueueItem) {
|
|
||||||
if (it.enrichmentType === "CALIBER") {
|
|
||||||
return hasProductCaliber(it);
|
|
||||||
}
|
|
||||||
const g = it.productCaliberGroup;
|
|
||||||
return typeof g === "string" && g.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ ADD THIS: text for the “Already set:” pill
|
|
||||||
function alreadySetLabel(it: QueueItem) {
|
|
||||||
return it.enrichmentType === "CALIBER" ? it.productCaliber : it.productCaliberGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EnrichmentQueueClient() {
|
|
||||||
const [type, setType] = useState<EnrichmentType>("CALIBER");
|
|
||||||
const [status, setStatus] = useState<EnrichmentStatus>("PENDING_REVIEW");
|
|
||||||
const [limit, setLimit] = useState<number>(50);
|
|
||||||
|
|
||||||
const [items, setItems] = useState<QueueItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [busyId, setBusyId] = useState<number | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Bulk selection
|
|
||||||
const [selected, setSelected] = useState<Record<number, boolean>>({});
|
|
||||||
|
|
||||||
// “Fancy” per-row UI: treat an item as “locally approved/rejected” immediately
|
|
||||||
// so the row swaps buttons without waiting for a reload.
|
|
||||||
const [localStatus, setLocalStatus] = useState<
|
|
||||||
Record<number, EnrichmentStatus>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const count = useMemo(() => items.length, [items]);
|
|
||||||
|
|
||||||
const selectedIds = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.entries(selected)
|
|
||||||
.filter(([, v]) => v)
|
|
||||||
.map(([k]) => Number(k)),
|
|
||||||
[selected]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
const allOnPageSelected = useMemo(() => {
|
|
||||||
if (items.length === 0) return false;
|
|
||||||
return items.every((it) => selected[it.id]);
|
|
||||||
}, [items, selected]);
|
|
||||||
|
|
||||||
function toggleAllOnPage() {
|
|
||||||
const next = { ...selected };
|
|
||||||
const nextValue = !allOnPageSelected;
|
|
||||||
for (const it of items) next[it.id] = nextValue;
|
|
||||||
setSelected(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOne(id: number) {
|
|
||||||
setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectiveStatus(it: QueueItem): EnrichmentStatus {
|
|
||||||
return localStatus[it.id] ?? it.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = (await apiFetch(
|
|
||||||
`/api/admin/enrichment/queue2?type=${encodeURIComponent(
|
|
||||||
type
|
|
||||||
)}&status=${encodeURIComponent(status)}&limit=${limit}`
|
|
||||||
)) as QueueItem[];
|
|
||||||
|
|
||||||
setItems(data ?? []);
|
|
||||||
|
|
||||||
// Keep selection only for items still visible
|
|
||||||
setSelected((prev) => {
|
|
||||||
const keep = new Set((data ?? []).map((x) => x.id));
|
|
||||||
const next: Record<number, boolean> = {};
|
|
||||||
for (const [k, v] of Object.entries(prev)) {
|
|
||||||
const id = Number(k);
|
|
||||||
if (keep.has(id)) next[id] = v;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset local status for items not in view
|
|
||||||
setLocalStatus((prev) => {
|
|
||||||
const keep = new Set((data ?? []).map((x) => x.id));
|
|
||||||
const next: Record<number, EnrichmentStatus> = {};
|
|
||||||
for (const [k, v] of Object.entries(prev)) {
|
|
||||||
const id = Number(k);
|
|
||||||
if (keep.has(id)) next[id] = v;
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message ?? "Failed to load queue");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRules() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await apiFetch(
|
|
||||||
`/api/admin/enrichment/run?type=${encodeURIComponent(type)}&limit=200`,
|
|
||||||
{ method: "POST" }
|
|
||||||
);
|
|
||||||
setStatus("PENDING_REVIEW");
|
|
||||||
setTimeout(load, 50);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message ?? "Failed to run rules");
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAi() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await apiFetch(
|
|
||||||
`/api/admin/enrichment/ai/run?type=${encodeURIComponent(type)}&limit=200`,
|
|
||||||
{ method: "POST" }
|
|
||||||
);
|
|
||||||
setStatus("PENDING_REVIEW");
|
|
||||||
setTimeout(load, 50);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message ?? "Failed to run AI");
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function act(id: number, action: "approve" | "reject" | "apply") {
|
|
||||||
setBusyId(id);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Optimistic UI for approve/reject so the row instantly changes
|
|
||||||
if (action === "approve")
|
|
||||||
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
|
|
||||||
if (action === "reject")
|
|
||||||
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
} catch (e: any) {
|
|
||||||
// rollback optimistic local status on error
|
|
||||||
setLocalStatus((p) => {
|
|
||||||
const next = { ...p };
|
|
||||||
delete next[id];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setError(e?.message ?? `Failed to ${action}`);
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bulk(action: "approve" | "reject" | "apply") {
|
|
||||||
const ids = selectedIds;
|
|
||||||
if (ids.length === 0) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Apply should only run for items that are APPROVED (effective status) AND product caliber is blank
|
|
||||||
const byId = new Map(items.map((x) => [x.id, x]));
|
|
||||||
const filtered =
|
|
||||||
action === "apply"
|
|
||||||
? ids.filter((id) => {
|
|
||||||
const it = byId.get(id);
|
|
||||||
if (!it) return false;
|
|
||||||
return (
|
|
||||||
effectiveStatus(it) === "APPROVED" && !hasAlreadySet(it)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: ids;
|
|
||||||
|
|
||||||
for (const id of filtered) {
|
|
||||||
// optimistic statuses for approve/reject in bulk
|
|
||||||
if (action === "approve")
|
|
||||||
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
|
|
||||||
if (action === "reject")
|
|
||||||
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
|
|
||||||
|
|
||||||
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelected({});
|
|
||||||
await load();
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message ?? `Failed to bulk ${action}`);
|
|
||||||
setLoading(false);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [type, status, limit]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex flex-col gap-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="text-sm text-zinc-400">
|
|
||||||
Showing <span className="text-zinc-200 font-medium">{count}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<select
|
|
||||||
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
|
||||||
value={type}
|
|
||||||
onChange={(e) => setType(e.target.value as EnrichmentType)}
|
|
||||||
>
|
|
||||||
<option value="CALIBER">CALIBER</option>
|
|
||||||
<option value="CALIBER_GROUP">CALIBER_GROUP</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelected({});
|
|
||||||
setLocalStatus({});
|
|
||||||
setStatus(e.target.value as EnrichmentStatus);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="PENDING_REVIEW">PENDING_REVIEW</option>
|
|
||||||
<option value="APPROVED">APPROVED</option>
|
|
||||||
<option value="REJECTED">REJECTED</option>
|
|
||||||
<option value="APPLIED">APPLIED</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
|
||||||
value={limit}
|
|
||||||
onChange={(e) => setLimit(parseInt(e.target.value, 10))}
|
|
||||||
>
|
|
||||||
<option value={25}>25</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={200}>200</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={load}
|
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={runRules}
|
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
|
||||||
disabled={loading}
|
|
||||||
title="Run enrichment rules to create new suggestions"
|
|
||||||
>
|
|
||||||
<Wand2 className="h-4 w-4" />
|
|
||||||
Run Rules
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={runAi}
|
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800"
|
|
||||||
disabled={loading}
|
|
||||||
title="Run AI enrichment to create new suggestions"
|
|
||||||
>
|
|
||||||
<Wand2 className="h-4 w-4" />
|
|
||||||
Run AI
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
|
||||||
{selectedIds.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
|
|
||||||
<div className="text-sm text-zinc-300">
|
|
||||||
Selected{" "}
|
|
||||||
<span className="font-semibold">{selectedIds.length}</span>
|
|
||||||
<span className="ml-2 text-xs text-zinc-500">
|
|
||||||
(Apply only works on APPROVED + product caliber blank)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => bulk("approve")}
|
|
||||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Approve Selected
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => bulk("reject")}
|
|
||||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Reject Selected
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => bulk("apply")}
|
|
||||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
|
||||||
disabled={loading}
|
|
||||||
title="Applies only APPROVED rows where product caliber is blank"
|
|
||||||
>
|
|
||||||
Apply Selected
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelected({})}
|
|
||||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-md border border-red-900/50 bg-red-950/30 p-3 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="overflow-hidden rounded-md border border-zinc-800">
|
|
||||||
<div className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 border-b border-zinc-800 bg-zinc-950 px-4 py-3 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allOnPageSelected}
|
|
||||||
onChange={toggleAllOnPage}
|
|
||||||
className="h-4 w-4 accent-amber-400"
|
|
||||||
aria-label="Select all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>Product</div>
|
|
||||||
<div className="text-right">Confidence</div>
|
|
||||||
<div className="text-right">Suggested</div>
|
|
||||||
<div className="text-right">Actions</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-zinc-800 bg-zinc-950/40">
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div className="p-6 text-sm text-zinc-400">No items found.</div>
|
|
||||||
) : (
|
|
||||||
items.map((it) => {
|
|
||||||
const suggested =
|
|
||||||
it.enrichmentType === "CALIBER"
|
|
||||||
? it.attributes?.caliber
|
|
||||||
: it.attributes?.caliberGroup; const isBusy = busyId === it.id;
|
|
||||||
const st = effectiveStatus(it);
|
|
||||||
const alreadySet = hasAlreadySet(it);
|
|
||||||
const alreadySetValue = alreadySetLabel(it);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={it.id}
|
|
||||||
className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!selected[it.id]}
|
|
||||||
onChange={() => toggleOne(it.id)}
|
|
||||||
className="h-4 w-4 accent-amber-400"
|
|
||||||
aria-label={`Select enrichment ${it.id}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{it.mainImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={it.mainImageUrl}
|
|
||||||
alt=""
|
|
||||||
className="h-10 w-10 rounded border border-zinc-800 object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-900" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<a
|
|
||||||
href={`/admin/products/${it.productId}`}
|
|
||||||
className="text-sm font-semibold text-zinc-100 hover:underline line-clamp-2"
|
|
||||||
>
|
|
||||||
{it.productName ?? `Product #${it.productId}`}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="mt-0.5 text-xs text-zinc-500">
|
|
||||||
{it.brandName ? `${it.brandName} • ` : ""}
|
|
||||||
{it.source} • {st}
|
|
||||||
{alreadySet ? (
|
|
||||||
<span className="ml-2 inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-zinc-300">
|
|
||||||
Already set: {alreadySetValue}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{it.rationale ? (
|
|
||||||
<div className="mt-1 text-xs text-zinc-400 line-clamp-2">
|
|
||||||
{it.rationale}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right text-sm font-semibold text-zinc-200">
|
|
||||||
{(it.confidence ?? 0).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right text-sm text-amber-300">
|
|
||||||
{suggested ?? "—"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{st === "PENDING_REVIEW" && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => act(it.id, "approve")}
|
|
||||||
disabled={isBusy || alreadySet}
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
|
||||||
title={
|
|
||||||
alreadySet
|
|
||||||
? "Product caliber already set"
|
|
||||||
: "Approve"
|
|
||||||
}
|
|
||||||
aria-label="Approve"
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => act(it.id, "reject")}
|
|
||||||
disabled={isBusy}
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
|
||||||
title="Reject"
|
|
||||||
aria-label="Reject"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{st === "APPROVED" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => act(it.id, "apply")}
|
|
||||||
disabled={isBusy || alreadySet}
|
|
||||||
className="inline-flex h-8 items-center justify-center rounded-md bg-amber-400 px-3 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
|
||||||
title={
|
|
||||||
alreadySet
|
|
||||||
? `Already set on product: ${alreadySetValue}`
|
|
||||||
: "Apply (writes to product if blank)"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{st === "APPLIED" && (
|
|
||||||
<span className="inline-flex h-8 items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-200">
|
|
||||||
Applied
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{st === "REJECTED" && (
|
|
||||||
<span className="inline-flex h-8 items-center rounded-md border border-red-500/30 bg-red-500/10 px-3 text-xs font-semibold text-red-200">
|
|
||||||
Rejected
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import EnrichmentQueueClient from "./EnrichmentQueueClient";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default function AdminEnrichmentPage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-6xl px-6 py-8">
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-xl font-semibold text-zinc-50">Enrichment Queue</h1>
|
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
|
||||||
Review AI/rules suggestions, approve/reject, then apply to products.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnrichmentQueueClient />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+60
-55
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import AdminLeftNavigation from "@/components/AdminLeftNavigation";
|
import AdminLeftNavigation from "@/components/AdminLeftNavigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Download,
|
Download,
|
||||||
@@ -14,72 +11,81 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
LucideMail,
|
LucideMail,
|
||||||
Wand2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
|
{
|
||||||
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
|
label: "Dashboard",
|
||||||
{ label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
|
href: "/admin",
|
||||||
{ label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
|
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||||
{ label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
|
},
|
||||||
{ label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
|
{
|
||||||
{ label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
|
label: "Imports",
|
||||||
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
|
href: "/admin/import-status",
|
||||||
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
|
icon: <Download className="h-4 w-4" />,
|
||||||
{ label: "List Emails", href: "/admin/email", icon: <LucideMail className="h-4 w-4" /> },
|
},
|
||||||
{ label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
|
{
|
||||||
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" /> },
|
label: "Mappings",
|
||||||
|
href: "/admin/mapping",
|
||||||
|
icon: <Layers className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Products",
|
||||||
|
href: "/admin/products",
|
||||||
|
icon: <Boxes className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Merchants",
|
||||||
|
href: "/admin/merchants",
|
||||||
|
icon: <Store className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Platforms",
|
||||||
|
href: "/admin/platforms",
|
||||||
|
icon: <Layers className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
href: "/admin/users",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
href: "/admin/settings",
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "List Emails",
|
||||||
|
href: "/admin/email",
|
||||||
|
icon: <LucideMail className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Send a Email",
|
||||||
|
href: "/admin/email/send",
|
||||||
|
icon: <LucideMail className="h-4 w-4" />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ... existing code ...
|
||||||
|
// ADMIN CHECK FOR LOGIN
|
||||||
|
// const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
// if (!loading && user?.role !== "ADMIN") {
|
||||||
|
// redirect("/"); // or /login
|
||||||
|
// }
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const { user, loading } = useAuth();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// Where to send people back after login
|
|
||||||
const next = useMemo(
|
|
||||||
() => encodeURIComponent(pathname || "/admin"),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ✅ AUTH GUARD
|
|
||||||
* Redirects happen in useEffect (NOT during render)
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading) return;
|
|
||||||
|
|
||||||
// Not logged in
|
|
||||||
if (!user) {
|
|
||||||
router.replace(`/login?next=${next}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logged in but not admin
|
|
||||||
if (user.role !== "ADMIN") {
|
|
||||||
router.replace("/");
|
|
||||||
}
|
|
||||||
}, [loading, user, router, next]);
|
|
||||||
|
|
||||||
// While loading OR redirecting, render nothing
|
|
||||||
if (loading) return null;
|
|
||||||
if (!user) return null;
|
|
||||||
if (user.role !== "ADMIN") return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-black text-zinc-50">
|
<div className="flex min-h-screen bg-black text-zinc-50">
|
||||||
<AdminLeftNavigation
|
<AdminLeftNavigation
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||||
items={navItems}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main column */}
|
{/* Main column */}
|
||||||
@@ -94,7 +100,6 @@ export default function AdminLayout({
|
|||||||
Battl Builders Control Panel
|
Battl Builders Control Panel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<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
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const token = cookies().get("bb_access_token")?.value;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const dryRun = url.searchParams.get("dryRun") ?? "true";
|
|
||||||
const limit = url.searchParams.get("limit") ?? "0";
|
|
||||||
const tokenMinutes = url.searchParams.get("tokenMinutes") ?? "30";
|
|
||||||
|
|
||||||
const upstream = `${API_BASE_URL}/api/v1/admin/beta/invites/send?dryRun=${dryRun}&limit=${limit}&tokenMinutes=${tokenMinutes}`;
|
|
||||||
|
|
||||||
const res = await fetch(upstream, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
return new NextResponse(text, {
|
|
||||||
status: res.status,
|
|
||||||
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const { token, role } = await req.json();
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.json({ ok: false }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = NextResponse.json({ ok: true });
|
|
||||||
|
|
||||||
// Server-readable, JS-unreadable
|
|
||||||
res.cookies.set("bb_access_token", token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Optional: for quick middleware role checks (not “secure” by itself)
|
|
||||||
if (role) {
|
|
||||||
res.cookies.set("bb_role", role, {
|
|
||||||
httpOnly: false,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
path: "/",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE() {
|
|
||||||
const res = NextResponse.json({ ok: true });
|
|
||||||
res.cookies.set("bb_access_token", "", { path: "/", maxAge: 0 });
|
|
||||||
res.cookies.set("bb_role", "", { path: "/", maxAge: 0 });
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ export async function POST(req: Request) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
cache: "no-store",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always return ok=true (matches your server behavior)
|
// Always return ok=true (matches your server behavior)
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
// app/builds/[buildId]/page.tsx
|
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
function formatMoney(n?: number | null) {
|
|
||||||
if (n == null) return "—";
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeAgo(iso?: string | null) {
|
|
||||||
if (!iso) return "";
|
|
||||||
const d = new Date(iso);
|
|
||||||
const diff = Math.max(0, Date.now() - d.getTime());
|
|
||||||
const mins = Math.floor(diff / 60000);
|
|
||||||
if (mins < 60) return `${mins}m ago`;
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
if (hrs < 24) return `${hrs}h ago`;
|
|
||||||
const days = Math.floor(hrs / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BuildBreakdownPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: { buildId: string };
|
|
||||||
}) {
|
|
||||||
// Public detail endpoint
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 404) return notFound();
|
|
||||||
|
|
||||||
const build = await res.json().catch(() => null);
|
|
||||||
if (!build) return notFound();
|
|
||||||
|
|
||||||
const items: any[] = build.items ?? [];
|
|
||||||
|
|
||||||
const total = items.reduce((sum, it) => {
|
|
||||||
const qty = Number(it.quantity ?? 1);
|
|
||||||
const price = it.bestPrice == null ? 0 : Number(it.bestPrice);
|
|
||||||
return sum + qty * price;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const images: any[] = build.images ?? build.photos ?? build.media ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
|
||||||
{/* Header crumb */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link
|
|
||||||
href="/builds"
|
|
||||||
className="text-sm text-zinc-400 hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
← Back to Community Builds
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
|
|
||||||
<span className="rounded-full border border-zinc-800 bg-zinc-950 px-2 py-0.5">
|
|
||||||
Public Build
|
|
||||||
</span>
|
|
||||||
{build.updatedAt && (
|
|
||||||
<span className="hidden md:inline">
|
|
||||||
updated {timeAgo(build.updatedAt)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Layout */}
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
|
|
||||||
{/* Post column */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* “Reddit-ish” Post Card */}
|
|
||||||
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60">
|
|
||||||
<div className="flex">
|
|
||||||
{/* Vote rail */}
|
|
||||||
<div className="flex w-12 flex-col items-center gap-2 border-r border-zinc-900 bg-black/20 py-4 text-zinc-500">
|
|
||||||
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<div className="text-xs font-semibold text-zinc-400">—</div>
|
|
||||||
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Post body */}
|
|
||||||
<div className="flex-1 p-5">
|
|
||||||
{/* Meta */}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-500">
|
|
||||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-amber-300">
|
|
||||||
r/BattlBuilders
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>posted by</span>
|
|
||||||
<span className="text-zinc-300">u/anonymous</span>
|
|
||||||
{build.createdAt && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{timeAgo(build.createdAt)}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-50">
|
|
||||||
{build.title ?? "Untitled Build"}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{build.description ? (
|
|
||||||
<p className="mt-2 text-sm text-zinc-300/90">
|
|
||||||
{build.description}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-2 text-sm text-zinc-500">
|
|
||||||
{/* placeholder copy */}
|
|
||||||
Field notes coming soon. This build breakdown will include
|
|
||||||
purpose, tradeoffs, and why each part made the cut.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action bar */}
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
|
|
||||||
Comment
|
|
||||||
</button>
|
|
||||||
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
|
|
||||||
Share
|
|
||||||
</button>
|
|
||||||
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 text-zinc-500">
|
|
||||||
<span className="hidden sm:inline">Est. Total</span>
|
|
||||||
<span className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 text-zinc-200">
|
|
||||||
{formatMoney(total)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parts card */}
|
|
||||||
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-sm font-semibold text-zinc-200">Parts</h2>
|
|
||||||
<div className="text-xs text-zinc-500">{items.length} items</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
{items.map((it) => (
|
|
||||||
<div
|
|
||||||
key={it.uuid ?? it.id}
|
|
||||||
className="flex items-center gap-3 rounded-lg border border-zinc-900 bg-black/30 px-3 py-3"
|
|
||||||
>
|
|
||||||
{/* Thumb */}
|
|
||||||
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
|
||||||
{it.productImageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={it.productImageUrl}
|
|
||||||
alt={it.productName ?? "Part image"}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
|
|
||||||
IMG
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm text-zinc-100">
|
|
||||||
{it.productName ?? "Part"}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-zinc-500">
|
|
||||||
<span className="rounded-md border border-zinc-800 bg-black/20 px-1.5 py-0.5">
|
|
||||||
{it.slot ?? "slot"}
|
|
||||||
</span>
|
|
||||||
{it.productBrand && <span>{it.productBrand}</span>}
|
|
||||||
<span className="text-zinc-600">•</span>
|
|
||||||
<span>x{it.quantity ?? 1}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<div className="text-sm font-medium text-zinc-200">
|
|
||||||
{formatMoney(it.bestPrice)}
|
|
||||||
</div>
|
|
||||||
<button className="text-[11px] text-amber-300/90 hover:text-amber-200">
|
|
||||||
View part →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gallery */}
|
|
||||||
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-sm font-semibold text-zinc-200">
|
|
||||||
Gallery
|
|
||||||
</h2>
|
|
||||||
<span className="text-xs text-zinc-500">user submitted</span>
|
|
||||||
</div>
|
|
||||||
{images.length === 0 ? (
|
|
||||||
<div className="mt-4 rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4">
|
|
||||||
<p className="text-sm text-zinc-400">
|
|
||||||
No photos yet. Be the first to add a build pic.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-[11px] text-zinc-600">
|
|
||||||
(Upload UI coming soon — we’ll store and moderate images
|
|
||||||
per build.)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Mock thumbnails */}
|
|
||||||
<div className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="aspect-square rounded-md border border-zinc-800 bg-zinc-950 flex items-center justify-center text-[10px] text-zinc-600"
|
|
||||||
>
|
|
||||||
IMG
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="mt-4 rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
|
|
||||||
disabled
|
|
||||||
title="Upload UI coming soon"
|
|
||||||
>
|
|
||||||
Add Photo (coming soon)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-4">
|
|
||||||
{/* “Hero” preview */}
|
|
||||||
<div className="aspect-[16/9] overflow-hidden rounded-lg border border-zinc-900 bg-black/30">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={images[0].url ?? images[0]}
|
|
||||||
alt="Build photo"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbs */}
|
|
||||||
<div className="mt-3 grid grid-cols-4 gap-2 sm:grid-cols-6">
|
|
||||||
{images.slice(0, 12).map((img, idx) => (
|
|
||||||
<div
|
|
||||||
key={img.uuid ?? img.id ?? idx}
|
|
||||||
className="aspect-square overflow-hidden rounded-md border border-zinc-900 bg-black/30"
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={img.thumbUrl ?? img.url ?? img}
|
|
||||||
alt={`Build photo ${idx + 1}`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
|
|
||||||
View all
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
|
|
||||||
disabled
|
|
||||||
title="Upload UI coming soon"
|
|
||||||
>
|
|
||||||
Add Photo
|
|
||||||
</button>
|
|
||||||
<span className="ml-auto text-[11px] text-zinc-600">
|
|
||||||
{images.length} photos
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comments (placeholder) */}
|
|
||||||
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-sm font-semibold text-zinc-200">Comments</h2>
|
|
||||||
<span className="text-xs text-zinc-500">mocked</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
user: "u/gearhead",
|
|
||||||
body: "Solid parts list. Any reason you went with that upper over a BCM?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
user: "u/OP",
|
|
||||||
body: "Mostly availability + price. I’ll probably swap once I track deals for a week.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
user: "u/boltcarrier",
|
|
||||||
body: "Would love to see this with a pinned/weld option for 14.5 builds.",
|
|
||||||
},
|
|
||||||
].map((c, idx) => (
|
|
||||||
<div key={idx} className="flex gap-3">
|
|
||||||
<div className="mt-1 h-6 w-6 rounded-full bg-zinc-900 text-[10px] text-zinc-300 flex items-center justify-center">
|
|
||||||
{c.user === "u/OP" ? "OP" : "u"}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-[11px] text-zinc-500">
|
|
||||||
<span className="text-zinc-300">{c.user}</span> • 1h ago
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-sm text-zinc-300/90">
|
|
||||||
{c.body}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center gap-3 text-[11px] text-zinc-500">
|
|
||||||
<button className="hover:text-amber-300">Reply</button>
|
|
||||||
<button className="hover:text-amber-300">Share</button>
|
|
||||||
<button className="hover:text-amber-300">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4 text-sm text-zinc-500">
|
|
||||||
Commenting is coming soon. This will become the “breakdown”
|
|
||||||
thread for the build (notes, tradeoffs, deal alerts, revisions).
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<aside className="space-y-4">
|
|
||||||
{/* Build stats */}
|
|
||||||
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
|
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
|
||||||
Build Summary
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="mt-3 space-y-2 text-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-zinc-500">Build ID</span>
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
{(build.uuid ?? params.buildId).slice(0, 8)}…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-zinc-500">Visibility</span>
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
{build.isPublic ? "Public" : "Private"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-zinc-500">Items</span>
|
|
||||||
<span className="text-zinc-300">{items.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-zinc-500">Est. Total</span>
|
|
||||||
<span className="text-zinc-50 font-medium">
|
|
||||||
{formatMoney(total)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
|
||||||
<button className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition">
|
|
||||||
Open in Builder
|
|
||||||
</button>
|
|
||||||
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
|
|
||||||
Copy share link
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Placeholder “OP notes” */}
|
|
||||||
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
|
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
|
||||||
OP Notes
|
|
||||||
</h3>
|
|
||||||
<ul className="mt-3 space-y-2 text-sm text-zinc-400">
|
|
||||||
<li>• Purpose: home defense / range</li>
|
|
||||||
<li>• Priority: reliability first</li>
|
|
||||||
<li>• Next upgrades: optic, sling, light</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
// app/layout.tsx
|
// app/layout.tsx
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import Script from "next/script";
|
|
||||||
|
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { Banner } from "@/components/Banner";
|
import { Banner } from "@/components/Banner";
|
||||||
|
|
||||||
@@ -40,9 +38,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
|
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
|
||||||
{" "}
|
{" "}
|
||||||
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
|
|
||||||
strategy="lazyOnload"
|
|
||||||
/>
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Banner />
|
<Banner />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
+6
-27
@@ -13,7 +13,6 @@ export default function HomePage() {
|
|||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
setMessage("Drop an email in first, operator.");
|
setMessage("Drop an email in first, operator.");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
@@ -31,12 +30,11 @@ export default function HomePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text().catch(() => "");
|
throw new Error("Failed to save your signup.");
|
||||||
throw new Error(txt || "Failed to save your signup.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setMessage("✅ You’re on the list. Invites drop soon — we’ll email your access link when it’s go-time.");
|
setMessage("You’re locked in. Watch your inbox.");
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setUseCase("");
|
setUseCase("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -162,8 +160,7 @@ export default function HomePage() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@gearjunkie.com"
|
placeholder="you@gearjunkie.com"
|
||||||
disabled={status === "loading" || status === "success"}
|
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2"
|
||||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,22 +178,17 @@ export default function HomePage() {
|
|||||||
value={useCase}
|
value={useCase}
|
||||||
onChange={(e) => setUseCase(e.target.value)}
|
onChange={(e) => setUseCase(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={status === "loading" || status === "success"}
|
|
||||||
placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..."
|
placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..."
|
||||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === "loading" || status === "success"}
|
disabled={status === "loading"}
|
||||||
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
|
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{status === "loading"
|
{status === "loading" ? "Enlisting…" : "Get Beta Access"}
|
||||||
? "Enlisting…"
|
|
||||||
: status === "success"
|
|
||||||
? "On the List ✅"
|
|
||||||
: "Get Beta Access"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
@@ -213,19 +205,6 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "success" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setStatus("idle");
|
|
||||||
setMessage(null);
|
|
||||||
}}
|
|
||||||
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
Submit another email
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6 text-center text-[11px] text-zinc-500">
|
<div className="mt-6 text-center text-[11px] text-zinc-500">
|
||||||
Already invited?{" "}
|
Already invited?{" "}
|
||||||
<a href="/login" className="underline hover:text-zinc-300">
|
<a href="/login" className="underline hover:text-zinc-300">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Download,
|
Download,
|
||||||
@@ -11,41 +10,70 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
LucideMail,
|
LucideMail,
|
||||||
Wand2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
export type AdminNavItem = {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AdminLeftNavigationProps = {
|
type AdminLeftNavigationProps = {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
items?: AdminNavItem[]; // ✅ NEW
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultNavItems: AdminNavItem[] = [
|
const navItems = [
|
||||||
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
|
{
|
||||||
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
|
label: "Dashboard",
|
||||||
{ label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
|
href: "/admin",
|
||||||
{ label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
|
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||||
{ label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
|
},
|
||||||
{ label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
|
{
|
||||||
{ label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
|
label: "Imports",
|
||||||
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
|
href: "/admin/import-status",
|
||||||
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
|
icon: <Download className="h-4 w-4" />,
|
||||||
{ label: "Manage Emails", href: "/admin/email/manage", icon: <LucideMail className="h-4 w-4" /> },
|
},
|
||||||
{ label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
|
{
|
||||||
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" />,
|
label: "Mappings",
|
||||||
|
href: "/admin/mapping",
|
||||||
|
icon: <Layers className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Products",
|
||||||
|
href: "/admin/products",
|
||||||
|
icon: <Boxes className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Merchants",
|
||||||
|
href: "/admin/merchants",
|
||||||
|
icon: <Store className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Platforms",
|
||||||
|
href: "/admin/platforms",
|
||||||
|
icon: <Layers className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
href: "/admin/users",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
href: "/admin/settings",
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Manage Emails",
|
||||||
|
href: "/admin/email/manage",
|
||||||
|
icon: <LucideMail className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Send a Email",
|
||||||
|
href: "/admin/email/send",
|
||||||
|
icon: <LucideMail className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLeftNavigation({
|
export default function AdminLeftNavigation({
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
items = defaultNavItems, // ✅ NEW default
|
|
||||||
}: AdminLeftNavigationProps) {
|
}: AdminLeftNavigationProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -70,9 +98,7 @@ export default function AdminLeftNavigation({
|
|||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||||
<Link href="/" title="Back to Battl Dashboard">
|
<Link href={"/"} title={"Back to Battl Dashboard"} >Battl Builders</Link>
|
||||||
Battl Builders
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-zinc-600">Admin Command</p>
|
<p className="text-[10px] text-zinc-600">Admin Command</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,8 +106,8 @@ export default function AdminLeftNavigation({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 text-sm">
|
<nav className="flex-1 space-y-1 text-sm">
|
||||||
{items.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<a
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
|
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
|
||||||
@@ -91,13 +117,15 @@ export default function AdminLeftNavigation({
|
|||||||
{!collapsed && <span>{item.label}</span>}
|
{!collapsed && <span>{item.label}</span>}
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && <span className="text-[10px] text-zinc-600">›</span>}
|
{!collapsed && <span className="text-[10px] text-zinc-600">›</span>}
|
||||||
</Link>
|
</a>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
|
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
|
||||||
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">Status</p>
|
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
Import engine: <span className="text-emerald-400">online</span>
|
Import engine: <span className="text-emerald-400">online</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+6
-42
@@ -49,9 +49,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
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
|
|
||||||
*/
|
|
||||||
const persistAuth = useCallback(
|
const persistAuth = useCallback(
|
||||||
(nextToken: string | null, nextUser: AuthUser) => {
|
(nextToken: string | null, nextUser: AuthUser) => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -71,34 +68,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
// Hydrate from localStorage on first load
|
||||||
* ✅ Hydrate from localStorage ONCE
|
|
||||||
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const storedToken = window.localStorage.getItem(TOKEN_KEY);
|
const storedToken = window.localStorage.getItem(TOKEN_KEY);
|
||||||
const storedUser = window.localStorage.getItem(USER_KEY);
|
const storedUser = window.localStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
if (storedToken && storedUser) {
|
if (storedToken) setToken(storedToken);
|
||||||
|
|
||||||
|
if (storedUser) {
|
||||||
try {
|
try {
|
||||||
const parsedUser = JSON.parse(storedUser);
|
setUser(JSON.parse(storedUser));
|
||||||
|
|
||||||
setToken(storedToken);
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,24 +88,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
|
||||||
* Used by login/register/magic-link
|
|
||||||
*/
|
|
||||||
const setSession = useCallback(
|
const setSession = useCallback(
|
||||||
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||||
setToken(nextToken);
|
setToken(nextToken);
|
||||||
setUser(nextUser);
|
setUser(nextUser);
|
||||||
persistAuth(nextToken, nextUser);
|
persistAuth(nextToken, nextUser);
|
||||||
|
|
||||||
// ✅ Make middleware / server aware of the session
|
|
||||||
fetch("/api/auth/session", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
token: nextToken,
|
|
||||||
role: nextUser.role,
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
},
|
},
|
||||||
[persistAuth]
|
[persistAuth]
|
||||||
);
|
);
|
||||||
@@ -149,7 +118,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const nextUser: AuthUser =
|
const nextUser: AuthUser =
|
||||||
data.user ??
|
data.user ??
|
||||||
({
|
({
|
||||||
uuid: data.uuid,
|
|
||||||
email: data.email ?? email,
|
email: data.email ?? email,
|
||||||
displayName: data.displayName ?? null,
|
displayName: data.displayName ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
@@ -192,7 +160,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const nextUser: AuthUser =
|
const nextUser: AuthUser =
|
||||||
data.user ??
|
data.user ??
|
||||||
({
|
({
|
||||||
uuid: data.uuid,
|
|
||||||
email: data.email ?? email,
|
email: data.email ?? email,
|
||||||
displayName: data.displayName ?? displayName ?? null,
|
displayName: data.displayName ?? displayName ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
@@ -210,9 +177,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
persistAuth(null, null);
|
persistAuth(null, null);
|
||||||
|
|
||||||
// Clear server session cookies
|
|
||||||
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
|
|
||||||
}, [persistAuth]);
|
}, [persistAuth]);
|
||||||
|
|
||||||
const getAuthHeaders = useCallback((): HeadersInit => {
|
const getAuthHeaders = useCallback((): HeadersInit => {
|
||||||
@@ -227,7 +191,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
getAuthHeaders,
|
getAuthHeaders,
|
||||||
setSession,
|
setSession, // ✅ important
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
+9
-34
@@ -12,40 +12,15 @@ const ALWAYS_ALLOW = new Set<string>([
|
|||||||
"/sitemap.xml",
|
"/sitemap.xml",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PUBLIC_FILE =
|
// Match common public/static file extensions so middleware never blocks them
|
||||||
/\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
|
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;
|
|
||||||
|
|
||||||
console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT);
|
|
||||||
|
|
||||||
|
|
||||||
// =========================
|
|
||||||
// 1) Admin gate (always on)
|
|
||||||
// =========================
|
|
||||||
if (pathname.startsWith("/admin")) {
|
|
||||||
const token = req.cookies.get("bb_access_token")?.value;
|
|
||||||
const role = req.cookies.get("bb_role")?.value; // optional
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// =====================================
|
|
||||||
// 2) Launch-only gate (your existing one)
|
|
||||||
// =====================================
|
|
||||||
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
|
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
|
||||||
|
|
||||||
// ✅ Always allow Next internals + static assets
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
// ✅ Always allow Next internals + static assets (public/ and _next/)
|
||||||
if (
|
if (
|
||||||
pathname.startsWith("/_next") ||
|
pathname.startsWith("/_next") ||
|
||||||
pathname.startsWith("/favicon") ||
|
pathname.startsWith("/favicon") ||
|
||||||
@@ -56,17 +31,17 @@ if (pathname.startsWith("/admin")) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ Allow the root + a few public files
|
||||||
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
|
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
|
// 🚫 Everything else: hide it
|
||||||
return NextResponse.rewrite(new URL("/404", req.url));
|
return NextResponse.rewrite(new URL("/404", req.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
` 1`
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
// Run middleware on everything *except* Next internals and obvious static files.
|
||||||
|
// This keeps launch gating from ever breaking your logo/images/fonts/etc.
|
||||||
matcher: [
|
matcher: [
|
||||||
// run on everything except Next internals + obvious static files
|
|
||||||
"/((?!_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)).*)",
|
"/((?!_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)).*)",
|
||||||
],
|
],
|
||||||
|
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user