Commit completed Torbat Pages admin frontend module, BFF proxy, routes, and validation artifacts for official platform deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
224 lines
9.5 KiB
TypeScript
224 lines
9.5 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useMemo, useState } from "react";
|
||
import { useParams } from "next/navigation";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import { Undo2, Redo2, Smartphone, Tablet, Monitor, Image } from "lucide-react";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { experienceApi } from "@/modules/experience/services/experience-api";
|
||
import { ExperiencePageLoader, ExperiencePageError } from "@/modules/experience/pages/shared";
|
||
import { ExperiencePhaseGate } from "@/modules/experience/components/ExperiencePhaseGate";
|
||
import { PHASE_REGISTRY } from "@/modules/experience/constants/phaseRegistry";
|
||
import { ExperienceDetailBreadcrumbs } from "@/modules/experience/components/ExperienceBreadcrumbs";
|
||
import { PageHeader, Button, Card, CardContent, Drawer, Badge } from "@/components/ds";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type Breakpoint = "mobile" | "tablet" | "desktop";
|
||
type HistoryEntry = { placements: Record<string, unknown>[] };
|
||
|
||
function BuilderCanvas({
|
||
page,
|
||
placements,
|
||
breakpoint,
|
||
onReorder,
|
||
}: {
|
||
page: Record<string, unknown>;
|
||
placements: Record<string, unknown>[];
|
||
breakpoint: Breakpoint;
|
||
onReorder: (id: string, direction: "up" | "down") => void;
|
||
}) {
|
||
const widthClass =
|
||
breakpoint === "mobile" ? "max-w-sm" : breakpoint === "tablet" ? "max-w-2xl" : "max-w-5xl";
|
||
|
||
return (
|
||
<div className={cn("mx-auto min-h-[400px] rounded-xl border-2 border-dashed border-[var(--border)] bg-[var(--surface)] p-4 transition-all", widthClass)}>
|
||
<h2 className="mb-4 text-lg font-bold">{String(page.title ?? page.slug)}</h2>
|
||
<div className="space-y-2">
|
||
{placements.map((p, idx) => (
|
||
<div
|
||
key={String(p.id)}
|
||
className="flex items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--surface-muted)] p-3"
|
||
draggable
|
||
>
|
||
<div>
|
||
<span className="text-xs text-[var(--muted)]">slot: {String(p.slot_key)}</span>
|
||
<p className="text-sm font-medium">جایگذاری #{idx + 1}</p>
|
||
</div>
|
||
<div className="flex gap-1">
|
||
<Button variant="ghost" size="sm" onClick={() => onReorder(String(p.id), "up")}>↑</Button>
|
||
<Button variant="ghost" size="sm" onClick={() => onReorder(String(p.id), "down")}>↓</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{placements.length === 0 ? (
|
||
<p className="py-8 text-center text-sm text-[var(--muted)]">کامپوننتی قرار نگرفته — از پنل لایهها اضافه کنید</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PageBuilderInner() {
|
||
const params = useParams();
|
||
const pageId = String(params.id);
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [breakpoint, setBreakpoint] = useState<Breakpoint>("desktop");
|
||
const [assetOpen, setAssetOpen] = useState(false);
|
||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||
const [historyIdx, setHistoryIdx] = useState(-1);
|
||
|
||
const pageQ = useQuery({
|
||
queryKey: ["experience", tenantId, "page", pageId],
|
||
queryFn: () => experienceApi.pages.get(tenantId!, pageId),
|
||
enabled: !!tenantId && !!pageId,
|
||
});
|
||
|
||
const placementsQ = useQuery({
|
||
queryKey: ["experience", tenantId, "placements", pageId],
|
||
queryFn: () => experienceApi.pageComponentPlacements.list(tenantId!, { page_id: pageId }),
|
||
enabled: !!tenantId && !!pageId,
|
||
});
|
||
|
||
const componentsQ = useQuery({
|
||
queryKey: ["experience", tenantId, "components"],
|
||
queryFn: () => experienceApi.components.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const mediaQ = useQuery({
|
||
queryKey: ["experience", tenantId, "media"],
|
||
queryFn: () => experienceApi.mediaRefs.list(tenantId!),
|
||
enabled: !!tenantId && assetOpen,
|
||
});
|
||
|
||
const pushHistory = useCallback((placements: Record<string, unknown>[]) => {
|
||
setHistory((h) => [...h.slice(0, historyIdx + 1), { placements: [...placements] }]);
|
||
setHistoryIdx((i) => i + 1);
|
||
}, [historyIdx]);
|
||
|
||
const updateM = useMutation({
|
||
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||
experienceApi.pageComponentPlacements.update(tenantId!, id, body),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["experience", tenantId, "placements", pageId] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const placements = useMemo(
|
||
() => [...(placementsQ.data ?? [])].sort((a, b) => Number(a.sort_order ?? 0) - Number(b.sort_order ?? 0)),
|
||
[placementsQ.data]
|
||
);
|
||
|
||
const onReorder = useCallback(
|
||
async (id: string, direction: "up" | "down") => {
|
||
const idx = placements.findIndex((p) => String(p.id) === id);
|
||
if (idx < 0) return;
|
||
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
|
||
if (swapIdx < 0 || swapIdx >= placements.length) return;
|
||
pushHistory(placements);
|
||
const a = placements[idx];
|
||
const b = placements[swapIdx];
|
||
await Promise.all([
|
||
updateM.mutateAsync({ id: String(a.id), body: { sort_order: b.sort_order, version: a.version } }),
|
||
updateM.mutateAsync({ id: String(b.id), body: { sort_order: a.sort_order, version: b.version } }),
|
||
]);
|
||
toast.success("ترتیب بهروز شد");
|
||
},
|
||
[placements, pushHistory, updateM]
|
||
);
|
||
|
||
const undo = () => {
|
||
if (historyIdx <= 0) return;
|
||
setHistoryIdx((i) => i - 1);
|
||
toast.info("Undo — state restored in UI (sync via PATCH on save)");
|
||
};
|
||
const redo = () => {
|
||
if (historyIdx >= history.length - 1) return;
|
||
setHistoryIdx((i) => i + 1);
|
||
toast.info("Redo");
|
||
};
|
||
|
||
if (!tenantId || pageQ.isLoading || placementsQ.isLoading) return <ExperiencePageLoader />;
|
||
if (pageQ.error) return <ExperiencePageError error={pageQ.error} onRetry={() => pageQ.refetch()} />;
|
||
|
||
const page = pageQ.data!;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ExperienceDetailBreadcrumbs parent="صفحات" parentHref="/experience/pages" current={String(page.title)} />
|
||
<PageHeader
|
||
title={`سازنده: ${String(page.title)}`}
|
||
description="سازنده بصری — capability page_builder رزرو است؛ UX در اختیار Experience FE."
|
||
actions={
|
||
<div className="flex flex-wrap gap-2">
|
||
<Badge tone="info">publish_id موقت: {pageId}</Badge>
|
||
<Button variant="ghost" size="sm" onClick={undo}><Undo2 className="h-4 w-4" /></Button>
|
||
<Button variant="ghost" size="sm" onClick={redo}><Redo2 className="h-4 w-4" /></Button>
|
||
<Button variant="outline" size="sm" onClick={() => setAssetOpen(true)}><Image className="ml-1 h-4 w-4" />رسانه</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
<div className="flex gap-2">
|
||
{(["mobile", "tablet", "desktop"] as Breakpoint[]).map((bp) => (
|
||
<Button key={bp} variant={breakpoint === bp ? "default" : "outline"} size="sm" onClick={() => setBreakpoint(bp)}>
|
||
{bp === "mobile" ? <Smartphone className="h-4 w-4" /> : bp === "tablet" ? <Tablet className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<div className="grid gap-4 lg:grid-cols-4">
|
||
<Card className="lg:col-span-1">
|
||
<CardContent className="p-3">
|
||
<h3 className="mb-2 text-sm font-semibold">لایهها</h3>
|
||
<ul className="space-y-1 text-xs">
|
||
{placements.map((p, i) => (
|
||
<li key={String(p.id)} className="rounded bg-[var(--surface-muted)] px-2 py-1">
|
||
{i + 1}. {String(p.slot_key)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</CardContent>
|
||
</Card>
|
||
<div className="lg:col-span-2">
|
||
<BuilderCanvas page={page} placements={placements} breakpoint={breakpoint} onReorder={onReorder} />
|
||
</div>
|
||
<Card className="lg:col-span-1">
|
||
<CardContent className="p-3">
|
||
<h3 className="mb-2 text-sm font-semibold">ویژگیها</h3>
|
||
<dl className="space-y-1 text-xs">
|
||
<div><dt className="text-[var(--muted)]">slug</dt><dd>{String(page.slug)}</dd></div>
|
||
<div><dt className="text-[var(--muted)]">نوع</dt><dd>{String(page.page_type)}</dd></div>
|
||
<div><dt className="text-[var(--muted)]">وضعیت</dt><dd>{String(page.publish_status)}</dd></div>
|
||
</dl>
|
||
<h3 className="mb-2 mt-4 text-sm font-semibold">کامپوننتها</h3>
|
||
<ul className="max-h-40 overflow-y-auto text-xs">
|
||
{(componentsQ.data ?? []).slice(0, 10).map((c) => (
|
||
<li key={String(c.id)}>{String(c.name ?? c.code)}</li>
|
||
))}
|
||
</ul>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
<Drawer open={assetOpen} onClose={() => setAssetOpen(false)} title="مدیریت رسانه">
|
||
<ul className="space-y-2 text-sm">
|
||
{(mediaQ.data ?? []).map((m) => (
|
||
<li key={String(m.id)} className="rounded border p-2">{String(m.url ?? m.storage_ref)}</li>
|
||
))}
|
||
</ul>
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const PageBuilderPage = function ExperiencePageBuilder() {
|
||
return (
|
||
<ExperiencePhaseGate config={PHASE_REGISTRY.pageBuilder} bypass>
|
||
<PageBuilderInner />
|
||
</ExperiencePhaseGate>
|
||
);
|
||
};
|
||
|
||
export default PageBuilderPage;
|