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:
soroush.asadi
2026-06-02 15:20:07 +03:30
parent 163f0c9ec3
commit 9a1d60e9d0
7 changed files with 183 additions and 2 deletions
+3 -1
View File
@@ -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
View File
@@ -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} />;
}
+2
View File
@@ -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") },
]; ];
+7
View File
@@ -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>
);
}
+34
View File
@@ -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" },
],
};