diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/DiscountService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/DiscountService.cs index 842a926..6a6fc28 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/DiscountService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/DiscountService.cs @@ -85,6 +85,52 @@ public class DiscountService(IdentityDbContext db) : IDiscountService return MapResponse(discount); } + public async Task 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(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 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 diff --git a/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IDiscountService.cs b/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IDiscountService.cs index 56fb3dc..9a829fb 100644 --- a/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IDiscountService.cs +++ b/services/identity/FlatRender.IdentitySvc/Application/Services/Interfaces/IDiscountService.cs @@ -8,4 +8,6 @@ public interface IDiscountService Task ValidateAsync(Guid tenantId, string code, Guid? planId); Task> ListAsync(Guid tenantId, int page, int pageSize); Task CreateAsync(Guid tenantId, CreateDiscountRequest request); + Task UpdateAsync(Guid tenantId, Guid id, UpdateDiscountRequest request); + Task DeleteAsync(Guid tenantId, Guid id); } diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/DiscountsController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/DiscountsController.cs index f34b9ab..d304a5f 100644 --- a/services/identity/FlatRender.IdentitySvc/Controllers/DiscountsController.cs +++ b/services/identity/FlatRender.IdentitySvc/Controllers/DiscountsController.cs @@ -37,6 +37,22 @@ public class DiscountsController(IDiscountService discountService) : ControllerB return StatusCode(201, result); } + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(DiscountResponse), 200)] + public async Task 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 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()); diff --git a/services/identity/FlatRender.IdentitySvc/Models/Requests/BillingRequests.cs b/services/identity/FlatRender.IdentitySvc/Models/Requests/BillingRequests.cs index 0d7b0b6..3f448ec 100644 --- a/services/identity/FlatRender.IdentitySvc/Models/Requests/BillingRequests.cs +++ b/services/identity/FlatRender.IdentitySvc/Models/Requests/BillingRequests.cs @@ -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, diff --git a/src/components/admin/SceneColorEditor.tsx b/src/components/admin/SceneColorEditor.tsx new file mode 100644 index 0000000..58b056a --- /dev/null +++ b/src/components/admin/SceneColorEditor.tsx @@ -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("scenes"); + const [scenes, setScenes] = useState([]); + const [colors, setColors] = useState([]); + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+ {/* Header */} +
+
+

صحنه‌ها و رنگ‌ها

+

نسخه: {projectName}

+
+ +
+ + {/* Tabs */} +
+ {( + [ + ["scenes", `صحنه‌ها (${scenes.length})`], + ["colors", `رنگ‌های مشترک (${colors.length})`], + ["presets", `پالت‌ها (${presets.length})`], + ] as [Tab, string][] + ).map(([key, label]) => ( + + ))} +
+ + {/* Body */} +
+ {error && ( +

+ {error} +

+ )} + {loading ? ( +

در حال بارگذاری…

+ ) : tab === "scenes" ? ( + + ) : tab === "colors" ? ( + + ) : ( + + )} +
+
+
+ ); +} + +// ── 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({ ...empty }); + const [editId, setEditId] = useState(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 ( +
+
+ {scenes.length === 0 && ( +

هنوز صحنه‌ای تعریف نشده است.

+ )} + {scenes + .slice() + .sort((a, b) => a.sort - b.sort) + .map((s) => ( +
+ + {s.scene_type} + + + {s.title}{" "} + + ({s.key}) + + + {s.default_duration_sec ?? "—"}s + #{s.sort} + {!s.is_active && ( + + غیرفعال + + )} + + +
+ ))} +
+ + {/* Add / edit form */} +
+

+ {editId ? "ویرایش صحنه" : "افزودن صحنه"} +

+
+
+ + setDraft({ ...draft, key: e.target.value })} + /> +
+
+ + setDraft({ ...draft, title: e.target.value })} + /> +
+
+ + +
+
+ + + setDraft({ ...draft, default_duration_sec: Number(e.target.value) }) + } + /> +
+
+ + setDraft({ ...draft, sort: Number(e.target.value) })} + /> +
+
+ setDraft({ ...draft, is_active: e.target.checked })} + /> + +
+
+
+ + {editId && ( + + )} +
+
+
+ ); +} + +// ── 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({ ...empty }); + const [editId, setEditId] = useState(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 ( +
+
+ {colors.length === 0 && ( +

هنوز رنگ مشترکی تعریف نشده است.

+ )} + {colors + .slice() + .sort((a, b) => a.sort - b.sort) + .map((c) => ( +
+ + + {c.title}{" "} + + ({c.element_key}) + + + + {c.default_color} + + #{c.sort} + + +
+ ))} +
+ +
+

+ {editId ? "ویرایش رنگ" : "افزودن رنگ مشترک"} +

+
+
+ + setDraft({ ...draft, element_key: e.target.value })} + /> +
+
+ + setDraft({ ...draft, title: e.target.value })} + /> +
+
+ +
+ setDraft({ ...draft, default_color: e.target.value })} + /> + setDraft({ ...draft, default_color: e.target.value })} + /> +
+
+
+ + setDraft({ ...draft, sort: Number(e.target.value) })} + /> +
+
+
+ + {editId && ( + + )} +
+
+
+ ); +} + +// ── 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([{ element_key: "", value: "#ffffff", sort: 0 }]); + const [editId, setEditId] = useState(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 ( +
+
+ {presets.length === 0 && ( +

هنوز پالتی تعریف نشده است.

+ )} + {presets.map((p) => ( +
+ + {p.name || "بدون نام"} + +
+ {p.items?.slice(0, 8).map((it, i) => ( + + ))} +
+ + +
+ ))} +
+ +
+

+ {editId ? "ویرایش پالت" : "افزودن پالت رنگ"} +

+
+ + setName(e.target.value)} /> +
+
+ {items.map((it, idx) => ( +
+ + setItems((arr) => + arr.map((x, i) => (i === idx ? { ...x, element_key: e.target.value } : x)) + ) + } + /> + + setItems((arr) => + arr.map((x, i) => (i === idx ? { ...x, value: e.target.value } : x)) + ) + } + /> + +
+ ))} +
+ +
+ + {editId && ( + + )} +
+
+
+ ); +} diff --git a/src/components/admin/TemplatesAdmin.tsx b/src/components/admin/TemplatesAdmin.tsx index 7947d87..4ba2b62 100644 --- a/src/components/admin/TemplatesAdmin.tsx +++ b/src/components/admin/TemplatesAdmin.tsx @@ -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() { +
+ +
))} @@ -388,6 +395,14 @@ export function TemplatesAdmin() { )} + + {scEditor && ( + setScEditor(null)} + /> + )} ); } diff --git a/src/components/admin/admin-resources.tsx b/src/components/admin/admin-resources.tsx index 3625749..92dba01 100644 --- a/src/components/admin/admin-resources.tsx +++ b/src/components/admin/admin-resources.tsx @@ -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 }, ], };