feat(admin): Discounts and Website Settings sections
- /admin/discounts: list + create discount codes (kind, value, max uses, expiry) via /v1/discounts (backend has no edit/delete API yet) - /admin/settings: key/value site settings with upsert + secret flag. The value column is jsonb, so values are JSON-encoded on save / decoded for display - nav links + fa/en labels Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -321,7 +321,9 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"plans": "Plans",
|
"plans": "Plans",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"media": "Media"
|
"media": "Media",
|
||||||
|
"discounts": "Discounts",
|
||||||
|
"siteSettings": "Settings"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "Render Nodes",
|
"title": "Render Nodes",
|
||||||
|
|||||||
+3
-1
@@ -321,7 +321,9 @@
|
|||||||
"users": "کاربران",
|
"users": "کاربران",
|
||||||
"plans": "پلنها",
|
"plans": "پلنها",
|
||||||
"templates": "قالبها",
|
"templates": "قالبها",
|
||||||
"media": "رسانه"
|
"media": "رسانه",
|
||||||
|
"discounts": "تخفیفها",
|
||||||
|
"siteSettings": "تنظیمات سایت"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "نودهای رندر",
|
"title": "نودهای رندر",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AdminResource } from "@/components/admin/AdminResource";
|
||||||
|
import { discountsConfig } from "@/components/admin/admin-resources";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AdminResource config={discountsConfig} />;
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ export default async function AdminLayout({
|
|||||||
{ href: "/admin/ai", label: t("aiContent") },
|
{ href: "/admin/ai", label: t("aiContent") },
|
||||||
{ href: "/admin/users", label: t("users") },
|
{ href: "/admin/users", label: t("users") },
|
||||||
{ href: "/admin/plans", label: t("plans") },
|
{ href: "/admin/plans", label: t("plans") },
|
||||||
|
{ href: "/admin/discounts", label: t("discounts") },
|
||||||
|
{ href: "/admin/settings", label: t("siteSettings") },
|
||||||
{ href: "/admin/nodes", label: t("nodes") },
|
{ href: "/admin/nodes", label: t("nodes") },
|
||||||
{ href: "/admin/renders", label: t("renderQueue") },
|
{ href: "/admin/renders", label: t("renderQueue") },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { WebsiteSettingsAdmin } from "@/components/admin/WebsiteSettingsAdmin";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <WebsiteSettingsAdmin />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface Setting {
|
||||||
|
id?: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description?: string | null;
|
||||||
|
is_secret?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
||||||
|
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
|
||||||
|
const ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]";
|
||||||
|
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||||
|
const lbl = "mb-1 block text-xs font-medium text-gray-400";
|
||||||
|
|
||||||
|
const empty: Setting = { key: "", value: "", description: "", is_secret: false };
|
||||||
|
|
||||||
|
// The `value` column is jsonb, so values are stored JSON-encoded. Show the decoded
|
||||||
|
// string in the UI; re-encode on save. Plain text round-trips as a JSON string.
|
||||||
|
function decode(v: string): string {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(v);
|
||||||
|
return typeof p === "string" ? p : v;
|
||||||
|
} catch {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebsiteSettingsAdmin() {
|
||||||
|
const [rows, setRows] = useState<Setting[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [form, setForm] = useState<Setting>(empty);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/resource/settings/all", { cache: "no-store" });
|
||||||
|
const data = await res.json();
|
||||||
|
setRows(Array.isArray(data) ? data : data?.items ?? data?.data ?? []);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load settings");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!form.key) return;
|
||||||
|
setSaving(true); setError(null); setMsg(null);
|
||||||
|
const res = await fetch("/api/admin/resource/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
key: form.key, value: JSON.stringify(form.value ?? ""),
|
||||||
|
description: form.description || null, is_secret: !!form.is_secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) { setMsg("Saved"); setForm(empty); reload(); }
|
||||||
|
else { const d = await res.json().catch(() => null); setError(d?.error ?? "Save failed"); }
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Website Settings</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-400">Key/value site settings. Editing a key overwrites its value (upsert).</p>
|
||||||
|
</div>
|
||||||
|
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<section className={`${card} p-5`}>
|
||||||
|
<h2 className="text-sm font-semibold text-white">{form.id ? "Edit setting" : "Add / update setting"}</h2>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div><label className={lbl}>Key *</label><input className={inp} value={form.key} onChange={(e) => setForm({ ...form, key: e.target.value })} placeholder="e.g. site_title" /></div>
|
||||||
|
<div><label className={lbl}>Value</label><input className={inp} value={form.value} onChange={(e) => setForm({ ...form, value: e.target.value })} /></div>
|
||||||
|
<div className="sm:col-span-2"><label className={lbl}>Description</label><input className={inp} value={form.description ?? ""} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||||
|
<input type="checkbox" checked={!!form.is_secret} onChange={(e) => setForm({ ...form, is_secret: e.target.checked })} /> Secret (hidden from public API)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<button className={btn} onClick={save} disabled={saving || !form.key}>{saving ? "Saving…" : "Save setting"}</button>
|
||||||
|
{form.key && <button className={ghost} onClick={() => setForm(empty)}>Clear</button>}
|
||||||
|
{msg && <span className="text-xs text-gray-400">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={`${card} overflow-hidden`}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[#1e2235] text-left text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-3">Key</th><th className="px-4 py-3">Value</th>
|
||||||
|
<th className="px-4 py-3">Description</th><th className="px-4 py-3">Secret</th>
|
||||||
|
<th className="px-4 py-3 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">Loading…</td></tr>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">No settings yet. Add one above.</td></tr>
|
||||||
|
) : rows.map((s) => (
|
||||||
|
<tr key={s.key} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-200">{s.key}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-300">{s.is_secret ? "••••••" : (decode(s.value) || "—")}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{s.description || "—"}</td>
|
||||||
|
<td className="px-4 py-3">{s.is_secret ? "✓" : "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button className={ghost} onClick={() => setForm({ ...s, value: decode(s.value), description: s.description ?? "" })}>Edit</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -175,3 +175,37 @@ export const plansConfig: ResourceConfig = {
|
|||||||
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "off") },
|
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "off") },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const discountsConfig: ResourceConfig = {
|
||||||
|
title: "Discounts",
|
||||||
|
description: "Discount / coupon codes. (Codes are created here; the backend has no edit/delete API yet.)",
|
||||||
|
basePath: "discounts",
|
||||||
|
listKey: "data",
|
||||||
|
canCreate: true,
|
||||||
|
columns: [
|
||||||
|
{ key: "code", label: "Code" },
|
||||||
|
{ key: "kind", label: "Kind" },
|
||||||
|
{ key: "value", label: "Value" },
|
||||||
|
{ key: "used_count", label: "Used" },
|
||||||
|
{ key: "max_use_count", label: "Max uses" },
|
||||||
|
{ key: "is_active", label: "Active", render: (r) => badge(!!r.is_active, "active", "off") },
|
||||||
|
{ key: "expires_at", label: "Expires" },
|
||||||
|
],
|
||||||
|
fields: [
|
||||||
|
{ key: "name", label: "Name", required: true },
|
||||||
|
{ key: "code", label: "Code", required: true },
|
||||||
|
{
|
||||||
|
key: "kind", label: "Kind", type: "select", required: true,
|
||||||
|
options: [
|
||||||
|
{ value: "Percentage", label: "Percentage (%)" },
|
||||||
|
{ value: "FixedAmount", label: "Fixed amount" },
|
||||||
|
{ value: "FreeMonths", label: "Free months" },
|
||||||
|
{ value: "RenderCredits", label: "Render credits" },
|
||||||
|
],
|
||||||
|
defaultValue: "Percentage",
|
||||||
|
},
|
||||||
|
{ key: "value", label: "Value", type: "number", required: true },
|
||||||
|
{ key: "max_use_count", label: "Max use count (blank = unlimited)", type: "number" },
|
||||||
|
{ key: "expires_at", label: "Expires at (ISO date, optional)", placeholder: "2026-12-31T00:00:00Z" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user