Add Torob tags, Meshkee SEO routes, and updated static-image slots.

Product pages emit crawler-readable meta, public URLs use id plus Farsi slugs, and the homepage catalog matches the dashboard slot keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-01 16:12:26 +03:30
co-authored by Cursor
parent f42c30a4b7
commit c84147e765
40 changed files with 838 additions and 288 deletions
+4 -2
View File
@@ -6,12 +6,14 @@ import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
export function AboutUs({
image,
images = [],
aspectRatio,
}: {
image?: StaticImageWithUrl;
images?: StaticImageWithUrl[];
aspectRatio?: string;
}) {
const image = images[0];
return (
<section id="about" className="container-site mt-14">
<div className="grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
+25 -21
View File
@@ -30,7 +30,7 @@ export function BannerGrid4({ images = [], aspectRatio }: BannerSlotProps) {
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(aspectRatio, "21:9")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
{hasCopy ? (
<>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]" style={banner4Overlay} />
@@ -85,28 +85,32 @@ export function BannerGrid4({ images = [], aspectRatio }: BannerSlotProps) {
);
}
export function BannerRow3({ images = [], aspectRatio }: BannerSlotProps) {
export function BannerRow({ images = [], aspectRatio }: BannerSlotProps) {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
{images.length > 0
? images.map((image) => (
<div key={image.id} className="overflow-hidden rounded-2xl">
<SlotImage
image={image}
alt={slotAlt(image)}
imgClassName="h-auto w-full object-cover"
aspectRatio={aspectRatio}
fallbackAspect="12:5"
/>
</div>
))
: banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
</div>
{images.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
{images.map((image) => (
<div key={image.id} className="overflow-hidden rounded-2xl">
<SlotImage
image={image}
alt={slotAlt(image, "بنر سالار استور")}
imgClassName="h-auto w-full object-cover"
aspectRatio={aspectRatio}
fallbackAspect="12:5"
/>
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 md:gap-4">
{banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
</Link>
))}
</div>
)}
</section>
);
}
+3 -3
View File
@@ -18,11 +18,11 @@ export async function BlogCarousel() {
<Carousel ariaLabel="مقالات">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
key={post.id || post.slug}
href={post.href || `/blog/${post.slug}`}
className="snap-start shrink-0 w-[240px] sm:w-[280px] rounded-2xl bg-white border border-black/5 overflow-hidden hover:border-brand/40"
>
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="280px" />
<Media src={post.image} alt={post.title} className="aspect-[16/10]" sizes="280px" />
<div className="p-4">
<time className="text-xs text-zinc-400">{post.date}</time>
<h3 className="mt-2 text-sm leading-7 line-clamp-2">{post.title}</h3>
+45 -9
View File
@@ -1,20 +1,56 @@
import Image from "next/image";
import { SlotImage } from "@/components/SlotImage";
import { brands } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { Carousel } from "./Carousel";
export function Brands() {
type Props = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
};
export function Brands({ images = [], aspectRatio }: Props) {
return (
<section className="container-site mt-12">
<h2 className="section-title mb-6">برندها</h2>
<Carousel ariaLabel="برندها">
{brands.map((brand) => (
<div
key={brand.name}
className="snap-start shrink-0 w-[168px] sm:w-[196px] h-[108px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
>
<Image src={brand.image} alt={brand.name} width={180} height={80} className="max-h-[72px] w-auto object-contain" />
</div>
))}
{images.length > 0
? images.map((image) => {
const card = (
<div
className="snap-start shrink-0 w-[168px] sm:w-[196px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
style={slotAspectStyle(aspectRatio, "2:1")}
>
<SlotImage
image={{ ...image, linkUrl: null }}
alt={slotAlt(image, "برند")}
imgClassName="max-h-[72px] w-auto object-contain"
/>
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="snap-start shrink-0">
{card}
</a>
) : (
<div key={image.id}>{card}</div>
);
})
: brands.map((brand) => (
<div
key={brand.name}
className="snap-start shrink-0 w-[168px] sm:w-[196px] h-[108px] rounded-xl bg-white border border-black/5 grid place-items-center px-3"
>
<Image
src={brand.image}
alt={brand.name}
width={180}
height={80}
className="max-h-[72px] w-auto object-contain"
/>
</div>
))}
</Carousel>
</section>
);
+1 -1
View File
@@ -84,7 +84,7 @@ export function CartPopup({ open, onClose }: Props) {
onClick={onClose}
className="size-20 shrink-0 overflow-hidden rounded-xl border border-black/5"
>
<Media src={item.image || PLACEHOLDER.product} alt="" className="size-full" sizes="80px" />
<Media src={item.image || PLACEHOLDER.product} alt={item.name} className="size-full" sizes="80px" />
</Link>
<div className="min-w-0 flex-1">
<Link
+29 -12
View File
@@ -2,11 +2,21 @@ import Link from "next/link";
import Image from "next/image";
import { SlotImage } from "@/components/SlotImage";
import { categories } from "@/lib/data";
import { getCategories } from "@/lib/api";
import { categoryHref } from "@/lib/paths";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { DrawFrame } from "./DrawFrame";
export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
export async function Categories({
images = [],
aspectRatio,
}: {
images?: StaticImageWithUrl[];
aspectRatio?: string;
}) {
const apiCategories = images.length > 0 ? [] : await getCategories();
const bySlug = new Map(apiCategories.map((item) => [item.slug, item]));
return (
<section className="container-site mt-12">
<div className="flex items-end justify-between mb-6">
@@ -18,27 +28,31 @@ export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
{images.length > 0
? images.map((image) => {
const label = slotAlt(image);
const nameFa = image.titleFa?.trim();
const nameEn = image.titleEn?.trim();
const card = (
<div className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand">
<div className="size-[72px] sm:size-20 shrink-0 overflow-hidden" style={slotAspectStyle("1:1")}>
<div
className="size-[72px] sm:size-20 shrink-0 overflow-hidden"
style={slotAspectStyle(aspectRatio, "1:1")}
>
<SlotImage
image={{ ...image, linkUrl: null }}
alt={label}
alt={slotAlt(image, nameFa || nameEn || "دسته‌بندی")}
imgClassName="h-full w-full object-contain"
/>
</div>
{label || image.subtext ? (
{nameFa || nameEn ? (
<span className="min-w-0">
{label ? (
<span className="block text-lg font-medium text-ink leading-none">{label}</span>
{nameFa ? (
<span className="block text-lg font-medium text-ink leading-none">{nameFa}</span>
) : null}
{image.subtext ? (
{nameEn ? (
<span
dir="ltr"
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
>
{image.subtext}
{nameEn}
</span>
) : null}
</span>
@@ -54,10 +68,12 @@ export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
<div key={image.id}>{card}</div>
);
})
: categories.map((cat) => (
: categories.map((cat) => {
const api = bySlug.get(cat.slug);
return (
<Link
key={cat.slug}
href={`/products/${cat.slug}`}
href={api ? categoryHref(api) : `/products/${cat.slug}`}
className="draw-frame-host relative flex items-center gap-3 rounded-2xl border border-black/5 bg-zinc-50 px-3 py-2 text-brand"
>
<Image
@@ -78,7 +94,8 @@ export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
</span>
<DrawFrame />
</Link>
))}
);
})}
</div>
</section>
);
+1 -1
View File
@@ -35,7 +35,7 @@ export function Footer() {
<div className="container-site grid grid-cols-2 md:grid-cols-4 gap-6 py-8">
{features.map((item) => (
<div key={item.title} className="flex items-center gap-3">
<Image src={item.image} alt="" width={48} height={48} className="w-12 h-12 object-contain" />
<Image src={item.image} alt={item.title} width={48} height={48} className="w-12 h-12 object-contain" />
<h3 className="text-sm font-medium text-ink">{item.title}</h3>
</div>
))}
+12 -12
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { customerLoginUrl } from "@/lib/customer";
import { parentCategories } from "@/lib/data";
import type { MenuCategory } from "@/lib/nav";
import { SITE } from "@/lib/site";
import { useCart } from "@/lib/cart";
import { useSession } from "@/lib/session";
@@ -20,7 +20,7 @@ const nav = [
{ name: "تماس با ما", href: "/contactus" },
];
export function Header() {
export function Header({ menuCategories = [] }: { menuCategories?: MenuCategory[] }) {
const [open, setOpen] = useState(false);
const [mega, setMega] = useState(false);
const [cartOpen, setCartOpen] = useState(false);
@@ -60,16 +60,16 @@ export function Header() {
</Link>
{mega && (
<div className="absolute top-full right-0 w-[760px] rounded-2xl bg-white border border-black/5 shadow-xl p-5 grid grid-cols-4 gap-5">
{parentCategories.map((cat) => (
<div key={cat.slug}>
<Link href={`/products/${cat.slug}`} className="block mb-3 text-sm font-bold text-brand">
{menuCategories.map((cat) => (
<div key={cat.href}>
<Link href={cat.href} className="block mb-3 text-sm font-bold text-brand">
{cat.name}
</Link>
<ul className="space-y-1.5">
{cat.children.slice(0, 8).map((child) => (
<li key={child.slug}>
<li key={child.href}>
<Link
href={`/products/${child.slug}`}
href={child.href}
className="block text-[13px] text-zinc-600 hover:text-ink"
>
{child.name}
@@ -131,15 +131,15 @@ export function Header() {
<SearchBox className="mb-5 rounded-full bg-zinc-100 px-3" onNavigate={() => setOpen(false)} />
<p className="text-xs text-zinc-400 mb-2">محصولات</p>
<div className="space-y-4 mb-6">
{parentCategories.map((cat) => (
<div key={cat.slug}>
<Link href={`/products/${cat.slug}`} className="font-bold text-brand" onClick={() => setOpen(false)}>
{menuCategories.map((cat) => (
<div key={cat.href}>
<Link href={cat.href} className="font-bold text-brand" onClick={() => setOpen(false)}>
{cat.name}
</Link>
<ul className="mt-2 grid grid-cols-2 gap-1">
{cat.children.slice(0, 6).map((child) => (
<li key={child.slug}>
<Link href={`/products/${child.slug}`} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
<li key={child.href}>
<Link href={child.href} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
{child.name}
</Link>
</li>
+1 -1
View File
@@ -177,7 +177,7 @@ export function HeroSlider({
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(sideAspectRatio, "13:5")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
{hasCopy ? (
<>
<div
+2 -2
View File
@@ -8,14 +8,14 @@ export function ProductCard({ product }: { product: Product }) {
const unavailable = product.stock === 0 || product.price === 0;
return (
<Link href={product.slug ? `/products/${product.slug}` : "/products"} className="relative flex h-full min-h-[300px] flex-col overflow-hidden rounded-xl bg-white p-2">
<Link href={product.href || "/products"} className="relative flex h-full min-h-[300px] flex-col overflow-hidden rounded-xl bg-white p-2">
{off > 0 && (
<span className="absolute top-2 left-2 z-[2] rounded-full bg-brand px-2.5 py-1.5 font-oswald text-[13px] font-bold leading-none text-white shadow-md">
{off}%
</span>
)}
<div className="relative">
<Media src={product.image} alt="" className="aspect-square rounded-lg" sizes="220px" />
<Media src={product.image} alt={product.name} className="aspect-square rounded-lg" sizes="220px" />
</div>
<h3 className="mt-2 px-1 text-[13px] leading-6 text-ink line-clamp-2 min-h-12">{product.name}</h3>
<div className="mt-auto px-1 pb-1 pt-2">
+1 -1
View File
@@ -22,7 +22,7 @@ export function ProductShelf({ id, image, products, ariaLabel, tone = "red" }: P
<div className="rounded-2xl p-4 md:p-5" style={{ background: gradients[tone] }}>
<div className="flex items-stretch gap-3">
<div className="relative w-[150px] sm:w-[190px] shrink-0 min-h-[300px]">
<Image src={image} alt="" fill sizes="190px" className="object-contain object-center" />
<Image src={image} alt={ariaLabel} fill sizes="190px" className="object-contain object-center" />
</div>
<Carousel ariaLabel={ariaLabel} className="min-w-0 flex-1 py-1" tight>
{products.map((product) => (
+7 -6
View File
@@ -29,9 +29,10 @@ export function ProductView({ product, variants }: Props) {
const [qty, setQty] = useState(1);
const [added, setAdded] = useState(false);
const name = product.nameFa || product.title;
const variant = variants.find((item) => item.id === variantId) ?? variants[0];
const price = variant ? (variant.discountedPrice ?? variant.price) : (product.store?.minPrice ?? 0);
const oldPrice = variant?.discountedPrice ? variant.price : undefined;
const price = variant ? (variant.discountedPrice ?? variant.price ?? 0) : (product.store?.minPrice ?? 0);
const oldPrice = variant?.discountedPrice ? (variant.price ?? undefined) : undefined;
const stock = variant ? (variant.stockQuantity ?? 0) : product.store?.inStock ? 1 : 0;
const unavailable = !price || stock === 0;
const off = discountPercent(price, oldPrice);
@@ -45,11 +46,11 @@ export function ProductView({ product, variants }: Props) {
{off}%
</span>
)}
<Media src={image} alt={product.nameFa || product.title} className="aspect-square" sizes="560px" priority />
<Media src={image} alt={name} className="aspect-square" sizes="560px" priority />
</div>
{images.length > 1 && (
<div className="mt-3 grid grid-cols-6 gap-2">
{images.map((src) => (
{images.map((src, index) => (
<button
key={src}
type="button"
@@ -58,7 +59,7 @@ export function ProductView({ product, variants }: Props) {
src === image ? "border-brand" : "border-black/10"
}`}
>
<Media src={src} alt="" className="size-full" sizes="80px" />
<Media src={src} alt={`${name} — تصویر ${(index + 1).toLocaleString("fa-IR")}`} className="size-full" sizes="80px" />
</button>
))}
</div>
@@ -67,7 +68,7 @@ export function ProductView({ product, variants }: Props) {
<div className="rounded-2xl border border-black/5 p-5 md:p-6 lg:col-span-6">
{product.categoryNameFa && <p className="mb-2 text-xs text-zinc-400">{product.categoryNameFa}</p>}
<h1 className="text-xl md:text-2xl font-extrabold leading-9">{product.nameFa || product.title}</h1>
<p className="text-xl md:text-2xl font-extrabold leading-9">{name}</p>
{product.title && product.nameFa && (
<p dir="ltr" className="mt-1 text-sm text-zinc-400">
{product.title}
+2 -2
View File
@@ -100,11 +100,11 @@ export function SearchBox({ className = "", onNavigate }: Props) {
return (
<li key={product.id}>
<Link
href={product.slug ? `/products/${product.slug}` : `/products?q=${encodeURIComponent(query)}`}
href={product.href || `/products?q=${encodeURIComponent(query)}`}
onClick={onNavigate}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-50"
>
<Media src={product.image} alt="" className="size-12 shrink-0 rounded-lg" sizes="48px" />
<Media src={product.image} alt={product.name} className="size-12 shrink-0 rounded-lg" sizes="48px" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] leading-6">{product.name}</p>
{unavailable ? (