Update website image slot placeholders and leather banner preview.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e518713f1f
commit
7ea3d619d8
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
{onDelete ? (
|
||||
<IconButton label="حذف" variant="line" onClick={onDelete}>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+181
-40
@@ -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,13 +356,37 @@ export function normalizeSlotDef(raw: WebsiteSlotDefApi): WebsiteSlotDef {
|
||||
aspectLabel: local?.aspectLabel || raw.aspectLabel || "",
|
||||
previewObjectPosition:
|
||||
raw.previewObjectPosition ?? local?.previewObjectPosition,
|
||||
cellLabels: (local?.cellLabels ?? raw.cellLabels)?.map((cell, index) => ({
|
||||
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 ??
|
||||
local?.cellLabels?.[index]?.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 */
|
||||
|
||||
Reference in New Issue
Block a user