Improve clothes admin UX, website slots, and featured placement.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
67b1943a9e
commit
d8ecd57641
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user