@gigamusic/admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Default entry — page components + shared form components. Server-only
2
+ // handler factories live in `./server`; the consumer imports them separately
3
+ // to keep "use client" boundaries clean.
4
+
5
+ export { AdminLayout } from "./pages/AdminLayout";
6
+ export { AdminHomePage } from "./pages/AdminHomePage";
7
+ export { AdminLoginPage } from "./pages/AdminLoginPage";
8
+ export { AdminReleasesPage } from "./pages/AdminReleasesPage";
9
+ export { AdminNewReleasePage } from "./pages/AdminNewReleasePage";
10
+ export { AdminEditReleasePage } from "./pages/AdminEditReleasePage";
11
+ export { AdminLinksPage } from "./pages/AdminLinksPage";
12
+ export { AdminSettingsPage } from "./pages/AdminSettingsPage";
13
+ export { AdminOrdersPage } from "./pages/AdminOrdersPage";
14
+
15
+ export { ReleaseForm } from "./components/ReleaseForm";
16
+ export type { ReleaseFormProps } from "./components/ReleaseForm";
17
+ export { AdminLoginForm } from "./components/AdminLoginForm";
18
+ export { AdminNav } from "./components/AdminNav";
19
+
20
+ export type {
21
+ AdminDeps,
22
+ AdminLogger,
23
+ RouteHandler,
24
+ } from "./lib/types";
@@ -0,0 +1,31 @@
1
+ import type { Queries } from "@gigamusic/db";
2
+
3
+ /**
4
+ * Extract a best-effort client IP for rate-limit bucketing. Honours common
5
+ * Vercel/Cloudflare forward headers and falls back to a constant so the
6
+ * handler still rate-limits the entire route when no IP is available.
7
+ */
8
+ export function clientIp(req: Request): string {
9
+ const forwarded = req.headers.get("x-forwarded-for");
10
+ if (forwarded) {
11
+ const first = forwarded.split(",")[0]?.trim();
12
+ if (first) return first;
13
+ }
14
+ const real = req.headers.get("x-real-ip");
15
+ if (real) return real;
16
+ return "unknown";
17
+ }
18
+
19
+ const LOGIN_MAX_ATTEMPTS = 10;
20
+ const LOGIN_WINDOW_MS = 15 * 60 * 1000;
21
+
22
+ /** Consume one slot in the admin-login bucket for `ip`. Returns false when over the limit. */
23
+ export function consumeAdminLoginAttempt(
24
+ queries: Queries,
25
+ ip: string,
26
+ ): Promise<{ ok: boolean; remaining: number; resetAt: Date }> {
27
+ return queries.consumeRateLimit(`admin-login:${ip}`, {
28
+ max: LOGIN_MAX_ATTEMPTS,
29
+ windowMs: LOGIN_WINDOW_MS,
30
+ });
31
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Tiny JSON response helpers. Avoids the package depending on `NextResponse`
3
+ * — every handler factory returns plain `Response` objects, which Next.js's
4
+ * App Router accepts identically.
5
+ */
6
+
7
+ export function json(body: unknown, init?: ResponseInit): Response {
8
+ return new Response(JSON.stringify(body), {
9
+ ...init,
10
+ headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
11
+ });
12
+ }
13
+
14
+ export const unauthorized = () => json({ error: "Unauthorized" }, { status: 401 });
15
+ export const badRequest = (error: string, extra: Record<string, unknown> = {}) =>
16
+ json({ error, ...extra }, { status: 400 });
17
+ export const notFound = (error = "Not found") => json({ error }, { status: 404 });
18
+ export const serverError = (error: string, extra: Record<string, unknown> = {}) =>
19
+ json({ error, ...extra }, { status: 500 });
@@ -0,0 +1,89 @@
1
+ import { cookies } from "next/headers";
2
+ import {
3
+ createAdminSessionToken,
4
+ verifyAdminSessionToken,
5
+ } from "@gigamusic/core";
6
+
7
+ export const ADMIN_SESSION_COOKIE = "admin_session";
8
+ const SESSION_DURATION_SECONDS = 60 * 60 * 24;
9
+
10
+ /** Default cookie options consumed by every Set-Cookie path. */
11
+ function cookieOptions(maxAge: number) {
12
+ return {
13
+ httpOnly: true,
14
+ secure: true,
15
+ sameSite: "lax" as const,
16
+ maxAge,
17
+ path: "/",
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Mint a fresh admin session token and write the httpOnly cookie. Called
23
+ * after a successful password check from `createAdminLoginHandler`.
24
+ */
25
+ export async function writeAdminSessionCookie(
26
+ adminSessionSecret: string,
27
+ ): Promise<void> {
28
+ const token = await createAdminSessionToken({ secret: adminSessionSecret });
29
+ const jar = await cookies();
30
+ jar.set(ADMIN_SESSION_COOKIE, token, cookieOptions(SESSION_DURATION_SECONDS));
31
+ }
32
+
33
+ /** Clear the admin session cookie (logout). */
34
+ export async function clearAdminSessionCookie(): Promise<void> {
35
+ const jar = await cookies();
36
+ jar.set(ADMIN_SESSION_COOKIE, "", cookieOptions(0));
37
+ }
38
+
39
+ /**
40
+ * Check the current request's cookie jar for a valid admin session.
41
+ *
42
+ * Returns `true` if the cookie verifies; `false` for missing or invalid tokens.
43
+ * Never throws — callers can branch on the boolean.
44
+ */
45
+ export async function isAdminAuthenticated(
46
+ adminSessionSecret: string,
47
+ ): Promise<boolean> {
48
+ const jar = await cookies();
49
+ const token = jar.get(ADMIN_SESSION_COOKIE)?.value;
50
+ if (!token) return false;
51
+ try {
52
+ await verifyAdminSessionToken(token, adminSessionSecret);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Stateless verification for handlers that already have a `Request`. Reads the
61
+ * cookie out of the request's `Cookie` header so the function works in
62
+ * middleware-like contexts where `next/headers` is unavailable.
63
+ */
64
+ export async function verifyAdminSession(
65
+ req: Request,
66
+ adminSessionSecret: string,
67
+ ): Promise<boolean> {
68
+ const header = req.headers.get("cookie");
69
+ if (!header) return false;
70
+ const token = parseCookieHeader(header, ADMIN_SESSION_COOKIE);
71
+ if (!token) return false;
72
+ try {
73
+ await verifyAdminSessionToken(token, adminSessionSecret);
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /** Tiny single-cookie reader. Avoids pulling in a cookie parser dependency. */
81
+ function parseCookieHeader(header: string, name: string): string | null {
82
+ const parts = header.split(";");
83
+ for (const raw of parts) {
84
+ const trimmed = raw.trim();
85
+ if (!trimmed.startsWith(`${name}=`)) continue;
86
+ return decodeURIComponent(trimmed.slice(name.length + 1));
87
+ }
88
+ return null;
89
+ }
@@ -0,0 +1,45 @@
1
+ import type { Queries } from "@gigamusic/db";
2
+ import type { StorageProvider } from "@gigamusic/storage";
3
+ import type * as Audio from "@gigamusic/audio";
4
+ import type { EmailBranding } from "@gigamusic/email";
5
+
6
+ /**
7
+ * The Next.js App Router route-handler signature. Defined locally so the admin
8
+ * package doesn't have to depend on Next.js's internal `NextRequest` re-export
9
+ * — handler factories return ordinary `Request → Promise<Response>` callables.
10
+ */
11
+ export type RouteHandler = (
12
+ req: Request,
13
+ context?: { params: Promise<Record<string, string>> },
14
+ ) => Promise<Response>;
15
+
16
+ /**
17
+ * Aggregate dependency bag for every admin route handler factory. The consumer
18
+ * builds this once at startup and passes a slice (or the whole bag) into each
19
+ * factory; nothing inside the admin package reads `process.env` directly.
20
+ */
21
+ export interface AdminDeps {
22
+ queries: Queries;
23
+ storage: StorageProvider;
24
+ audio: typeof Audio;
25
+ adminPasswordHash: string;
26
+ adminSessionSecret: string;
27
+ branding: EmailBranding;
28
+ /**
29
+ * Optional logger. Defaults to a no-op so the package never writes to
30
+ * console on the consumer's behalf.
31
+ */
32
+ logger?: AdminLogger;
33
+ }
34
+
35
+ export interface AdminLogger {
36
+ info(message: string, meta?: Record<string, unknown>): void;
37
+ warn(message: string, meta?: Record<string, unknown>): void;
38
+ error(message: string, meta?: Record<string, unknown>): void;
39
+ }
40
+
41
+ export const noopLogger: AdminLogger = {
42
+ info() {},
43
+ warn() {},
44
+ error() {},
45
+ };
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ReleaseForm, type ReleaseFormProps } from "../components/ReleaseForm";
5
+
6
+ /**
7
+ * Edit-release page. The consumer's route handler hands us the `id` from
8
+ * `params`; we fetch the full record from `/api/admin/releases/{id}` and
9
+ * mount `<ReleaseForm>` once it's loaded.
10
+ */
11
+ export function AdminEditReleasePage({ id }: { id: string }) {
12
+ const [release, setRelease] = useState<ReleaseFormProps["release"] | null>(
13
+ null,
14
+ );
15
+ const [error, setError] = useState("");
16
+
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+ fetch(`/api/admin/releases/${id}`)
20
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Load failed"))))
21
+ .then((data) => {
22
+ if (!cancelled) setRelease(data as ReleaseFormProps["release"]);
23
+ })
24
+ .catch((err) => !cancelled && setError(String(err)));
25
+ return () => {
26
+ cancelled = true;
27
+ };
28
+ }, [id]);
29
+
30
+ if (error) return <p className="text-sm text-destructive">{error}</p>;
31
+ if (!release) return <p className="text-sm text-muted-foreground">Loading…</p>;
32
+
33
+ return (
34
+ <div className="max-w-2xl">
35
+ <h1 className="mb-6 text-2xl font-semibold">{release.name}</h1>
36
+ <ReleaseForm release={release} />
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+
5
+ /**
6
+ * Default admin landing page. Just links into the major sections. Consumers
7
+ * who want to drop directly to releases can simply re-export
8
+ * `AdminReleasesPage` from `/admin/page.tsx` instead.
9
+ */
10
+ export function AdminHomePage() {
11
+ return (
12
+ <div className="space-y-6">
13
+ <h1 className="text-2xl font-semibold">Admin</h1>
14
+ <ul className="space-y-2 text-sm">
15
+ <li>
16
+ <Link href="/admin/releases" className="hover:underline">
17
+ Releases
18
+ </Link>
19
+ </li>
20
+ <li>
21
+ <Link href="/admin/orders" className="hover:underline">
22
+ Orders
23
+ </Link>
24
+ </li>
25
+ <li>
26
+ <Link href="/admin/links" className="hover:underline">
27
+ Links
28
+ </Link>
29
+ </li>
30
+ <li>
31
+ <Link href="/admin/settings" className="hover:underline">
32
+ Settings
33
+ </Link>
34
+ </li>
35
+ </ul>
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+ import { AdminNav } from "../components/AdminNav";
3
+
4
+ /**
5
+ * Visual chrome for the admin section: nav bar + content container. This
6
+ * component does **not** auth-gate by itself — consumers protect the route
7
+ * via a `proxy.ts` that calls `verifyAdminSession` and redirects to
8
+ * `/admin/login` on failure.
9
+ *
10
+ * Keeping the layout free of auth state lets it stay synchronous and
11
+ * matches the locked contract (`children`-only).
12
+ */
13
+ export function AdminLayout({ children }: { children: ReactNode }) {
14
+ return (
15
+ <div className="gm-admin-shell mx-auto max-w-5xl px-4 py-6">
16
+ <AdminNav />
17
+ {children}
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,187 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useSlot } from "@gigamusic/ui/client";
5
+
6
+ interface LinkItem {
7
+ id: number;
8
+ title: string;
9
+ url: string;
10
+ position: number;
11
+ isVisible: boolean;
12
+ showOnHero: boolean;
13
+ }
14
+
15
+ /**
16
+ * Editor for the global `/links` page link list. Optimistic updates with a
17
+ * 500ms debounced PUT; reordering uses the bulk `orderedIds` endpoint so two
18
+ * swapped neighbors land in a single round-trip.
19
+ */
20
+ export function AdminLinksPage() {
21
+ const Button = useSlot("Button");
22
+ const [links, setLinks] = useState<LinkItem[]>([]);
23
+ const [newTitle, setNewTitle] = useState("");
24
+ const [newUrl, setNewUrl] = useState("");
25
+ const [loading, setLoading] = useState(true);
26
+
27
+ useEffect(() => {
28
+ let cancelled = false;
29
+ fetch("/api/admin/links")
30
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Load failed"))))
31
+ .then((data: LinkItem[]) => {
32
+ if (!cancelled) {
33
+ setLinks(data);
34
+ setLoading(false);
35
+ }
36
+ })
37
+ .catch(() => !cancelled && setLoading(false));
38
+ return () => {
39
+ cancelled = true;
40
+ };
41
+ }, []);
42
+
43
+ async function addLink() {
44
+ if (!newTitle || !newUrl) return;
45
+ const res = await fetch("/api/admin/links", {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ title: newTitle, url: newUrl }),
49
+ });
50
+ if (res.ok) {
51
+ const link = (await res.json()) as LinkItem;
52
+ setLinks((prev) => [...prev, link]);
53
+ setNewTitle("");
54
+ setNewUrl("");
55
+ }
56
+ }
57
+
58
+ async function saveLink(link: LinkItem) {
59
+ await fetch("/api/admin/links", {
60
+ method: "PUT",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify(link),
63
+ });
64
+ }
65
+
66
+ async function deleteLink(id: number) {
67
+ await fetch(`/api/admin/links?id=${id}`, { method: "DELETE" });
68
+ setLinks((prev) => prev.filter((l) => l.id !== id));
69
+ }
70
+
71
+ function updateField<K extends keyof LinkItem>(
72
+ id: number,
73
+ field: K,
74
+ value: LinkItem[K],
75
+ ) {
76
+ setLinks((prev) => {
77
+ const next = prev.map((l) => (l.id === id ? { ...l, [field]: value } : l));
78
+ const updated = next.find((l) => l.id === id);
79
+ if (updated) void saveLink(updated);
80
+ return next;
81
+ });
82
+ }
83
+
84
+ async function moveLink(index: number, direction: "up" | "down") {
85
+ const swap = direction === "up" ? index - 1 : index + 1;
86
+ if (swap < 0 || swap >= links.length) return;
87
+ const next = [...links];
88
+ [next[index]!, next[swap]!] = [next[swap]!, next[index]!];
89
+ setLinks(next);
90
+ await fetch("/api/admin/links", {
91
+ method: "PUT",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ orderedIds: next.map((l) => l.id) }),
94
+ });
95
+ }
96
+
97
+ if (loading) return <p className="text-sm text-muted-foreground">Loading…</p>;
98
+
99
+ return (
100
+ <div className="space-y-6">
101
+ <h1 className="text-2xl font-semibold">Links</h1>
102
+ <p className="text-sm text-muted-foreground">
103
+ Edits the global <code>/links</code> page and hero links on the homepage.
104
+ </p>
105
+
106
+ <div className="space-y-3">
107
+ {links.map((link, index) => (
108
+ <div
109
+ key={link.id}
110
+ className="flex items-start gap-3 rounded border border-border p-3"
111
+ >
112
+ <div className="flex flex-col gap-1">
113
+ <Button
114
+ variant="ghost"
115
+ size="icon-xs"
116
+ disabled={index === 0}
117
+ onClick={() => moveLink(index, "up")}
118
+ >
119
+
120
+ </Button>
121
+ <Button
122
+ variant="ghost"
123
+ size="icon-xs"
124
+ disabled={index === links.length - 1}
125
+ onClick={() => moveLink(index, "down")}
126
+ >
127
+
128
+ </Button>
129
+ </div>
130
+ <div className="flex flex-1 flex-wrap items-center gap-2">
131
+ <input
132
+ value={link.title}
133
+ onChange={(e) => updateField(link.id, "title", e.target.value)}
134
+ placeholder="Title"
135
+ className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
136
+ />
137
+ <input
138
+ value={link.url}
139
+ onChange={(e) => updateField(link.id, "url", e.target.value)}
140
+ placeholder="URL"
141
+ className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
142
+ />
143
+ <Button
144
+ variant="ghost"
145
+ size="sm"
146
+ onClick={() => updateField(link.id, "isVisible", !link.isVisible)}
147
+ >
148
+ {link.isVisible ? "Visible" : "Hidden"}
149
+ </Button>
150
+ <label className="flex items-center gap-1 text-xs text-muted-foreground">
151
+ <input
152
+ type="checkbox"
153
+ checked={link.showOnHero}
154
+ onChange={() =>
155
+ updateField(link.id, "showOnHero", !link.showOnHero)
156
+ }
157
+ />
158
+ homepage
159
+ </label>
160
+ <Button variant="destructive" size="sm" onClick={() => deleteLink(link.id)}>
161
+ Delete
162
+ </Button>
163
+ </div>
164
+ </div>
165
+ ))}
166
+ </div>
167
+
168
+ <div className="flex flex-wrap items-center gap-2 rounded border border-dashed border-border p-3">
169
+ <input
170
+ value={newTitle}
171
+ onChange={(e) => setNewTitle(e.target.value)}
172
+ placeholder="Link title"
173
+ className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
174
+ />
175
+ <input
176
+ value={newUrl}
177
+ onChange={(e) => setNewUrl(e.target.value)}
178
+ placeholder="https://..."
179
+ className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 text-sm"
180
+ />
181
+ <Button onClick={addLink} disabled={!newTitle || !newUrl}>
182
+ Add Link
183
+ </Button>
184
+ </div>
185
+ </div>
186
+ );
187
+ }
@@ -0,0 +1,11 @@
1
+ import { AdminLoginForm } from "../components/AdminLoginForm";
2
+
3
+ /** Standalone login page — useful when the consumer wants `/admin/login` separate from layout-driven gating. */
4
+ export function AdminLoginPage() {
5
+ return (
6
+ <div className="gm-admin-shell mx-auto max-w-sm px-4 py-20">
7
+ <h1 className="mb-6 text-center text-2xl font-semibold">Admin Login</h1>
8
+ <AdminLoginForm />
9
+ </div>
10
+ );
11
+ }
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import { ReleaseForm } from "../components/ReleaseForm";
4
+
5
+ export function AdminNewReleasePage() {
6
+ return (
7
+ <div className="max-w-2xl">
8
+ <h1 className="mb-6 text-2xl font-semibold">New Release</h1>
9
+ <ReleaseForm />
10
+ </div>
11
+ );
12
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useSlot } from "@gigamusic/ui/client";
5
+ import { formatCurrency } from "@gigamusic/ui";
6
+
7
+ interface OrderItem {
8
+ id: number;
9
+ release?: { name: string } | null;
10
+ track?: { name: string } | null;
11
+ }
12
+
13
+ interface Order {
14
+ id: number;
15
+ email: string;
16
+ amountTotal: number;
17
+ status: "pending" | "completed" | "failed";
18
+ createdAt: string;
19
+ stripePaymentId: string | null;
20
+ items: OrderItem[];
21
+ }
22
+
23
+ /**
24
+ * Email-lookup orders view. The consumer can swap this for a paginated table
25
+ * backed by a custom `/api/admin/orders` endpoint that supports filtering by
26
+ * date range; the minimum surface here is "find a customer's orders to
27
+ * troubleshoot a missing download."
28
+ */
29
+ export function AdminOrdersPage() {
30
+ const Button = useSlot("Button");
31
+ const [email, setEmail] = useState("");
32
+ const [orders, setOrders] = useState<Order[] | null>(null);
33
+ const [loading, setLoading] = useState(false);
34
+ const [error, setError] = useState("");
35
+
36
+ async function search(e: React.FormEvent) {
37
+ e.preventDefault();
38
+ if (!email) return;
39
+ setLoading(true);
40
+ setError("");
41
+ try {
42
+ const res = await fetch(
43
+ `/api/admin/orders?email=${encodeURIComponent(email)}`,
44
+ );
45
+ if (!res.ok) throw new Error(`Search failed (${res.status})`);
46
+ setOrders((await res.json()) as Order[]);
47
+ } catch (err) {
48
+ setError(err instanceof Error ? err.message : String(err));
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ }
53
+
54
+ return (
55
+ <div>
56
+ <h1 className="mb-6 text-2xl font-semibold">Orders</h1>
57
+ <form onSubmit={search} className="mb-6 flex gap-2">
58
+ <input
59
+ type="email"
60
+ value={email}
61
+ onChange={(e) => setEmail(e.target.value)}
62
+ placeholder="Search by email..."
63
+ className="flex-1 rounded border border-border bg-background px-3 py-2 text-sm"
64
+ />
65
+ <Button type="submit" disabled={loading}>
66
+ {loading ? "Searching..." : "Search"}
67
+ </Button>
68
+ </form>
69
+
70
+ {error && <p className="text-sm text-destructive">{error}</p>}
71
+
72
+ {orders && (
73
+ <table className="w-full text-sm">
74
+ <thead>
75
+ <tr className="border-b border-border text-left">
76
+ <th className="py-2">ID</th>
77
+ <th className="py-2">Date</th>
78
+ <th className="py-2">Items</th>
79
+ <th className="py-2 text-right">Amount</th>
80
+ <th className="py-2 text-right">Status</th>
81
+ <th className="py-2 text-right">Stripe</th>
82
+ </tr>
83
+ </thead>
84
+ <tbody>
85
+ {orders.map((order) => (
86
+ <tr key={order.id} className="border-b border-border/40">
87
+ <td className="py-2 font-mono text-xs">#{order.id}</td>
88
+ <td className="py-2">
89
+ {new Date(order.createdAt).toLocaleDateString()}
90
+ </td>
91
+ <td className="py-2 max-w-[200px] truncate text-muted-foreground">
92
+ {order.items
93
+ .map((i) => i.release?.name ?? i.track?.name ?? "—")
94
+ .join(", ")}
95
+ </td>
96
+ <td className="py-2 text-right">
97
+ {formatCurrency(order.amountTotal)}
98
+ </td>
99
+ <td className="py-2 text-right">{order.status}</td>
100
+ <td className="py-2 text-right">
101
+ {order.stripePaymentId ? (
102
+ <a
103
+ href={`https://dashboard.stripe.com/payments/${order.stripePaymentId}`}
104
+ rel="noopener noreferrer"
105
+ className="text-xs hover:underline"
106
+ >
107
+ View
108
+ </a>
109
+ ) : (
110
+ <span className="text-xs text-muted-foreground">—</span>
111
+ )}
112
+ </td>
113
+ </tr>
114
+ ))}
115
+ {orders.length === 0 && (
116
+ <tr>
117
+ <td colSpan={6} className="py-8 text-center text-muted-foreground">
118
+ No orders found.
119
+ </td>
120
+ </tr>
121
+ )}
122
+ </tbody>
123
+ </table>
124
+ )}
125
+ </div>
126
+ );
127
+ }