Redesign category showcase and convert products section to carousel
- Replace icon-based category tiles with curated photo cards (brown bottom overlay, single-row layout, taller aspect ratio, tighter gaps, no card frame, light brown section background) via new CategoryShowcase component and categoryIcons fallback images. - Add ScrollReveal component for page-enter animations, used across the category showcase and other sections. - Turn the homepage "New Products" grid into a horizontal snap-scroll carousel with prev/next controls (ProductsCarousel). - Extend Pagination and the products page to preserve categoryId filters, and widen aspectRatioToCss to accept undefined. - Document the static-image-slots system and API client conventions in CLAUDE.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,62 @@ import { toFa } from "@/lib/format";
|
||||
const API_BASE = "https://api.meshkee.com/api/v1";
|
||||
export const TENANT_DOMAIN = "aradarisman.com";
|
||||
|
||||
/**
|
||||
* Business-managed image overrides (hero slider, category tiles, feature
|
||||
* icons, about photo, brand logos). Each slot has a stable `key`; when a
|
||||
* slot has no images (or the fetch fails) callers must keep their current
|
||||
* placeholder — never render broken UI. See CLAUDE.md for the slot-key map.
|
||||
*/
|
||||
export type StaticImageItem = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string | null;
|
||||
titleFa: string | null;
|
||||
titleEn: string | null;
|
||||
subtext: string | null;
|
||||
linkUrl: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type StaticImageSlot = {
|
||||
id: string;
|
||||
key: string;
|
||||
kind: "single" | "list";
|
||||
label: string;
|
||||
aspectRatio: string | null;
|
||||
recommendedWidth: number | null;
|
||||
itemCount: number | null;
|
||||
pageKey: string;
|
||||
images: StaticImageItem[];
|
||||
};
|
||||
|
||||
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
|
||||
try {
|
||||
const qs = pageKey ? `?pageKey=${encodeURIComponent(pageKey)}` : "";
|
||||
const data = await meshkeeFetch<{ items: StaticImageSlot[] }>(
|
||||
`/tenants/${TENANT_DOMAIN}/website/static-images${qs}`
|
||||
);
|
||||
return data.items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
|
||||
const slots = await getStaticImageSlots();
|
||||
return slots.find((s) => s.key === key) ?? null;
|
||||
}
|
||||
|
||||
/** "21:9" -> "21 / 9" for CSS aspect-ratio; stays in sync with whatever the API declares. */
|
||||
export function aspectRatioToCss(ratio: string | null | undefined, fallback = "16 / 9"): string {
|
||||
if (!ratio) return fallback;
|
||||
const [w, h] = ratio.split(":");
|
||||
if (!w || !h) return fallback;
|
||||
return `${w} / ${h}`;
|
||||
}
|
||||
|
||||
export type ProductStore = {
|
||||
variantCount: number;
|
||||
minPrice: number | null;
|
||||
@@ -94,6 +150,64 @@ export function productPriceLabel(product: Product): string {
|
||||
return "تماس بگیرید";
|
||||
}
|
||||
|
||||
export type CategorySummary = {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
count: number;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The backend's raw category taxonomy has duplicate entries sharing the same
|
||||
* display name under different ids (a data-entry artifact, not a frontend
|
||||
* bug). This derives category tiles from the products actually published,
|
||||
* merging duplicate-name ids into one tile (summed count) and linking to
|
||||
* whichever id holds the most products under that name. `image` is the
|
||||
* first photographed product found under that name (any of the merged
|
||||
* ids) — null if none of them have a real photo yet.
|
||||
*/
|
||||
export async function getCategorySummary(limit = 8): Promise<CategorySummary[]> {
|
||||
const { items } = await getProducts({ pageSize: 100 });
|
||||
|
||||
const perId = new Map<
|
||||
string,
|
||||
{ id: string; name: string; count: number; image: string | null }
|
||||
>();
|
||||
for (const p of items) {
|
||||
if (!p.categoryId || !p.categoryNameFa) continue;
|
||||
const img = productImage(p);
|
||||
const entry = perId.get(p.categoryId);
|
||||
if (entry) {
|
||||
entry.count += 1;
|
||||
if (!entry.image && img) entry.image = img;
|
||||
} else {
|
||||
perId.set(p.categoryId, { id: p.categoryId, name: p.categoryNameFa, count: 1, image: img });
|
||||
}
|
||||
}
|
||||
|
||||
const perName = new Map<string, CategorySummary>();
|
||||
for (const entry of perId.values()) {
|
||||
const existing = perName.get(entry.name);
|
||||
if (!existing) {
|
||||
perName.set(entry.name, {
|
||||
categoryId: entry.id,
|
||||
name: entry.name,
|
||||
count: entry.count,
|
||||
image: entry.image,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
existing.count += entry.count;
|
||||
const currentBest = perId.get(existing.categoryId)?.count ?? 0;
|
||||
if (entry.count > currentBest) existing.categoryId = entry.id;
|
||||
if (!existing.image && entry.image) existing.image = entry.image;
|
||||
}
|
||||
|
||||
return Array.from(perName.values())
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export function stripHtml(html: string, maxLength = 140): string {
|
||||
const text = html
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
|
||||
Reference in New Issue
Block a user