feat(admin): discount edit/delete + project-scoped scene/color editor

Identity (discounts):
- DiscountsController: PUT /v1/discounts/{id}, DELETE /v1/discounts/{id}
- DiscountService.UpdateAsync (partial update, code-clash guard) + DeleteAsync
- UpdateDiscountRequest record (all fields optional incl. is_active)
- Frontend discountsConfig: canEdit + canDelete + is_active field

Content (scenes/colors — UI for existing CRUD endpoints):
- New SceneColorEditor.tsx: 3-tab modal (scenes / shared-colors / color-presets),
  project-scoped, full add/edit/delete per tab, colour pickers + palette item editor
- Wired into TemplatesAdmin: "صحنه‌ها و رنگ‌ها" button per template variant row
- Routes through the generic admin proxy with ?project_id=

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-05 12:16:13 +03:30
parent ac700787bd
commit 67060c73b2
7 changed files with 805 additions and 1 deletions
@@ -85,6 +85,52 @@ public class DiscountService(IdentityDbContext db) : IDiscountService
return MapResponse(discount);
}
public async Task<DiscountResponse?> UpdateAsync(Guid tenantId, Guid id, UpdateDiscountRequest request)
{
var discount = await db.Discounts.FirstOrDefaultAsync(d => d.Id == id && d.TenantId == tenantId);
if (discount == null) return null;
if (request.Name != null) discount.Name = request.Name;
if (request.Code != null)
{
var newCode = request.Code.ToUpper();
if (newCode != discount.Code)
{
var clash = await db.Discounts.AnyAsync(d =>
d.TenantId == tenantId && d.Code == newCode && d.Id != id);
if (clash) throw new InvalidOperationException("Discount code already exists");
discount.Code = newCode;
}
}
if (request.Kind != null)
{
if (!Enum.TryParse<DiscountKind>(request.Kind, true, out var kind))
throw new ArgumentException("Invalid discount kind");
discount.Kind = kind;
}
if (request.Value.HasValue) discount.Value = request.Value.Value;
if (request.OwnerUserId.HasValue) discount.OwnerUserId = request.OwnerUserId;
if (request.OwnerProfitPercentage.HasValue) discount.OwnerProfitPercentage = request.OwnerProfitPercentage.Value;
if (request.MaxUseCount.HasValue) discount.MaxUseCount = request.MaxUseCount;
if (request.AppliesToPlanIds != null) discount.AppliesToPlanIds = request.AppliesToPlanIds;
if (request.StartsAt.HasValue) discount.StartsAt = request.StartsAt;
if (request.ExpiresAt.HasValue) discount.ExpiresAt = request.ExpiresAt;
if (request.IsActive.HasValue) discount.IsActive = request.IsActive.Value;
discount.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapResponse(discount);
}
public async Task<bool> DeleteAsync(Guid tenantId, Guid id)
{
var discount = await db.Discounts.FirstOrDefaultAsync(d => d.Id == id && d.TenantId == tenantId);
if (discount == null) return false;
db.Discounts.Remove(discount);
await db.SaveChangesAsync();
return true;
}
private static DiscountResponse MapResponse(Discount d) => new(
d.Id, d.Name, d.Code, d.Kind.ToString(), d.Value,
d.UsedCount, d.MaxUseCount, d.IsActive, d.ExpiresAt, d.CreatedAt
@@ -8,4 +8,6 @@ public interface IDiscountService
Task<DiscountValidateResponse> ValidateAsync(Guid tenantId, string code, Guid? planId);
Task<PagedResponse<DiscountResponse>> ListAsync(Guid tenantId, int page, int pageSize);
Task<DiscountResponse> CreateAsync(Guid tenantId, CreateDiscountRequest request);
Task<DiscountResponse?> UpdateAsync(Guid tenantId, Guid id, UpdateDiscountRequest request);
Task<bool> DeleteAsync(Guid tenantId, Guid id);
}
@@ -37,6 +37,22 @@ public class DiscountsController(IDiscountService discountService) : ControllerB
return StatusCode(201, result);
}
[HttpPut("{id:guid}")]
[ProducesResponseType(typeof(DiscountResponse), 200)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateDiscountRequest request)
{
var result = await discountService.UpdateAsync(GetTenantId(), id, request);
return result == null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
[ProducesResponseType(204)]
public async Task<IActionResult> Delete(Guid id)
{
var ok = await discountService.DeleteAsync(GetTenantId(), id);
return ok ? NoContent() : NotFound();
}
private Guid GetTenantId() => Guid.Parse(User.FindFirst("tenant_id")?.Value
?? throw new UnauthorizedAccessException());
@@ -23,6 +23,21 @@ public record CreateDiscountRequest(
DateTime? ExpiresAt = null
);
// Partial update — every field optional; only non-null values are applied.
public record UpdateDiscountRequest(
string? Name = null,
string? Code = null,
string? Kind = null,
decimal? Value = null,
Guid? OwnerUserId = null,
decimal? OwnerProfitPercentage = null,
int? MaxUseCount = null,
Guid[]? AppliesToPlanIds = null,
DateTime? StartsAt = null,
DateTime? ExpiresAt = null,
bool? IsActive = null
);
public record IssueRefundRequest(
[Required] Guid PaymentId,
long? AmountMinor,
+707
View File
@@ -0,0 +1,707 @@
"use client";
import { useCallback, useEffect, useState } from "react";
/**
* Project-scoped editor for scenes, shared colors, and color presets.
* Opened from a template variant (project) row in TemplatesAdmin.
*
* All three resources live in content-svc and are keyed by `project_id`:
* GET/POST/PUT/DELETE /v1/scenes?project_id=
* GET/POST/PUT/DELETE /v1/shared-colors?project_id=
* GET/POST/PUT/DELETE /v1/color-presets?project_id=
*/
interface Scene {
id: string;
project_id: string;
key: string;
title: string;
scene_type: string;
default_duration_sec?: number | null;
sort: number;
is_active: boolean;
}
interface SharedColor {
id: string;
project_id: string;
element_key: string;
title: string;
default_color: string;
sort: number;
}
interface PresetItem {
id?: string;
element_key: string;
value: string;
sort: number;
}
interface ColorPreset {
id: string;
project_id: string;
name?: string | null;
sort: number;
items: PresetItem[];
}
type Tab = "scenes" | "colors" | "presets";
const inp =
"w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2.5 py-1.5 text-xs text-gray-200 placeholder:text-gray-600 focus:border-indigo-500 focus:outline-none";
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-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2b]";
const del =
"rounded-lg border border-red-500/30 px-2 py-1 text-xs text-red-300 hover:bg-red-500/10";
const lbl = "mb-0.5 block text-[10px] text-gray-500";
const SCENE_TYPES = ["Intro", "Main", "Outro", "Transition", "Logo", "Lower-Third"];
export function SceneColorEditor({
projectId,
projectName,
onClose,
}: {
projectId: string;
projectName: string;
onClose: () => void;
}) {
const [tab, setTab] = useState<Tab>("scenes");
const [scenes, setScenes] = useState<Scene[]>([]);
const [colors, setColors] = useState<SharedColor[]>([]);
const [presets, setPresets] = useState<ColorPreset[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const api = (resource: string, suffix = "") =>
`/api/admin/resource/${resource}${suffix}?project_id=${projectId}`;
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [s, c, p] = await Promise.all([
fetch(api("scenes"), { cache: "no-store" }).then((r) => r.json()),
fetch(api("shared-colors"), { cache: "no-store" }).then((r) => r.json()),
fetch(api("color-presets"), { cache: "no-store" }).then((r) => r.json()),
]);
setScenes(Array.isArray(s) ? s : (s?.items ?? []));
setColors(Array.isArray(c) ? c : (c?.items ?? []));
setPresets(Array.isArray(p) ? p : (p?.items ?? []));
} catch {
setError("بارگذاری اطلاعات ناموفق بود");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
useEffect(() => {
reload();
}, [reload]);
// The proxy appends ?project_id= via api(); for writes the body also carries it.
const writeUrl = (resource: string, id?: string) =>
id ? api(resource, `/${id}`) : api(resource);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4">
<div className="flex max-h-[88vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-[#1e2235] bg-[#0a0c16]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
<div>
<h3 className="text-sm font-semibold text-gray-100">صحنهها و رنگها</h3>
<p className="text-[11px] text-gray-500">نسخه: {projectName}</p>
</div>
<button className={ghost} onClick={onClose}>
بستن
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-[#1e2235] px-3 pt-2">
{(
[
["scenes", `صحنه‌ها (${scenes.length})`],
["colors", `رنگ‌های مشترک (${colors.length})`],
["presets", `پالت‌ها (${presets.length})`],
] as [Tab, string][]
).map(([key, label]) => (
<button
key={key}
onClick={() => setTab(key)}
className={
tab === key
? "rounded-t-lg border-b-2 border-indigo-500 px-3 py-2 text-xs font-medium text-white"
: "px-3 py-2 text-xs text-gray-500 hover:text-gray-300"
}
>
{label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">
{error && (
<p className="mb-3 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{error}
</p>
)}
{loading ? (
<p className="py-8 text-center text-xs text-gray-500">در حال بارگذاری</p>
) : tab === "scenes" ? (
<ScenesTab
projectId={projectId}
scenes={scenes}
writeUrl={writeUrl}
onChange={reload}
setError={setError}
/>
) : tab === "colors" ? (
<ColorsTab
projectId={projectId}
colors={colors}
writeUrl={writeUrl}
onChange={reload}
setError={setError}
/>
) : (
<PresetsTab
projectId={projectId}
presets={presets}
writeUrl={writeUrl}
onChange={reload}
setError={setError}
/>
)}
</div>
</div>
</div>
);
}
// ── Scenes ────────────────────────────────────────────────────────────────────
function ScenesTab({
projectId,
scenes,
writeUrl,
onChange,
setError,
}: {
projectId: string;
scenes: Scene[];
writeUrl: (r: string, id?: string) => string;
onChange: () => void;
setError: (s: string | null) => void;
}) {
const empty = { key: "", title: "", scene_type: "Main", default_duration_sec: 5, sort: 0, is_active: true };
const [draft, setDraft] = useState<typeof empty>({ ...empty });
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
const body = { project_id: projectId, ...draft };
const res = await fetch(writeUrl("scenes", editId ?? undefined), {
method: editId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setBusy(false);
if (res.ok) {
setDraft({ ...empty });
setEditId(null);
onChange();
} else {
setError("ذخیرهٔ صحنه ناموفق بود");
}
};
const remove = async (s: Scene) => {
if (!confirm(`صحنهٔ «${s.title}» حذف شود؟`)) return;
const res = await fetch(writeUrl("scenes", s.id), { method: "DELETE" });
if (res.ok) onChange();
};
return (
<div className="space-y-3">
<div className="space-y-1.5">
{scenes.length === 0 && (
<p className="text-xs text-gray-600">هنوز صحنهای تعریف نشده است.</p>
)}
{scenes
.slice()
.sort((a, b) => a.sort - b.sort)
.map((s) => (
<div
key={s.id}
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
>
<span className="rounded bg-indigo-500/15 px-1.5 py-0.5 text-[10px] text-indigo-300">
{s.scene_type}
</span>
<span className="flex-1 truncate text-xs text-gray-200">
{s.title}{" "}
<span className="text-gray-600" dir="ltr">
({s.key})
</span>
</span>
<span className="text-[11px] text-gray-500">{s.default_duration_sec ?? "—"}s</span>
<span className="text-[11px] text-gray-600">#{s.sort}</span>
{!s.is_active && (
<span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[10px] text-gray-400">
غیرفعال
</span>
)}
<button
className={ghost}
onClick={() => {
setEditId(s.id);
setDraft({
key: s.key,
title: s.title,
scene_type: s.scene_type,
default_duration_sec: s.default_duration_sec ?? 5,
sort: s.sort,
is_active: s.is_active,
});
}}
>
ویرایش
</button>
<button className={del} onClick={() => remove(s)}>
حذف
</button>
</div>
))}
</div>
{/* Add / edit form */}
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
<p className="mb-2 text-[11px] font-medium text-gray-400">
{editId ? "ویرایش صحنه" : "افزودن صحنه"}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div>
<label className={lbl}>کلید (یکتا)</label>
<input
className={inp}
dir="ltr"
value={draft.key}
onChange={(e) => setDraft({ ...draft, key: e.target.value })}
/>
</div>
<div>
<label className={lbl}>عنوان</label>
<input
className={inp}
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
</div>
<div>
<label className={lbl}>نوع</label>
<select
className={inp}
value={draft.scene_type}
onChange={(e) => setDraft({ ...draft, scene_type: e.target.value })}
>
{SCENE_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label className={lbl}>مدت پیشفرض (ثانیه)</label>
<input
className={inp}
type="number"
step="0.5"
value={draft.default_duration_sec}
onChange={(e) =>
setDraft({ ...draft, default_duration_sec: Number(e.target.value) })
}
/>
</div>
<div>
<label className={lbl}>ترتیب</label>
<input
className={inp}
type="number"
value={draft.sort}
onChange={(e) => setDraft({ ...draft, sort: Number(e.target.value) })}
/>
</div>
<div className="flex items-end gap-1.5 pb-1">
<input
id="scene-active"
type="checkbox"
checked={draft.is_active}
onChange={(e) => setDraft({ ...draft, is_active: e.target.checked })}
/>
<label htmlFor="scene-active" className="text-xs text-gray-400">
فعال
</label>
</div>
</div>
<div className="mt-2 flex gap-2">
<button className={btn} onClick={submit} disabled={busy || !draft.key || !draft.title}>
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن"}
</button>
{editId && (
<button
className={ghost}
onClick={() => {
setEditId(null);
setDraft({ ...empty });
}}
>
انصراف
</button>
)}
</div>
</div>
</div>
);
}
// ── Shared colors ───────────────────────────────────────────────────────────
function ColorsTab({
projectId,
colors,
writeUrl,
onChange,
setError,
}: {
projectId: string;
colors: SharedColor[];
writeUrl: (r: string, id?: string) => string;
onChange: () => void;
setError: (s: string | null) => void;
}) {
const empty = { element_key: "", title: "", default_color: "#4c6ef5", sort: 0 };
const [draft, setDraft] = useState<typeof empty>({ ...empty });
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
setBusy(true);
setError(null);
const body = { project_id: projectId, attr_value: draft.default_color, ...draft };
const res = await fetch(writeUrl("shared-colors", editId ?? undefined), {
method: editId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setBusy(false);
if (res.ok) {
setDraft({ ...empty });
setEditId(null);
onChange();
} else {
setError("ذخیرهٔ رنگ ناموفق بود");
}
};
const remove = async (c: SharedColor) => {
if (!confirm(`رنگ «${c.title}» حذف شود؟`)) return;
const res = await fetch(writeUrl("shared-colors", c.id), { method: "DELETE" });
if (res.ok) onChange();
};
return (
<div className="space-y-3">
<div className="space-y-1.5">
{colors.length === 0 && (
<p className="text-xs text-gray-600">هنوز رنگ مشترکی تعریف نشده است.</p>
)}
{colors
.slice()
.sort((a, b) => a.sort - b.sort)
.map((c) => (
<div
key={c.id}
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
>
<span
className="h-5 w-5 shrink-0 rounded border border-white/10"
style={{ background: c.default_color }}
/>
<span className="flex-1 truncate text-xs text-gray-200">
{c.title}{" "}
<span className="text-gray-600" dir="ltr">
({c.element_key})
</span>
</span>
<span className="text-[11px] text-gray-500" dir="ltr">
{c.default_color}
</span>
<span className="text-[11px] text-gray-600">#{c.sort}</span>
<button
className={ghost}
onClick={() => {
setEditId(c.id);
setDraft({
element_key: c.element_key,
title: c.title,
default_color: c.default_color,
sort: c.sort,
});
}}
>
ویرایش
</button>
<button className={del} onClick={() => remove(c)}>
حذف
</button>
</div>
))}
</div>
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
<p className="mb-2 text-[11px] font-medium text-gray-400">
{editId ? "ویرایش رنگ" : "افزودن رنگ مشترک"}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<label className={lbl}>کلید عنصر</label>
<input
className={inp}
dir="ltr"
value={draft.element_key}
onChange={(e) => setDraft({ ...draft, element_key: e.target.value })}
/>
</div>
<div>
<label className={lbl}>عنوان</label>
<input
className={inp}
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
</div>
<div>
<label className={lbl}>رنگ</label>
<div className="flex gap-1">
<input
type="color"
className="h-8 w-9 shrink-0 rounded border border-[#262b40] bg-transparent"
value={draft.default_color}
onChange={(e) => setDraft({ ...draft, default_color: e.target.value })}
/>
<input
className={inp}
dir="ltr"
value={draft.default_color}
onChange={(e) => setDraft({ ...draft, default_color: e.target.value })}
/>
</div>
</div>
<div>
<label className={lbl}>ترتیب</label>
<input
className={inp}
type="number"
value={draft.sort}
onChange={(e) => setDraft({ ...draft, sort: Number(e.target.value) })}
/>
</div>
</div>
<div className="mt-2 flex gap-2">
<button
className={btn}
onClick={submit}
disabled={busy || !draft.element_key || !draft.title}
>
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن"}
</button>
{editId && (
<button
className={ghost}
onClick={() => {
setEditId(null);
setDraft({ ...empty });
}}
>
انصراف
</button>
)}
</div>
</div>
</div>
);
}
// ── Color presets ───────────────────────────────────────────────────────────
function PresetsTab({
projectId,
presets,
writeUrl,
onChange,
setError,
}: {
projectId: string;
presets: ColorPreset[];
writeUrl: (r: string, id?: string) => string;
onChange: () => void;
setError: (s: string | null) => void;
}) {
const [name, setName] = useState("");
const [items, setItems] = useState<PresetItem[]>([{ element_key: "", value: "#ffffff", sort: 0 }]);
const [editId, setEditId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reset = () => {
setName("");
setItems([{ element_key: "", value: "#ffffff", sort: 0 }]);
setEditId(null);
};
const submit = async () => {
setBusy(true);
setError(null);
const body = {
project_id: projectId,
name: name || null,
sort: 0,
items: items
.filter((i) => i.element_key)
.map((i, idx) => ({ element_key: i.element_key, value: i.value, sort: idx })),
};
const res = await fetch(writeUrl("color-presets", editId ?? undefined), {
method: editId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setBusy(false);
if (res.ok) {
reset();
onChange();
} else {
setError("ذخیرهٔ پالت ناموفق بود");
}
};
const remove = async (p: ColorPreset) => {
if (!confirm(`پالت حذف شود؟`)) return;
const res = await fetch(writeUrl("color-presets", p.id), { method: "DELETE" });
if (res.ok) onChange();
};
return (
<div className="space-y-3">
<div className="space-y-1.5">
{presets.length === 0 && (
<p className="text-xs text-gray-600">هنوز پالتی تعریف نشده است.</p>
)}
{presets.map((p) => (
<div
key={p.id}
className="flex flex-wrap items-center gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2"
>
<span className="flex-1 truncate text-xs text-gray-200">
{p.name || "بدون نام"}
</span>
<div className="flex gap-1">
{p.items?.slice(0, 8).map((it, i) => (
<span
key={i}
title={`${it.element_key}: ${it.value}`}
className="h-4 w-4 rounded-sm border border-white/10"
style={{ background: it.value }}
/>
))}
</div>
<button
className={ghost}
onClick={() => {
setEditId(p.id);
setName(p.name ?? "");
setItems(
p.items?.length
? p.items.map((i) => ({ element_key: i.element_key, value: i.value, sort: i.sort }))
: [{ element_key: "", value: "#ffffff", sort: 0 }]
);
}}
>
ویرایش
</button>
<button className={del} onClick={() => remove(p)}>
حذف
</button>
</div>
))}
</div>
<div className="rounded-lg border border-dashed border-[#262b40] p-3">
<p className="mb-2 text-[11px] font-medium text-gray-400">
{editId ? "ویرایش پالت" : "افزودن پالت رنگ"}
</p>
<div className="mb-2">
<label className={lbl}>نام پالت (اختیاری)</label>
<input className={inp} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1.5">
{items.map((it, idx) => (
<div key={idx} className="flex items-center gap-1.5">
<input
className={inp}
dir="ltr"
placeholder="element_key"
value={it.element_key}
onChange={(e) =>
setItems((arr) =>
arr.map((x, i) => (i === idx ? { ...x, element_key: e.target.value } : x))
)
}
/>
<input
type="color"
className="h-8 w-9 shrink-0 rounded border border-[#262b40] bg-transparent"
value={it.value}
onChange={(e) =>
setItems((arr) =>
arr.map((x, i) => (i === idx ? { ...x, value: e.target.value } : x))
)
}
/>
<button
className={del}
onClick={() => setItems((arr) => arr.filter((_, i) => i !== idx))}
>
×
</button>
</div>
))}
</div>
<button
className={`${ghost} mt-2`}
onClick={() => setItems((arr) => [...arr, { element_key: "", value: "#ffffff", sort: arr.length }])}
>
+ رنگ
</button>
<div className="mt-2 flex gap-2">
<button className={btn} onClick={submit} disabled={busy}>
{busy ? "…" : editId ? "ذخیرهٔ تغییرات" : "+ افزودن پالت"}
</button>
{editId && (
<button className={ghost} onClick={reset}>
انصراف
</button>
)}
</div>
</div>
</div>
);
}
+15
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { slugify } from "@/components/admin/AdminResource";
import { FileUploadField } from "@/components/admin/FileUploadField";
import { AdminThumb } from "@/components/admin/AdminThumb";
import { SceneColorEditor } from "@/components/admin/SceneColorEditor";
interface Container {
id: string;
@@ -74,6 +75,7 @@ export function TemplatesAdmin() {
const emptyNewProj = { name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" };
const [newProj, setNewProj] = useState({ ...emptyNewProj });
const [addingProj, setAddingProj] = useState(false);
const [scEditor, setScEditor] = useState<{ id: string; name: string } | null>(null);
const api = (p: string) => `/api/admin/resource/${p}`;
@@ -352,6 +354,11 @@ export function TemplatesAdmin() {
<button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button>
</div>
</div>
<div className="flex items-end">
<button type="button" className="rounded-lg border border-indigo-500/40 px-2.5 py-1 text-xs text-indigo-300 hover:bg-indigo-500/10" onClick={() => setScEditor({ id: p.id, name: p.name })}>
صحنهها و رنگها
</button>
</div>
</div>
</div>
))}
@@ -388,6 +395,14 @@ export function TemplatesAdmin() {
</div>
</div>
)}
{scEditor && (
<SceneColorEditor
projectId={scEditor.id}
projectName={scEditor.name}
onClose={() => setScEditor(null)}
/>
)}
</div>
);
}
+4 -1
View File
@@ -302,11 +302,13 @@ export const plansConfig: ResourceConfig = {
export const discountsConfig: ResourceConfig = {
title: "تخفیف‌ها",
description: "کدهای تخفیف / کوپن. (کدها اینجا ساخته می‌شوند؛ هنوز API ویرایش/حذف وجود ندارد.)",
description: "کدهای تخفیف / کوپن. ساخت، ویرایش و حذف.",
basePath: "discounts",
listQuery: "pageSize=500&page_size=500",
listKey: "data",
canCreate: true,
canEdit: true,
canDelete: true,
columns: [
{ key: "code", label: "کد" },
{ key: "kind", label: "نوع" },
@@ -332,5 +334,6 @@ export const discountsConfig: ResourceConfig = {
{ key: "value", label: "مقدار", type: "number", required: true },
{ key: "max_use_count", label: "حداکثر دفعات استفاده (خالی = نامحدود)", type: "number" },
{ key: "expires_at", label: "تاریخ انقضا (ISO، اختیاری)", placeholder: "2026-12-31T00:00:00Z" },
{ key: "is_active", label: "فعال", type: "checkbox", defaultValue: true },
],
};