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",
|
||||
"plans": "Plans",
|
||||
"templates": "Templates",
|
||||
"media": "Media"
|
||||
"media": "Media",
|
||||
"discounts": "Discounts",
|
||||
"siteSettings": "Settings"
|
||||
},
|
||||
"appAdminNodesPage": {
|
||||
"title": "Render Nodes",
|
||||
|
||||
+3
-1
@@ -321,7 +321,9 @@
|
||||
"users": "کاربران",
|
||||
"plans": "پلنها",
|
||||
"templates": "قالبها",
|
||||
"media": "رسانه"
|
||||
"media": "رسانه",
|
||||
"discounts": "تخفیفها",
|
||||
"siteSettings": "تنظیمات سایت"
|
||||
},
|
||||
"appAdminNodesPage": {
|
||||
"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/users", label: t("users") },
|
||||
{ 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/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") },
|
||||
],
|
||||
};
|
||||
|
||||
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