Improve clothes admin UX, website slots, and featured placement.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 18:30:52 +03:30
co-authored by Cursor
parent 67b1943a9e
commit d8ecd57641
26 changed files with 3091 additions and 213 deletions
+691
View File
@@ -0,0 +1,691 @@
"use client";
import Image from "next/image";
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, { EditIcon, TrashIcon } from "@/components/ui/IconButton";
import ImageCropper from "@/components/ui/ImageCropper";
import { FormModal } from "@/components/ui/Modal";
import { useConfirm } from "@/components/ui/ConfirmDialog";
import {
WEBSITE_SLOT_DEFS,
groupSlotSections,
heroPreviewObjectPosition,
normalizeSlotDef,
type WebsiteSlotDef,
type WebsiteSlotItem,
} from "@/data/website-slots";
import { ApiError, uploadMedia } from "@/lib/api";
import {
createWebsiteSlotItem,
deleteWebsiteSlotItem,
fetchWebsiteSlots,
updateWebsiteSlotItem,
} from "@/lib/website-slots-api";
type FormState = {
nameFa: string;
nameEn: string;
subtext: string;
linkText: string;
link: string;
imagePreview: string;
imageFile: File | null;
};
const emptyForm = (): FormState => ({
nameFa: "",
nameEn: "",
subtext: "",
linkText: "",
link: "",
imagePreview: "",
imageFile: null,
});
export default function WebsiteImagesPage() {
const confirmDialog = useConfirm();
const fileRef = useRef<HTMLInputElement>(null);
const [catalog, setCatalog] = useState<WebsiteSlotDef[]>(WEBSITE_SLOT_DEFS);
const [items, setItems] = useState<WebsiteSlotItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [slotSlug, setSlotSlug] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [targetSortOrder, setTargetSortOrder] = useState<number | undefined>();
const [form, setForm] = useState<FormState>(emptyForm());
const reload = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchWebsiteSlots();
const nextCatalog = (data.catalog?.length ? data.catalog : WEBSITE_SLOT_DEFS)
.map((row) => normalizeSlotDef(row as WebsiteSlotDef & { key?: string }))
.filter((row) => row.slug && row.slug !== "payment-badges");
setCatalog(nextCatalog.length ? nextCatalog : WEBSITE_SLOT_DEFS);
setItems(data.items ?? []);
} catch (err) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری اسلات‌های وب‌سایت ناموفق بود.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const itemsBySlot = useMemo(() => {
const map = new Map<string, WebsiteSlotItem[]>();
for (const item of items) {
const list = map.get(item.slotKey) ?? [];
list.push(item);
map.set(item.slotKey, list);
}
for (const [, list] of map) {
list.sort((a, b) => a.sortOrder - b.sortOrder);
}
return map;
}, [items]);
const sections = useMemo(() => groupSlotSections(catalog), [catalog]);
const activeDef = catalog.find((d) => d.slug === slotSlug);
const activeCell =
targetSortOrder !== undefined
? activeDef?.cellLabels?.[targetSortOrder]
: undefined;
const activeAspectRatio =
activeCell?.aspectRatio ?? activeDef?.aspectRatio ?? 1;
const activeAspectLabel =
activeCell?.aspectLabel ?? activeDef?.aspectLabel ?? "";
const activePreviewObjectPosition = useMemo(() => {
if (!activeDef) return undefined;
if (activeDef.slug === "hero-slider") {
const list = itemsBySlot.get(activeDef.slug) ?? [];
const index = editingId
? list.findIndex((row) => row.id === editingId)
: list.length;
return heroPreviewObjectPosition(Math.max(0, index));
}
return (
activeCell?.previewObjectPosition ?? activeDef.previewObjectPosition
);
}, [activeDef, activeCell, editingId, itemsBySlot]);
const activeFocusY = objectPositionFocusY(activePreviewObjectPosition);
function closeModal() {
setModalOpen(false);
setEditingId(null);
setSlotSlug("");
setTargetSortOrder(undefined);
setForm(emptyForm());
if (fileRef.current) fileRef.current.value = "";
}
function openCreate(def: WebsiteSlotDef, sortOrder?: number) {
setEditingId(null);
setSlotSlug(def.slug);
setTargetSortOrder(sortOrder);
const cell =
sortOrder !== undefined ? def.cellLabels?.[sortOrder] : undefined;
setForm({
...emptyForm(),
nameFa: cell?.labelFa ?? "",
nameEn: cell?.labelEn ?? "",
});
setModalOpen(true);
}
function openEdit(def: WebsiteSlotDef, item: WebsiteSlotItem) {
setEditingId(item.id);
setSlotSlug(def.slug);
setTargetSortOrder(item.sortOrder);
setForm({
nameFa: item.nameFa,
nameEn: item.nameEn,
subtext: item.subtext,
linkText: item.linkText,
link: item.link,
imagePreview: item.imageUrl ?? "",
imageFile: null,
});
setModalOpen(true);
}
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setForm((prev) => ({
...prev,
imageFile: file,
imagePreview: URL.createObjectURL(file),
}));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!slotSlug) return;
setSaving(true);
setError(null);
try {
let imageUrl: string | undefined;
let imageKey: string | undefined;
if (form.imageFile) {
const uploaded = await uploadMedia(form.imageFile, "website");
imageUrl = uploaded.url;
imageKey = uploaded.storageKey;
}
const payload = {
nameFa: form.nameFa.trim(),
nameEn: form.nameEn.trim(),
subtext: form.subtext.trim(),
linkText: form.linkText.trim(),
link: form.link.trim(),
...(imageUrl ? { imageUrl, imageKey } : {}),
};
if (editingId) {
await updateWebsiteSlotItem(editingId, payload);
} else {
await createWebsiteSlotItem({
slotKey: slotSlug,
...payload,
...(targetSortOrder !== undefined
? { sortOrder: targetSortOrder }
: {}),
});
}
await reload();
closeModal();
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ذخیره اسلات ناموفق بود.",
);
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!(await confirmDialog("این آیتم حذف شود؟"))) return;
setError(null);
try {
await deleteWebsiteSlotItem(id);
setItems((prev) => prev.filter((item) => item.id !== id));
} catch (err) {
setError(err instanceof ApiError ? err.message : "حذف ناموفق بود.");
}
}
return (
<>
<ClothesShell
eyebrow="Images"
title="مدیریت تصاویر"
description="اسلات‌های صفحه اصلی با نسبت تصویر واقعی هر جایگاه."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "وب‌سایت", href: "/website" },
{ label: "تصاویر" },
]}
>
{error ? (
<p className="mb-3 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
{error}
</p>
) : null}
{loading ? (
<p className="text-sm text-muted">در حال بارگذاری...</p>
) : (
<div>
{sections.map((section, sectionIndex) => {
const primary = section.defs[0];
const slugLine =
section.defs.length > 1
? section.defs.map((d) => d.slug).join(" · ")
: primary.slug;
return (
<section
key={section.key}
className={`space-y-3 ${
sectionIndex > 0 ? "border-t border-line pt-8" : ""
} ${
sectionIndex < sections.length - 1 ? "pb-8" : ""
}`}
>
<div>
<h2 className="text-base text-ink">{section.labelFa}</h2>
<p
dir="ltr"
className="mt-1 font-montserrat text-sm tracking-wide text-muted"
>
<span className="text-ink/70">{slugLine}</span>
<span className="mx-2 text-line">·</span>
<span>{formatImageSize(section.aspectLabel)}</span>
</p>
</div>
<div className="flex flex-wrap gap-3">
{section.defs.map((def) => {
const slotItems = itemsBySlot.get(def.slug) ?? [];
if (def.kind === "collection") {
return (
<div
key={def.slug}
className="contents"
>
{slotItems.map((item, index) => (
<SlotThumb
key={item.id}
aspectRatio={def.aspectRatio}
imageUrl={item.imageUrl}
objectPosition={
def.slug === "hero-slider"
? heroPreviewObjectPosition(index)
: def.previewObjectPosition
}
title={item.nameFa || "بدون نام"}
subtitle={formatImageSize(def.aspectLabel)}
onEdit={() => openEdit(def, item)}
onDelete={() => void handleDelete(item.id)}
/>
))}
<PlusThumb
aspectRatio={def.aspectRatio}
label={formatImageSize(def.aspectLabel)}
onClick={() => openCreate(def)}
/>
</div>
);
}
if (def.kind === "fixed-set") {
return (
<div key={def.slug} className="contents">
{Array.from({
length: def.itemCount ?? 0,
}).map((_, index) => {
const item = slotItems.find(
(row) => row.sortOrder === index,
);
const cell = def.cellLabels?.[index];
const cellAr =
cell?.aspectRatio ?? def.aspectRatio;
const sizeLabel = formatImageSize(
cell?.aspectLabel ?? def.aspectLabel,
);
const widthRem = cell?.previewWidthRem;
if (item) {
return (
<SlotThumb
key={item.id}
aspectRatio={cellAr}
widthRem={widthRem}
imageUrl={item.imageUrl}
objectPosition={
cell?.previewObjectPosition ??
def.previewObjectPosition
}
title={
item.nameFa ||
cell?.labelEn ||
`${def.slug}-${index + 1}`
}
subtitle={sizeLabel}
onEdit={() => openEdit(def, item)}
onDelete={() => void handleDelete(item.id)}
/>
);
}
return (
<PlusThumb
key={`${def.slug}-${index}`}
aspectRatio={cellAr}
widthRem={widthRem}
label={sizeLabel}
onClick={() => openCreate(def, index)}
/>
);
})}
</div>
);
}
// fixed
const item = slotItems[0];
const sizeLabel = formatImageSize(def.aspectLabel);
if (item) {
return (
<SlotThumb
key={item.id}
aspectRatio={def.aspectRatio}
imageUrl={item.imageUrl}
objectPosition={def.previewObjectPosition}
title={item.nameFa || def.slug}
subtitle={sizeLabel}
onEdit={() => openEdit(def, item)}
onDelete={() => void handleDelete(item.id)}
/>
);
}
return (
<PlusThumb
key={def.slug}
aspectRatio={def.aspectRatio}
label={sizeLabel}
onClick={() => openCreate(def)}
/>
);
})}
</div>
</section>
);
})}
</div>
)}
</ClothesShell>
<FormModal
open={modalOpen}
title={
editingId
? `ویرایش — ${activeDef?.labelFa ?? ""}`
: `جدید — ${activeDef?.labelFa ?? ""}`
}
subtitle={
activeDef
? `${activeDef.slug} · ${formatImageSize(activeAspectLabel)}`
: "Website image slot"
}
onClose={closeModal}
onSubmit={handleSubmit}
submitLabel={saving ? "..." : "ذخیره"}
wide="xl"
>
<div className="space-y-5">
<div>
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
<p className="text-sm text-ink">تصویر</p>
<p
dir="ltr"
className="font-montserrat text-sm tracking-wide text-muted"
>
{formatImageSize(activeAspectLabel)}
</p>
</div>
{form.imagePreview ? (
<div
className={`mx-auto w-full ${
activeAspectRatio >= 1.2 ? "max-w-md" : "max-w-[9.5rem]"
}`}
>
<ImageCropper
src={form.imagePreview}
aspectRatio={activeAspectRatio}
focusY={activeFocusY}
alt={activeDef?.labelFa ?? "پیش‌نمایش"}
className="website-slot-cropper"
/>
<button
type="button"
className="mt-2 w-full text-center text-xs text-muted underline-offset-2 hover:text-ink hover:underline"
onClick={() => fileRef.current?.click()}
>
تعویض تصویر
</button>
</div>
) : (
<button
type="button"
className={`relative mx-auto flex w-full items-center justify-center overflow-hidden border border-dashed border-line bg-white text-muted transition-colors hover:border-ink hover:text-ink ${
activeAspectRatio >= 1.2 ? "max-w-md" : "max-w-[9.5rem]"
}`}
style={{ aspectRatio: String(activeAspectRatio) }}
onClick={() => fileRef.current?.click()}
>
<span className="flex flex-col items-center gap-2 px-3">
<span className="text-4xl leading-none">+</span>
<span dir="ltr" className="font-montserrat text-sm">
{formatImageSize(activeAspectLabel)}
</span>
</span>
</button>
)}
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="sr-only"
onChange={handleImageChange}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="clothes-field">
<label htmlFor="slot-name-fa">نام فارسی</label>
<input
id="slot-name-fa"
value={form.nameFa}
onChange={(e) =>
setForm((prev) => ({ ...prev, nameFa: e.target.value }))
}
placeholder="نام را کاربر تعریف می‌کند"
/>
</div>
<div className="clothes-field">
<label htmlFor="slot-name-en">نام انگلیسی</label>
<input
id="slot-name-en"
dir="ltr"
value={form.nameEn}
onChange={(e) =>
setForm((prev) => ({ ...prev, nameEn: e.target.value }))
}
placeholder="Optional English name"
/>
</div>
<div className="clothes-field sm:col-span-2">
<label htmlFor="slot-subtext">زیرنویس</label>
<textarea
id="slot-subtext"
value={form.subtext}
rows={4}
onChange={(e) =>
setForm((prev) => ({ ...prev, subtext: e.target.value }))
}
className="!min-h-[6rem]"
/>
</div>
<div className="clothes-field">
<label htmlFor="slot-link-text">متن لینک</label>
<input
id="slot-link-text"
value={form.linkText}
onChange={(e) =>
setForm((prev) => ({
...prev,
linkText: e.target.value,
}))
}
/>
</div>
<div className="clothes-field">
<label htmlFor="slot-link">لینک</label>
<input
id="slot-link"
dir="ltr"
value={form.link}
onChange={(e) =>
setForm((prev) => ({ ...prev, link: e.target.value }))
}
placeholder="/products"
/>
</div>
</div>
</div>
</FormModal>
</>
);
}
function formatImageSize(label: string) {
return label
.replace(/(\d+)\s*[x×]\s*(\d+)/gi, "$1 × $2")
.replace(/\s*·\s*/g, " · ")
.replace(/\s+/g, " ")
.trim();
}
/** Map Tailwind object-position class → vertical focus 0..1 for ImageCropper */
function objectPositionFocusY(objectPosition?: string) {
if (!objectPosition) return 0.5;
if (/\bobject-top\b/.test(objectPosition)) return 0;
if (/\bobject-(?:left-)?bottom\b/.test(objectPosition)) return 1;
const match = objectPosition.match(/_(\d+(?:\.\d+)?)%/);
if (match) return Number(match[1]) / 100;
return 0.5;
}
function thumbWidthClass(aspectRatio: number, widthRem?: number) {
if (widthRem) return undefined;
if (aspectRatio >= 4) return "w-full max-w-md";
if (aspectRatio >= 1.5) return "w-[min(100%,18rem)] sm:w-[22rem]";
if (aspectRatio >= 1) return "w-[9rem] sm:w-[10rem]";
return "w-[7.5rem] sm:w-[8.5rem]";
}
function plusSizeClass(aspectRatio: number, widthRem?: number) {
const scale = widthRem ?? (aspectRatio >= 1.5 ? 22 : aspectRatio >= 1 ? 10 : 8);
if (scale >= 12 || aspectRatio >= 1.5) return "text-5xl sm:text-6xl";
if (scale >= 9 || aspectRatio >= 1) return "text-3xl";
return "text-2xl";
}
function PlusThumb({
aspectRatio,
widthRem,
label,
onClick,
}: {
aspectRatio: number;
widthRem?: number;
label?: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label || "افزودن"}
className={`group flex flex-col gap-2 text-start ${thumbWidthClass(aspectRatio, widthRem) ?? ""}`}
style={widthRem ? { width: `${widthRem}rem` } : undefined}
>
<span
className="relative flex w-full items-center justify-center border border-dashed border-line bg-white text-muted transition-colors group-hover:border-ink group-hover:text-ink"
style={{ aspectRatio: String(aspectRatio) }}
>
<span className={`${plusSizeClass(aspectRatio, widthRem)} leading-none`}>
+
</span>
</span>
{label ? (
<span
dir="ltr"
className="line-clamp-2 text-end font-montserrat text-xs text-muted"
>
{label}
</span>
) : null}
</button>
);
}
function SlotThumb({
aspectRatio,
widthRem,
imageUrl,
objectPosition,
title,
subtitle,
onEdit,
onDelete,
}: {
aspectRatio: number;
widthRem?: number;
imageUrl: string | null;
/** Tailwind object-* class matching storefront framing */
objectPosition?: string;
title: string;
subtitle?: string;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div
className={`flex flex-col gap-2 ${thumbWidthClass(aspectRatio, widthRem) ?? ""}`}
style={widthRem ? { width: `${widthRem}rem` } : undefined}
>
<div
className="relative w-full overflow-hidden border border-line bg-white"
style={{ aspectRatio: String(aspectRatio) }}
>
{imageUrl ? (
<Image
src={imageUrl}
alt=""
fill
unoptimized
className={`object-cover ${objectPosition ?? "object-center"}`}
sizes="360px"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-[0.65rem] text-muted">
بدون تصویر
</span>
)}
</div>
<div className="min-w-0">
<p className="truncate text-xs text-ink">{title}</p>
{subtitle ? (
<p
dir="ltr"
className="truncate text-end font-montserrat text-xs text-muted"
>
{subtitle}
</p>
) : null}
</div>
<div className="flex gap-1">
<IconButton label="ویرایش" variant="line" onClick={onEdit}>
<EditIcon />
</IconButton>
<IconButton label="حذف" variant="line" onClick={onDelete}>
<TrashIcon />
</IconButton>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import DashboardBanner from "@/components/dashboard/DashboardBanner";
import DashTileGrid, {
DashTileItem,
} from "@/components/dashboard/DashTileGrid";
import ClothesShell from "@/components/clothes/ClothesShell";
const websiteTiles: DashTileItem[] = [
{
href: "/website/images",
title: "مدیریت تصاویر",
titleEn: "Images",
description: "بنرها و تصاویر صفحه اصلی",
icon: <ImagesIcon />,
},
{
href: "/website/requests",
title: "درخواست‌ها",
titleEn: "Requests",
description: "پیام‌ها و درخواست‌های کاربران",
icon: <RequestsIcon />,
},
];
export default function WebsitePage() {
const bannerDelay = 80 + websiteTiles.length * 70;
return (
<ClothesShell
eyebrow="Website"
title="مدیریت وب‌سایت"
description="تصاویر فروشگاه و درخواست‌های کاربران را از اینجا مدیریت کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "وب‌سایت" },
]}
>
<DashTileGrid tiles={websiteTiles} />
<DashboardBanner
src="/images/image4.jpg"
alt="نمای وب‌سایت مانا"
caption="Quiet elegance."
priority
style={{ animationDelay: `${bannerDelay}ms` }}
/>
</ClothesShell>
);
}
function iconProps() {
return {
width: 56,
height: 56,
viewBox: "0 0 24 24",
fill: "none",
"aria-hidden": true as const,
};
}
function ImagesIcon() {
return (
<svg {...iconProps()}>
<rect
x="4.5"
y="5.5"
width="15"
height="13"
rx="1"
stroke="currentColor"
strokeWidth="0.85"
/>
<circle cx="9" cy="10" r="1.6" stroke="currentColor" strokeWidth="0.85" />
<path
d="M5.5 16.5 9.5 13l2.5 2.2 2.3-2.7 4.2 4"
stroke="currentColor"
strokeWidth="0.85"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function RequestsIcon() {
return (
<svg {...iconProps()}>
<path
d="M5.5 6.5h13v9.5H11l-3.5 2.5V16H5.5V6.5Z"
stroke="currentColor"
strokeWidth="0.85"
strokeLinejoin="round"
/>
<path
d="M9 10h6M9 13h4"
stroke="currentColor"
strokeWidth="0.85"
strokeLinecap="round"
/>
</svg>
);
}
+20
View File
@@ -0,0 +1,20 @@
import ClothesShell from "@/components/clothes/ClothesShell";
export default function WebsiteRequestsPage() {
return (
<ClothesShell
eyebrow="Requests"
title="درخواست‌ها"
description="پیام‌ها و درخواست‌های ارسال‌شده از وب‌سایت."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "وب‌سایت", href: "/website" },
{ label: "درخواست‌ها" },
]}
>
<p className="rounded-lg border border-line bg-surface px-4 py-10 text-center text-sm text-muted">
به‌زودی درخواست‌های کاربران اینجا نمایش داده می‌شود.
</p>
</ClothesShell>
);
}