diff --git a/messages/en.json b/messages/en.json index 0ffefcb..fd5d800 100644 --- a/messages/en.json +++ b/messages/en.json @@ -321,7 +321,9 @@ "users": "Users", "plans": "Plans", "templates": "Templates", - "media": "Media" + "media": "Media", + "discounts": "Discounts", + "siteSettings": "Settings" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index 9933a58..a442a4f 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -321,7 +321,9 @@ "users": "کاربران", "plans": "پلن‌ها", "templates": "قالب‌ها", - "media": "رسانه" + "media": "رسانه", + "discounts": "تخفیف‌ها", + "siteSettings": "تنظیمات سایت" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/src/app/[locale]/admin/discounts/page.tsx b/src/app/[locale]/admin/discounts/page.tsx new file mode 100644 index 0000000..4f02bb9 --- /dev/null +++ b/src/app/[locale]/admin/discounts/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { discountsConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index bc28c04..d923db8 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -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") }, ]; diff --git a/src/app/[locale]/admin/settings/page.tsx b/src/app/[locale]/admin/settings/page.tsx new file mode 100644 index 0000000..635c423 --- /dev/null +++ b/src/app/[locale]/admin/settings/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { WebsiteSettingsAdmin } from "@/components/admin/WebsiteSettingsAdmin"; + +export default function Page() { + return ; +} diff --git a/src/components/admin/WebsiteSettingsAdmin.tsx b/src/components/admin/WebsiteSettingsAdmin.tsx new file mode 100644 index 0000000..ea93cc0 --- /dev/null +++ b/src/components/admin/WebsiteSettingsAdmin.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [form, setForm] = useState(empty); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(null); + const [error, setError] = useState(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 ( +
+
+

Website Settings

+

Key/value site settings. Editing a key overwrites its value (upsert).

+
+ {error &&

{error}

} + +
+

{form.id ? "Edit setting" : "Add / update setting"}

+
+
setForm({ ...form, key: e.target.value })} placeholder="e.g. site_title" />
+
setForm({ ...form, value: e.target.value })} />
+
setForm({ ...form, description: e.target.value })} />
+ +
+
+ + {form.key && } + {msg && {msg}} +
+
+ +
+ + + + + + + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map((s) => ( + + + + + + + + ))} + +
KeyValueDescriptionSecretActions
Loading…
No settings yet. Add one above.
{s.key}{s.is_secret ? "••••••" : (decode(s.value) || "—")}{s.description || "—"}{s.is_secret ? "✓" : "—"} + +
+
+
+ ); +} diff --git a/src/components/admin/admin-resources.tsx b/src/components/admin/admin-resources.tsx index 7d06e93..a2cc494 100644 --- a/src/components/admin/admin-resources.tsx +++ b/src/components/admin/admin-resources.tsx @@ -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" }, + ], +};