TorbatYar/frontend/modules/experience/features/pageBuilder.tsx
Mortezakoohjani 0eec9f729b feat(experience): deploy Experience frontend FE-11.0-11.5 portal
Commit completed Torbat Pages admin frontend module, BFF proxy, routes, and validation artifacts for official platform deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:50 +03:30

224 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;