Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d7809ccf1 | |||
| e202d811ed |
@@ -0,0 +1,92 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ const navItems = [
|
|||||||
{ label: "Settings", href: "/admin/settings", icon: <Settings 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: "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: "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" /> },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ const defaultNavItems: AdminNavItem[] = [
|
|||||||
{ label: "Settings", href: "/admin/settings", icon: <Settings 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: "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: "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" />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLeftNavigation({
|
export default function AdminLeftNavigation({
|
||||||
|
|||||||
+4
-1
@@ -18,6 +18,9 @@ const PUBLIC_FILE =
|
|||||||
export function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT);
|
||||||
|
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// 1) Admin gate (always on)
|
// 1) Admin gate (always on)
|
||||||
// =========================
|
// =========================
|
||||||
@@ -55,10 +58,10 @@ if (pathname.startsWith("/admin")) {
|
|||||||
|
|
||||||
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
|
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
|
|
||||||
return NextResponse.rewrite(new URL("/404", req.url));
|
return NextResponse.rewrite(new URL("/404", req.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
` 1`
|
` 1`
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
|
|||||||
Reference in New Issue
Block a user