Update website image slot placeholders and leather banner preview.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-11 15:02:41 +03:30
co-authored by Cursor
parent e518713f1f
commit 7ea3d619d8
4 changed files with 269 additions and 72 deletions
+2 -5
View File
@@ -31,7 +31,7 @@ import {
updateCustomerRole,
} from "@/lib/customers-api";
const PAGE_SIZE = 5;
const PAGE_SIZE = 24;
const ROLES: UserRole[] = ["CUSTOMER", "STORE_ADMIN", "SUPER_ADMIN"];
const MEMBERSHIPS: SubscriptionStatus[] = ["regular", "premium"];
@@ -275,7 +275,7 @@ export default function CustomersPage() {
style={{ animationDelay: `${70 + i * 45}ms` }}
>
<div className="min-w-0 flex-1">
<h2 className="truncate text-base text-ink/65 sm:text-lg">
<h2 className="truncate text-sm text-ink/65 sm:text-base">
{customer.nameFa}
</h2>
<p
@@ -284,9 +284,6 @@ export default function CustomersPage() {
>
{customer.phone}
</p>
<p className="mt-1 text-[0.7rem] text-muted">
{roleLabel(customer.role)}
</p>
</div>
<div className="customer-stats shrink-0">
+2 -2
View File
@@ -753,11 +753,11 @@ body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
gap: 0.75rem;
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius-card);
padding: 0.9rem 1rem;
padding: 0.5rem 0.85rem;
transition: border-color 0.25s ease;
}
+82 -23
View File
@@ -20,6 +20,7 @@ import {
groupSlotSections,
heroPreviewObjectPosition,
normalizeSlotDef,
resolveSlotImageUrl,
type WebsiteSlotDef,
type WebsiteSlotItem,
} from "@/data/website-slots";
@@ -71,9 +72,16 @@ export default function WebsiteImagesPage() {
setError(null);
try {
const data = await fetchWebsiteSlots();
const nextCatalog = (data.catalog?.length ? data.catalog : WEBSITE_SLOT_DEFS)
const remote = (data.catalog?.length ? data.catalog : WEBSITE_SLOT_DEFS)
.map((row) => normalizeSlotDef(row as WebsiteSlotDef & { key?: string }))
.filter((row) => row.slug && row.slug !== "payment-badges");
const remoteSlugs = new Set(remote.map((row) => row.slug));
const nextCatalog = [
...remote,
...WEBSITE_SLOT_DEFS.filter((row) => !remoteSlugs.has(row.slug)).map(
(row) => normalizeSlotDef(row),
),
];
setCatalog(nextCatalog.length ? nextCatalog : WEBSITE_SLOT_DEFS);
setItems(data.items ?? []);
} catch (err) {
@@ -147,10 +155,14 @@ export default function WebsiteImagesPage() {
setTargetSortOrder(sortOrder);
const cell =
sortOrder !== undefined ? def.cellLabels?.[sortOrder] : undefined;
const defaultImage = cell?.defaultImage ?? def.defaultImage ?? "";
setForm({
...emptyForm(),
nameFa: cell?.labelFa ?? "",
nameEn: cell?.labelEn ?? "",
nameFa: cell?.defaultNameFa ?? cell?.labelFa ?? def.defaultNameFa ?? "",
nameEn: cell?.defaultNameEn ?? cell?.labelEn ?? def.defaultNameEn ?? "",
subtext: cell?.defaultSubtext ?? def.defaultSubtext ?? "",
link: cell?.defaultLink ?? def.defaultLink ?? "",
imagePreview: resolveSlotImageUrl(defaultImage) ?? "",
});
setModalOpen(true);
}
@@ -159,13 +171,16 @@ export default function WebsiteImagesPage() {
setEditingId(item.id);
setSlotSlug(def.slug);
setTargetSortOrder(item.sortOrder);
const cell = def.cellLabels?.[item.sortOrder];
const image =
item.imageUrl || cell?.defaultImage || def.defaultImage || "";
setForm({
nameFa: item.nameFa,
nameEn: item.nameEn,
subtext: item.subtext,
linkText: item.linkText,
link: item.link,
imagePreview: item.imageUrl ?? "",
imagePreview: resolveSlotImageUrl(image) ?? "",
imageFile: null,
});
setModalOpen(true);
@@ -193,6 +208,17 @@ export default function WebsiteImagesPage() {
const uploaded = await uploadMedia(form.imageFile, "website");
imageUrl = uploaded.url;
imageKey = uploaded.storageKey;
} else {
const cell =
targetSortOrder !== undefined
? activeDef?.cellLabels?.[targetSortOrder]
: undefined;
const fallback =
cell?.defaultImage ?? activeDef?.defaultImage ?? undefined;
// Keep storefront-relative defaults when saving without a new upload.
if (fallback && !editingId) {
imageUrl = fallback;
}
}
const payload = {
@@ -299,7 +325,7 @@ export default function WebsiteImagesPage() {
<SlotThumb
key={item.id}
aspectRatio={def.aspectRatio}
imageUrl={item.imageUrl}
imageUrl={resolveSlotImageUrl(item.imageUrl)}
objectPosition={
def.slug === "hero-slider"
? heroPreviewObjectPosition(index)
@@ -336,25 +362,37 @@ export default function WebsiteImagesPage() {
cell?.aspectLabel ?? def.aspectLabel,
);
const widthRem = cell?.previewWidthRem;
if (item) {
const previewImage = resolveSlotImageUrl(
item?.imageUrl || cell?.defaultImage,
);
if (item || previewImage) {
return (
<SlotThumb
key={item.id}
key={item?.id ?? `${def.slug}-${index}`}
aspectRatio={cellAr}
widthRem={widthRem}
imageUrl={item.imageUrl}
imageUrl={previewImage}
objectPosition={
cell?.previewObjectPosition ??
def.previewObjectPosition
}
title={
item.nameFa ||
cell?.labelEn ||
item?.nameFa ||
cell?.defaultNameFa ||
cell?.labelFa ||
`${def.slug}-${index + 1}`
}
subtitle={sizeLabel}
onEdit={() => openEdit(def, item)}
onDelete={() => void handleDelete(item.id)}
onEdit={() =>
item
? openEdit(def, item)
: openCreate(def, index)
}
onDelete={
item
? () => void handleDelete(item.id)
: undefined
}
/>
);
}
@@ -375,17 +413,31 @@ export default function WebsiteImagesPage() {
// fixed
const item = slotItems[0];
const sizeLabel = formatImageSize(def.aspectLabel);
if (item) {
const previewImage = resolveSlotImageUrl(
item?.imageUrl || def.defaultImage,
);
if (item || previewImage) {
return (
<SlotThumb
key={item.id}
key={item?.id ?? def.slug}
aspectRatio={def.aspectRatio}
imageUrl={item.imageUrl}
imageUrl={previewImage}
objectPosition={def.previewObjectPosition}
title={item.nameFa || def.slug}
title={
item?.nameFa ||
def.defaultNameFa ||
def.labelFa ||
def.slug
}
subtitle={sizeLabel}
onEdit={() => openEdit(def, item)}
onDelete={() => void handleDelete(item.id)}
onEdit={() =>
item ? openEdit(def, item) : openCreate(def)
}
onDelete={
item
? () => void handleDelete(item.id)
: undefined
}
/>
);
}
@@ -641,8 +693,13 @@ function SlotThumb({
title: string;
subtitle?: string;
onEdit: () => void;
onDelete: () => void;
onDelete?: () => void;
}) {
const coverClass =
imageUrl?.includes("/images/transparent/")
? "object-contain bg-[#ececec] p-2"
: `object-cover ${objectPosition ?? "object-center"}`;
return (
<div
className={`flex flex-col gap-2 ${thumbWidthClass(aspectRatio, widthRem) ?? ""}`}
@@ -658,7 +715,7 @@ function SlotThumb({
alt=""
fill
unoptimized
className={`object-cover ${objectPosition ?? "object-center"}`}
className={coverClass}
sizes="360px"
/>
) : (
@@ -682,9 +739,11 @@ function SlotThumb({
<IconButton label="ویرایش" variant="line" onClick={onEdit}>
<EditIcon />
</IconButton>
<IconButton label="حذف" variant="line" onClick={onDelete}>
<TrashIcon />
</IconButton>
{onDelete ? (
<IconButton label="حذف" variant="line" onClick={onDelete}>
<TrashIcon />
</IconButton>
) : null}
</div>
</div>
);
+183 -42
View File
@@ -17,6 +17,12 @@ export type WebsiteSlotDef = {
* (preview only — uploaded file is full-bleed cover).
*/
previewObjectPosition?: string;
/** Storefront-relative default image when no upload exists yet */
defaultImage?: string;
defaultNameFa?: string;
defaultNameEn?: string;
defaultSubtext?: string;
defaultLink?: string;
cellLabels?: Array<{
labelFa: string;
labelEn: string;
@@ -25,6 +31,11 @@ export type WebsiteSlotDef = {
/** Relative preview width in rem (matches on-site scale) */
previewWidthRem?: number;
previewObjectPosition?: string;
defaultImage?: string;
defaultNameFa?: string;
defaultNameEn?: string;
defaultSubtext?: string;
defaultLink?: string;
}>;
};
@@ -65,52 +76,153 @@ export const WEBSITE_SLOT_DEFS: WebsiteSlotDef[] = [
},
{
/**
* Homepage CategoryBento (lg): 3×3 grid, height min(62vw, 560px).
* Cell sizes at 1520×560 content (max-w 1600 − lg padding, 8px gaps):
* 0: 1×3 tall · 1: 2×1 wide · 2: 1×1 · 3: 1×2 · 4: 1×1
* Homepage CategoryBento (lg): 6×3 grid inside a 2:1 frame.
* Order: mantel, shirts, pants, bag, scarf, shoe.
*/
slug: "category-strip",
labelFa: "دسته‌بندی‌ها",
labelEn: "Categories",
kind: "fixed-set",
itemCount: 5,
aspectRatio: 501 / 560,
aspectLabel: "بنتو · ۵ قاب",
itemCount: 6,
aspectRatio: 1,
aspectLabel: "بنتو · ۶ قاب",
cellLabels: [
{
labelFa: "قاب بلند",
labelEn: "Tall",
aspectRatio: 501 / 560,
aspectLabel: "501×560",
previewWidthRem: 7,
},
{
labelFa: "قاب عریض",
labelEn: "Wide",
aspectRatio: 1011 / 181,
aspectLabel: "1011×181",
previewWidthRem: 16,
},
{
labelFa: "قاب میانی",
labelEn: "Middle",
aspectRatio: 501 / 181,
aspectLabel: "501×181",
previewWidthRem: 9,
},
{
labelFa: "قاب نیمه‌بلند",
labelEn: "Half-tall",
aspectRatio: 501 / 371,
aspectLabel: "501×371",
labelFa: "مانتو",
labelEn: "Mantel",
aspectRatio: 1,
aspectLabel: "۱×۱",
previewWidthRem: 8,
defaultImage: "/images/transparent/mantel.png",
defaultNameFa: "مانتو",
defaultNameEn: "Mantel",
},
{
labelFa: "قاب پایین",
labelEn: "Bottom",
aspectRatio: 501 / 181,
aspectLabel: "501×181",
labelFa: "پیراهن",
labelEn: "Shirts",
aspectRatio: 4,
aspectLabel: "۴×۱",
previewWidthRem: 14,
defaultImage: "/images/transparent/shirt.png",
defaultNameFa: "پیراهن",
defaultNameEn: "Shirts",
},
{
labelFa: "شلوار",
labelEn: "Pants",
aspectRatio: 2,
aspectLabel: "۲×۱",
previewWidthRem: 9,
defaultImage: "/images/transparent/pants.png",
defaultNameFa: "شلوار",
defaultNameEn: "Pants",
},
{
labelFa: "کیف زنانه",
labelEn: "Bag",
aspectRatio: 2,
aspectLabel: "۲×۱",
previewWidthRem: 9,
defaultImage: "/images/transparent/bag.png",
defaultNameFa: "کیف زنانه",
defaultNameEn: "Wemon Bag",
},
{
labelFa: "شال",
labelEn: "Scarf",
aspectRatio: 3,
aspectLabel: "۳×۱",
previewWidthRem: 11,
defaultImage: "/images/transparent/scarf.png",
defaultNameFa: "شال",
defaultNameEn: "Scarf",
},
{
labelFa: "کفش",
labelEn: "Shoe",
aspectRatio: 3,
aspectLabel: "۳×۱",
previewWidthRem: 11,
defaultImage: "/images/transparent/shoe.png",
defaultNameFa: "کفش",
defaultNameEn: "Shoe",
},
],
},
{
slug: "opening-frame",
labelFa: "قاب بازشو",
labelEn: "Opening frame",
kind: "fixed",
sectionKey: "sticky-frames",
sectionLabelFa: "قاب‌های اسکرولی",
sectionLabelEn: "Sticky frames",
aspectRatio: 16 / 9,
aspectLabel: "۱۶×۹",
previewObjectPosition: "object-[center_22%]",
defaultImage: "/images/landscapes/frame1.jpg",
defaultNameFa: "به سبک مانا",
defaultNameEn: "Opening frame",
},
{
slug: "sticky-story",
labelFa: "داستان اسکرولی",
labelEn: "Sticky story",
kind: "fixed-set",
sectionKey: "sticky-frames",
sectionLabelFa: "قاب‌های اسکرولی",
sectionLabelEn: "Sticky frames",
itemCount: 4,
aspectRatio: 16 / 9,
aspectLabel: "۱۶×۹",
cellLabels: [
{
labelFa: "مانتو",
labelEn: "Mantel",
previewObjectPosition: "object-[center_28%]",
defaultImage:
"/images/landscapes/ian-dooley-C6BrEIucDDU-unsplash.jpg",
defaultNameFa: "مانتو",
defaultNameEn: "Mantel",
defaultSubtext:
"مانتوهایی با برش معاصر؛ ساده، شهری و مناسب هر روز.",
defaultLink: "/products?category=coat",
},
{
labelFa: "پیراهن",
labelEn: "Shirts",
previewObjectPosition: "object-[center_35%]",
defaultImage:
"/images/landscapes/tamara-bellis-nbC4EyCxhb8-unsplash.jpg",
defaultNameFa: "پیراهن",
defaultNameEn: "Shirts",
defaultSubtext:
"پیراهن‌هایی با پارچه لطیف و ایستایی تمیز برای استایل روزمره.",
defaultLink: "/products?category=shirts",
},
{
labelFa: "شلوار",
labelEn: "Pants",
previewObjectPosition: "object-[center_30%]",
defaultImage:
"/images/landscapes/vinicius-amnx-amano-k2yixQS_YH0-unsplash.jpg",
defaultNameFa: "شلوار",
defaultNameEn: "Pants",
defaultSubtext:
"شلوارهایی با فرم خوش‌ایست که شکل خود را حفظ می‌کنند.",
defaultLink: "/products?category=pants",
},
{
labelFa: "کیف زنانه",
labelEn: "Bags",
previewObjectPosition: "object-[center_40%]",
defaultImage:
"/images/landscapes/mojtaba-mosayebzadeh-1nLTyduE1cY-unsplash.jpg",
defaultNameFa: "کیف زنانه",
defaultNameEn: "Bags",
defaultSubtext:
"کیف‌هایی با جزئیات دقیق؛ تکمیل‌کننده استایل مانا.",
defaultLink: "/products?category=bags",
},
],
},
@@ -154,7 +266,12 @@ export const WEBSITE_SLOT_DEFS: WebsiteSlotDef[] = [
kind: "fixed",
aspectRatio: 1920 / 585,
aspectLabel: "1920×585",
previewObjectPosition: "object-left-bottom",
// Storefront crops mobile to the subject on the left
previewObjectPosition: "object-[22%_center]",
defaultImage: "/images/home/banners/leather.png",
defaultNameFa: "هنر پوشیدن",
defaultNameEn: "THE ART OF DRESSING",
defaultLink: "/products?collection=leather",
},
{
slug: "four-portrait-banner",
@@ -239,15 +356,39 @@ export function normalizeSlotDef(raw: WebsiteSlotDefApi): WebsiteSlotDef {
aspectLabel: local?.aspectLabel || raw.aspectLabel || "",
previewObjectPosition:
raw.previewObjectPosition ?? local?.previewObjectPosition,
cellLabels: (local?.cellLabels ?? raw.cellLabels)?.map((cell, index) => ({
...cell,
previewObjectPosition:
cell.previewObjectPosition ??
local?.cellLabels?.[index]?.previewObjectPosition,
})),
defaultImage: raw.defaultImage ?? local?.defaultImage,
defaultNameFa: raw.defaultNameFa ?? local?.defaultNameFa,
defaultNameEn: raw.defaultNameEn ?? local?.defaultNameEn,
defaultSubtext: raw.defaultSubtext ?? local?.defaultSubtext,
defaultLink: raw.defaultLink ?? local?.defaultLink,
cellLabels: (local?.cellLabels ?? raw.cellLabels)?.map((cell, index) => {
const localCell = local?.cellLabels?.[index];
return {
...cell,
previewObjectPosition:
cell.previewObjectPosition ?? localCell?.previewObjectPosition,
defaultImage: cell.defaultImage ?? localCell?.defaultImage,
defaultNameFa: cell.defaultNameFa ?? localCell?.defaultNameFa,
defaultNameEn: cell.defaultNameEn ?? localCell?.defaultNameEn,
defaultSubtext: cell.defaultSubtext ?? localCell?.defaultSubtext,
defaultLink: cell.defaultLink ?? localCell?.defaultLink,
};
}),
};
}
/** Resolve storefront-relative paths for dashboard previews. */
export function resolveSlotImageUrl(imageUrl?: string | null) {
if (!imageUrl) return null;
if (/^https?:\/\//i.test(imageUrl) || imageUrl.startsWith("blob:")) {
return imageUrl;
}
const site =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
return `${site}${imageUrl.startsWith("/") ? "" : "/"}${imageUrl}`;
}
/** Same focal points as website `map-home-slots` hero slides */
export function heroPreviewObjectPosition(index: number) {
if (index === 0) return "object-[center_20%]";