feat(portal+admin): shared shell, customer portal (dashboard nav, subscriptions, settings) + admin portal (order queue w/ status actions, source registry) + admin/subscriptions APIs
PortalShell (sidebar + session bar via /api/auth/me + logout). Customer: /portal nav, /portal/subscriptions (create feed), /portal/settings (dev-login + DPA). Admin: /admin (metrics + order queue with inline status advance), /admin/sources. APIs: /api/admin/metrics, /api/subscriptions. tsc 0, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { PortalShell, StatusPill } from "@/components/portal-shell";
|
||||
import { LoadingSkeleton, ErrorState, EmptyState } from "@/components/states";
|
||||
import { formatSEK } from "@/lib/data";
|
||||
|
||||
interface Order {
|
||||
order_id: string;
|
||||
flow: string;
|
||||
status: string;
|
||||
total_ore: number;
|
||||
deposit_ore: number;
|
||||
created_at: string;
|
||||
}
|
||||
interface Metrics {
|
||||
total: number;
|
||||
in_production: number;
|
||||
review: number;
|
||||
delivered: number;
|
||||
deposit_paid_ore: number;
|
||||
pipeline_ore: number;
|
||||
}
|
||||
|
||||
const NEXT_STATUS: Record<string, string> = {
|
||||
draft: "quoted",
|
||||
quoted: "in_production",
|
||||
in_production: "review",
|
||||
review: "accepted",
|
||||
accepted: "delivered",
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||
const [metrics, setMetrics] = useState<Metrics | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false);
|
||||
try {
|
||||
const [o, m] = await Promise.all([
|
||||
fetch("/api/orders", { cache: "no-store" }).then((r) => r.json()),
|
||||
fetch("/api/admin/metrics", { cache: "no-store" }).then((r) => r.json()),
|
||||
]);
|
||||
setOrders(Array.isArray(o) ? o : []);
|
||||
setMetrics(m);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function advance(id: string, status: string) {
|
||||
const next = NEXT_STATUS[status];
|
||||
if (!next) return;
|
||||
await fetch(`/api/orders/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: next }) });
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalShell variant="admin" title="Orderkö & drift">
|
||||
{error ? (
|
||||
<ErrorState onRetry={load} />
|
||||
) : !metrics || !orders ? (
|
||||
<LoadingSkeleton rows={6} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-px border border-border bg-border md:grid-cols-5">
|
||||
{[
|
||||
["Ordrar", String(metrics.total)],
|
||||
["I produktion", String(metrics.in_production)],
|
||||
["Granskas", String(metrics.review)],
|
||||
["Levererade", String(metrics.delivered)],
|
||||
["Förskott in", formatSEK((metrics.deposit_paid_ore || 0) / 100)],
|
||||
].map(([l, v]) => (
|
||||
<div key={l} className="bg-background p-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">{l}</p>
|
||||
<p className="mt-2 text-2xl font-medium tabular-nums tracking-[-0.02em]">{v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Orderkö</h2>
|
||||
{orders.length === 0 ? (
|
||||
<div className="mt-6"><EmptyState title="Tom kö." line="Inga ordrar än." /></div>
|
||||
) : (
|
||||
<div className="mt-4 border border-border">
|
||||
<div className="grid grid-cols-[1.4fr_1.2fr_1fr_1fr_1.2fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
<span>ID</span><span>Flöde</span><span>Status</span><span className="text-right">Summa</span><span className="text-right">Åtgärd</span>
|
||||
</div>
|
||||
{orders.map((o) => (
|
||||
<div key={o.order_id} className="grid grid-cols-[1.4fr_1.2fr_1fr_1fr_1.2fr] items-center border-b border-border px-4 py-3 last:border-b-0">
|
||||
<span className="font-mono text-[12px] tabular-nums">{o.order_id.slice(0, 8)}…</span>
|
||||
<span className="text-sm">{o.flow}</span>
|
||||
<StatusPill status={o.status} />
|
||||
<span className="text-right font-mono text-[12px] tabular-nums">{formatSEK(o.total_ore / 100)}</span>
|
||||
<span className="text-right">
|
||||
{NEXT_STATUS[o.status] ? (
|
||||
<button
|
||||
onClick={() => advance(o.order_id, o.status)}
|
||||
className="border border-foreground px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] transition-colors hover:bg-foreground hover:text-background"
|
||||
>
|
||||
→ {NEXT_STATUS[o.status]}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">klar</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</PortalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { PortalShell } from "@/components/portal-shell";
|
||||
import { LoadingSkeleton, ErrorState } from "@/components/states";
|
||||
|
||||
interface Source {
|
||||
source_id: string;
|
||||
name: string;
|
||||
market: string;
|
||||
access: string;
|
||||
licence: string;
|
||||
cadence: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
const ACCESS_COLOR: Record<string, string> = {
|
||||
api: "var(--prov-3)", bulk: "var(--prov-2)", authorized_web: "var(--prov-1)", licensed: "var(--prov-0)",
|
||||
};
|
||||
|
||||
export default function AdminSourcesPage() {
|
||||
const [sources, setSources] = useState<Source[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false);
|
||||
try {
|
||||
const r = await fetch("/api/sources", { cache: "no-store" });
|
||||
setSources(await r.json());
|
||||
} catch { setError(true); }
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
return (
|
||||
<PortalShell variant="admin" title="Källregister & färskhet">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Laglig sourcing. API > bulk > auktoriserad webb > licens. Verifiera villkor.
|
||||
</p>
|
||||
{error ? (
|
||||
<div className="mt-6"><ErrorState onRetry={load} /></div>
|
||||
) : !sources ? (
|
||||
<div className="mt-6"><LoadingSkeleton rows={8} /></div>
|
||||
) : (
|
||||
<div className="mt-6 border border-border">
|
||||
<div className="grid grid-cols-[1.6fr_0.8fr_1fr_1.6fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
<span>Källa</span><span>Marknad</span><span>Åtkomst</span><span>Licens</span><span>Färskhet</span>
|
||||
</div>
|
||||
{sources.map((s) => (
|
||||
<div key={s.source_id} className="grid grid-cols-[1.6fr_0.8fr_1fr_1.6fr_1fr] items-center border-b border-border px-4 py-3 text-sm last:border-b-0">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<span className="font-mono text-xs">{s.market}</span>
|
||||
<span className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.06em]">
|
||||
<span className="inline-block h-2 w-2" style={{ backgroundColor: ACCESS_COLOR[s.access] ?? "var(--muted-foreground)" }} />
|
||||
{s.access}
|
||||
</span>
|
||||
<span className="truncate pr-3 text-xs text-muted-foreground">{s.licence}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{s.cadence}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PortalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listOrders } from "@/lib/orders-store";
|
||||
|
||||
// GET /api/admin/metrics — order queue aggregates for the admin dashboard.
|
||||
export async function GET() {
|
||||
const orders = await listOrders();
|
||||
const byStatus: Record<string, number> = {};
|
||||
let depositPaidOre = 0;
|
||||
let pipelineOre = 0;
|
||||
for (const o of orders) {
|
||||
byStatus[o.status] = (byStatus[o.status] ?? 0) + 1;
|
||||
if (o.status !== "draft") depositPaidOre += o.deposit_ore ?? 0;
|
||||
pipelineOre += o.total_ore ?? 0;
|
||||
}
|
||||
return NextResponse.json({
|
||||
total: orders.length,
|
||||
by_status: byStatus,
|
||||
in_production: byStatus["in_production"] ?? 0,
|
||||
review: byStatus["review"] ?? 0,
|
||||
delivered: byStatus["delivered"] ?? 0,
|
||||
deposit_paid_ore: depositPaidOre,
|
||||
pipeline_ore: pipelineOre,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSubscription, listSubscriptions } from "@/lib/subscriptions-store";
|
||||
|
||||
// GET /api/subscriptions — feeds list. POST — convert an order to a recurring feed.
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listSubscriptions());
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
if (!body.order_id) {
|
||||
return NextResponse.json({ error: { code: "bad_request", message: "order_id krävs" } }, { status: 400 });
|
||||
}
|
||||
const sub = await createSubscription(body.order_id, body.cadence ?? "weekly", body.webhook_url ?? null);
|
||||
return NextResponse.json(sub, { status: 201 });
|
||||
}
|
||||
@@ -94,6 +94,25 @@ export default function PortalPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav className="flex flex-wrap gap-2 border-b border-border py-4">
|
||||
{[
|
||||
["Översikt", "/portal"],
|
||||
["Prenumerationer", "/portal/subscriptions"],
|
||||
["Filer", "/portal/files"],
|
||||
["Integrationer", "/portal/integrationer"],
|
||||
["Inställningar", "/portal/settings"],
|
||||
["Admin", "/admin"],
|
||||
].map(([l, h]) => (
|
||||
<Link
|
||||
key={h}
|
||||
href={h}
|
||||
className="border border-border px-3 py-1.5 font-mono text-[10.5px] uppercase tracking-[0.1em] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
|
||||
>
|
||||
{l}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Content
|
||||
orders={orders}
|
||||
error={error}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PortalShell } from "@/components/portal-shell";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const [loginEmail, setLoginEmail] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
async function refresh() {
|
||||
const d = await fetch("/api/auth/me").then((r) => r.json()).catch(() => ({ user: null }));
|
||||
setEmail(d?.user?.email ?? null);
|
||||
}
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
async function login() {
|
||||
setMsg("");
|
||||
const r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: loginEmail, key }) });
|
||||
if (r.ok) { setMsg("Inloggad."); void refresh(); }
|
||||
else { const e = await r.json().catch(() => ({})); setMsg(e?.error?.message ?? `Fel (${r.status})`); }
|
||||
}
|
||||
async function logout() { await fetch("/api/auth/logout", { method: "POST" }); void refresh(); }
|
||||
|
||||
return (
|
||||
<PortalShell variant="customer" title="Inställningar">
|
||||
<section className="border border-border p-6">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Konto</p>
|
||||
{email ? (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-sm">Inloggad som <span className="font-mono">{email}</span></span>
|
||||
<button onClick={logout} className="border border-foreground px-4 py-2 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors hover:bg-foreground hover:text-background">Logga ut</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-wrap items-end gap-3">
|
||||
<input value={loginEmail} onChange={(e) => setLoginEmail(e.target.value)} placeholder="e-post" className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
|
||||
<input value={key} onChange={(e) => setKey(e.target.value)} placeholder="dev-nyckel" type="password" className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
|
||||
<button onClick={login} className="bg-foreground px-5 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)]">Logga in</button>
|
||||
{msg ? <span className="font-mono text-[11px] text-muted-foreground">{msg}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 font-mono text-[10.5px] text-muted-foreground">
|
||||
Skarp inloggning (Auth.js + OAuth) aktiveras när databasen är kopplad. Dev-login kräver AUTH_SECRET + DEV_LOGIN_KEY.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-6 border border-border p-6">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Datahantering & DPA</p>
|
||||
<ul className="mt-4 space-y-2.5 text-sm">
|
||||
{[
|
||||
["Härkomst per fält", "Källa, färskhet, konfidens — på varje uppgift."],
|
||||
["GDPR-native", "Laglig grund och DPA per leverans."],
|
||||
["Din data: du pushar", "Read-only roll du skapar. Tidsbegränsad. Raderad efteråt."],
|
||||
["Revisionslogg", "Vad. När. Varför."],
|
||||
].map(([t, s]) => (
|
||||
<li key={t} className="flex items-start gap-2">
|
||||
<span className="mt-0.5" style={{ color: "var(--prov-3)" }}>✓</span>
|
||||
<span><span className="font-medium">{t}.</span> <span className="text-muted-foreground">{s}</span></span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</PortalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { PortalShell, StatusPill } from "@/components/portal-shell";
|
||||
import { LoadingSkeleton, EmptyState, ErrorState } from "@/components/states";
|
||||
|
||||
interface Sub {
|
||||
sub_id: string;
|
||||
order_id: string;
|
||||
cadence: string;
|
||||
status: string;
|
||||
webhook_url: string | null;
|
||||
}
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const [subs, setSubs] = useState<Sub[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [orderId, setOrderId] = useState("");
|
||||
const [cadence, setCadence] = useState("weekly");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(false);
|
||||
try { setSubs(await (await fetch("/api/subscriptions", { cache: "no-store" })).json()); }
|
||||
catch { setError(true); }
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function create() {
|
||||
if (!orderId || busy) return;
|
||||
setBusy(true);
|
||||
await fetch("/api/subscriptions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order_id: orderId, cadence }) }).catch(() => {});
|
||||
setOrderId("");
|
||||
setBusy(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalShell variant="customer" title="Prenumerationer & flöden">
|
||||
<div className="border border-border p-5">
|
||||
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-muted-foreground">Gör om en order till ett flöde</p>
|
||||
<div className="mt-4 flex flex-wrap items-end gap-3">
|
||||
<input value={orderId} onChange={(e) => setOrderId(e.target.value)} placeholder="order_id" className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground" />
|
||||
<select value={cadence} onChange={(e) => setCadence(e.target.value)} className="border border-border bg-input px-3 py-2 font-mono text-xs outline-none focus:border-foreground">
|
||||
<option value="weekly">Veckovis</option>
|
||||
<option value="daily">Dagligt</option>
|
||||
</select>
|
||||
<button onClick={create} disabled={busy || !orderId} className="bg-foreground px-5 py-2 font-mono text-[11px] uppercase tracking-[0.1em] text-background transition-colors hover:bg-[var(--accent-red)] disabled:opacity-50">
|
||||
{busy ? "Skapar…" : "Skapa flöde"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{error ? <ErrorState onRetry={load} /> : !subs ? <LoadingSkeleton rows={4} /> : subs.length === 0 ? (
|
||||
<EmptyState title="Inga flöden än." line="Gör om en order till ett återkommande flöde." />
|
||||
) : (
|
||||
<div className="border border-border">
|
||||
<div className="grid grid-cols-[1.6fr_1fr_1fr_1fr] border-b border-border bg-secondary/50 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
<span>Order</span><span>Kadens</span><span>Status</span><span>Webhook</span>
|
||||
</div>
|
||||
{subs.map((s) => (
|
||||
<div key={s.sub_id} className="grid grid-cols-[1.6fr_1fr_1fr_1fr] items-center border-b border-border px-4 py-3 text-sm last:border-b-0">
|
||||
<span className="font-mono text-[12px] tabular-nums">{s.order_id.slice(0, 8)}…</span>
|
||||
<span className="font-mono text-xs">{s.cadence}</span>
|
||||
<StatusPill status={s.status} />
|
||||
<span className="truncate font-mono text-[11px] text-muted-foreground">{s.webhook_url ?? "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type NavItem = { label: string; href: string };
|
||||
|
||||
const CUSTOMER_NAV: NavItem[] = [
|
||||
{ label: "Översikt", href: "/portal" },
|
||||
{ label: "Prenumerationer", href: "/portal/subscriptions" },
|
||||
{ label: "Filer", href: "/portal/files" },
|
||||
{ label: "Integrationer", href: "/portal/integrationer" },
|
||||
{ label: "Inställningar", href: "/portal/settings" },
|
||||
];
|
||||
|
||||
const ADMIN_NAV: NavItem[] = [
|
||||
{ label: "Översikt", href: "/admin" },
|
||||
{ label: "Källor", href: "/admin/sources" },
|
||||
{ label: "Jobb", href: "/admin/jobs" },
|
||||
{ label: "Kunder", href: "/admin/customers" },
|
||||
];
|
||||
|
||||
export function PortalShell({
|
||||
variant,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
variant: "customer" | "admin";
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const nav = variant === "admin" ? ADMIN_NAV : CUSTOMER_NAV;
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/me")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setEmail(d?.user?.email ?? null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function logout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||||
setEmail(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* top bar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-background px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tracking-[-0.04em]">0pt</span>
|
||||
<span className="inline-block h-1 w-1" style={{ backgroundColor: "var(--accent-red)" }} />
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{variant === "admin" ? "admin" : "portal"}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
{email ? (
|
||||
<>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{email}</span>
|
||||
<button onClick={logout} className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
Logga ut
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link href="/portal/settings" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
Logga in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-[80rem] flex-col gap-0 md:flex-row">
|
||||
{/* sidebar */}
|
||||
<aside className="shrink-0 border-b border-border md:w-56 md:border-b-0 md:border-r">
|
||||
<nav className="flex flex-row gap-0 overflow-x-auto p-2 md:flex-col md:p-4">
|
||||
{nav.map((n) => {
|
||||
const active = pathname === n.href;
|
||||
return (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
className="border-l-2 px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors"
|
||||
style={{
|
||||
borderColor: active ? "var(--accent-red)" : "transparent",
|
||||
color: active ? "var(--foreground)" : "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{variant === "customer" ? (
|
||||
<Link href="/bestall" className="mt-2 bg-foreground px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-background hover:bg-[var(--accent-red)]">
|
||||
Ny beställning
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/portal" className="mt-2 border border-border px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
← Kundvy
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* content */}
|
||||
<main className="min-w-0 flex-1 px-6 py-10">
|
||||
<h1 className="text-3xl font-medium tracking-[-0.03em] md:text-4xl">{title}</h1>
|
||||
<div className="mt-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "delivered" || status === "accepted" ? "var(--prov-3)"
|
||||
: status === "in_production" || status === "review" ? "var(--accent-red)"
|
||||
: "var(--muted-foreground)";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.08em]">
|
||||
<span className="inline-block h-2 w-2" style={{ backgroundColor: color }} />
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getSql } from "@/lib/db";
|
||||
|
||||
export interface Subscription {
|
||||
sub_id: string;
|
||||
order_id: string;
|
||||
cadence: string;
|
||||
webhook_url: string | null;
|
||||
status: string;
|
||||
next_run_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const mem = new Map<string, Subscription>();
|
||||
|
||||
export async function createSubscription(order_id: string, cadence: string, webhook_url: string | null): Promise<Subscription> {
|
||||
const sql = getSql();
|
||||
if (sql) {
|
||||
const [row] = await sql<Subscription[]>`
|
||||
INSERT INTO subscriptions (order_id, cadence, webhook_url, status)
|
||||
VALUES (${order_id}, ${cadence}, ${webhook_url}, 'active')
|
||||
RETURNING sub_id, order_id, cadence, webhook_url, status, next_run_at, now() AS created_at`;
|
||||
return row;
|
||||
}
|
||||
const id = globalThis.crypto?.randomUUID?.() ?? `sub-${Math.floor(Math.random() * 1e9)}`;
|
||||
const sub: Subscription = { sub_id: id, order_id, cadence, webhook_url, status: "active", next_run_at: null, created_at: new Date().toISOString() };
|
||||
mem.set(id, sub);
|
||||
return sub;
|
||||
}
|
||||
|
||||
export async function listSubscriptions(): Promise<Subscription[]> {
|
||||
const sql = getSql();
|
||||
if (sql) {
|
||||
return sql<Subscription[]>`SELECT sub_id, order_id, cadence, webhook_url, status, next_run_at, created_at FROM subscriptions ORDER BY created_at DESC LIMIT 100`;
|
||||
}
|
||||
return [...mem.values()];
|
||||
}
|
||||
Reference in New Issue
Block a user