Compare commits

..
4 Commits
Author SHA1 Message Date
Alireza HassaniandCursor c84147e765 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>
2026-09-01 16:12:26 +03:30
Websites Agent f42c30a4b7 Enable Next.js standalone output for leaner production deploy 2026-08-29 04:20:28 +00:00
Alireza HassaniandCursor 49b83223f1 Wire homepage banners to Meshkee static-image slots.
Publish a slot catalog so the dashboard can import keys, and keep local placeholders when a slot is empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:54:10 +03:30
Alireza HassaniandCursor 46e4835360 Stop importing gitignored sample images so production build can succeed.
Deploy checkouts do a hard reset, so Unsplash files ignored locally are missing on the server and next build fails with Module not found.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:38:50 +03:30
46 changed files with 1542 additions and 376 deletions
+17
View File
@@ -0,0 +1,17 @@
---
description: Keep Meshkee static-image slot catalog in sync with on-page image sizes.
globs: lib/static-images.ts,app/meshkee/**,components/HeroSlider.tsx,components/Banners.tsx,components/Categories.tsx,components/Brands.tsx,components/AboutUs.tsx,app/page.tsx,app/aboutus/page.tsx
alwaysApply: false
---
# Static image slot catalog
Named homepage slots are defined in `lib/static-images.ts` (`STATIC_IMAGE_SLOT_CATALOG`) and published at `GET /meshkee/static-image-slots`.
When you change a slot’s layout, CSS aspect ratio, or displayed width:
1. Update that slot’s `aspectRatio` (`W:H`) and `recommendedWidth` in `STATIC_IMAGE_SLOT_CATALOG` in the **same change**.
2. Keep using `slot.aspectRatio` from the Meshkee API on the rendered frame.
3. Do not invent upload or CMS APIs. Empty/failed slots keep the local placeholder.
Keys: `slider` (duplicatable), `two-banner-below-slider` (2), `categories` (duplicatable), `four-banner` (4), `two-banner-row` (2), `brands` (duplicatable), `about-us-image` (2).
+3 -1
View File
@@ -20,7 +20,9 @@
# production
/build
# sample images
# Local Unsplash samples — never import these in app code.
# Production deploy does a hard git reset, so gitignored files disappear
# and `next build` fails with Module not found. Use public/images/placeholders/.
/src/assets/images/samples/
# misc
+47
View File
@@ -0,0 +1,47 @@
# Salar Store — AI context
Persian RTL storefront for **salarstore.com**. Next.js App Router. Do not invent Meshkee admin/upload APIs.
## Meshkee
- Apex domain: `salarstore.com`
- API: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_API_BASE_URL`)
- Tenant: `GET /tenants/salarstore.com` → `businessId`
- Public content: `/tenants/salarstore.com/...` (no auth)
- Customer app: `https://customer.salarstore.com`
- Business app: `https://business.salarstore.com`
Docs: https://api.meshkee.com/docs/website
## Static image slots
Dashboard-replaceable homepage images. Fetch `GET /tenants/salarstore.com/website/static-images?pageKey=home`. Empty/fail → keep local placeholders.
The business dashboard **Refresh** button imports keys from:
`GET https://salarstore.com/meshkee/static-image-slots`
Source of truth: `lib/static-images.ts` (`STATIC_IMAGE_SLOT_CATALOG`).
| key | kind | itemCount | aspectRatio | where |
| --- | --- | --- | --- | --- |
| `slider` | list (duplicatable) | unbounded | `21:8` | Homepage hero. Overlay `titleFa` / `titleEn` / `subtext` when set. |
| `two-banner-below-slider` | list | 2 | `13:5` | Two tiles under the slider. Overlay `titleFa`, `titleEn`, `subtext` like the original UI. |
| `categories` | list (duplicatable) | unbounded | `1:1` | Category row. `titleFa` + `titleEn` beside the icon. |
| `four-banner` | list | 4 | `21:9` | 2×2 banner grid. Overlay `titleFa` / `titleEn` / `subtext` when set. |
| `two-banner-row` | list | 2 | `12:5` | Two-up row after top-selling. |
| `brands` | list (duplicatable) | unbounded | `2:1` | Brand logo carousel. |
| `about-us-image` | list | 2 | `8:5` | Home about uses image 1. `/aboutus` uses image 1 as hero and image 2 as the side portrait. |
Previous keys (`banners-below-slider`, `four-banner-section`, `three-banner-row`) are still read as aliases until the dashboard Refresh imports the catalog again.
Rules:
- Use `slot.images` (all if `kind=list`, first if `kind=single`). `url` → `<img src>`. `linkUrl` → wrap in `<a>`.
- Apply `slot.aspectRatio` from the API on the rendered frame.
- **If a slot’s on-page size or aspect ratio changes, update `STATIC_IMAGE_SLOT_CATALOG` (`aspectRatio` + `recommendedWidth`) in the same change** so the dashboard cropper stays in sync.
- Do not add upload/CMS endpoints on this website.
## Cart + customer session
Guest cart key `meshkee-guest-cart` (localStorage + cookie on `.salarstore.com`). Line `id` is the **store item variant id**. Continue hands off to `https://customer.salarstore.com/checkout/cart#guestCart=...` (guests go via login with that hash inside `redirect`). Shared cookies: `meshkee_customer_access_token` / `meshkee_customer_refresh_token`.
+20
View File
@@ -0,0 +1,20 @@
Wire this page to salarstore.com static-image slots.
Read http://meshkee.com/docs/website (static-images). Env is already set.
Fetch slots, match by key:
- slider > duplicatable
- banners-below-slider > two image
- categories > duplicatable
- four-banner-section → four slot
- three-banner-row → three slot
- about us image - one slot
Use slot.images (all of them if kind=list, first if kind=single).
url → <img src>. linkUrl → wrap in <a>. Empty or fail → keep current placeholder.
Use slot.aspectRatio from the API. Do not invent upload APIs.
If image size or aspect ratio has changed during development, please update it for statics image reader. Add this as rule
update context md for better use in other Ai and systems - update it on every prompt (If required)
+41 -16
View File
@@ -1,27 +1,44 @@
import type { Metadata } from "next";
import Image from "next/image";
import { PageHero } from "@/components/PageHero";
import { SlotImage } from "@/components/SlotImage";
import { SITE } from "@/lib/site";
import {
STATIC_IMAGE_KEYS,
getStaticImageSlot,
imagesForSlot,
slotAlt,
} from "@/lib/static-images";
export const metadata: Metadata = {
title: `درباره ما | ${SITE.nameFa}`,
description: SITE.about,
alternates: { canonical: "/aboutus" },
};
export default function AboutPage() {
export default async function AboutPage() {
const slot = await getStaticImageSlot(STATIC_IMAGE_KEYS.aboutUsImage);
const images = slot ? imagesForSlot([slot], STATIC_IMAGE_KEYS.aboutUsImage) : [];
const hero = images[0];
const side = images[1];
return (
<>
<PageHero title="درباره ما" english="About Us" />
<section className="container-site py-10 md:py-14">
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
<Image
src="/images/pages/about-bg.webp"
alt=""
fill
priority
sizes="1200px"
className="object-cover object-center"
/>
{hero ? (
<SlotImage image={hero} fill alt={slotAlt(hero, SITE.nameFa)} />
) : (
<Image
src="/images/pages/about-bg.webp"
alt={SITE.nameFa}
fill
priority
sizes="1200px"
className="object-cover object-center"
/>
)}
<div
className="pointer-events-none absolute inset-0"
style={{
@@ -42,13 +59,21 @@ export default function AboutPage() {
<p className="text-zinc-600 leading-8 text-justify">{SITE.about}</p>
</div>
<div className="relative mx-auto w-full max-w-xs">
<Image
src="/images/pages/about-side.png"
alt={SITE.nameFa}
width={300}
height={603}
className="h-auto w-full object-contain"
/>
{side ? (
<SlotImage
image={side}
alt={slotAlt(side, SITE.nameFa)}
imgClassName="h-auto w-full object-contain"
/>
) : (
<Image
src="/images/pages/about-side.png"
alt={SITE.nameFa}
width={300}
height={603}
className="h-auto w-full object-contain"
/>
)}
</div>
</div>
</section>
@@ -1,25 +1,27 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { notFound, permanentRedirect } from "next/navigation";
import { Media } from "@/components/Media";
import { PageHero } from "@/components/PageHero";
import { RichContent } from "@/components/RichContent";
import { getBlog } from "@/lib/api";
import { getBlogById } from "@/lib/api";
import { formatDate } from "@/lib/format";
import { PLACEHOLDER } from "@/lib/data";
import { blogHref, blogTitleSlug } from "@/lib/paths";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ slug: string }>;
params: Promise<{ id: string; slug: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const { id } = await params;
try {
const blog = await getBlog(slug);
const blog = await getBlogById(id);
if (!blog) return { title: SITE.nameFa };
return {
title: `${blog.title} | ${SITE.nameFa}`,
description: blog.abstract || SITE.aboutShort,
description: blog.abstract?.trim() || SITE.aboutShort,
alternates: { canonical: blogHref(blog) },
};
} catch {
return { title: SITE.nameFa };
@@ -27,10 +29,14 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
export default async function BlogDetailPage({ params }: Props) {
const { slug } = await params;
const blog = await getBlog(slug);
const { id, slug } = await params;
const blog = await getBlogById(id);
if (!blog) notFound();
if (decodeURIComponent(slug) !== blogTitleSlug(blog.title)) {
permanentRedirect(blogHref(blog));
}
const author = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ");
return (
+20
View File
@@ -0,0 +1,20 @@
import { notFound, permanentRedirect } from "next/navigation";
import { getBlog, getBlogById } from "@/lib/api";
import { blogHref } from "@/lib/paths";
/** Legacy `/blog/{slug-or-id}` → canonical `/blog/{id}/{titleSlug}`. */
export default async function LegacyBlogRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const byId = await getBlogById(id);
if (byId) permanentRedirect(blogHref(byId));
const bySlug = await getBlog(id);
if (bySlug) permanentRedirect(blogHref(bySlug));
notFound();
}
+5 -4
View File
@@ -9,7 +9,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `وبلاگ | ${SITE.nameFa}`,
description: "آخرین مقالات سالار استور",
description: "مقالات کوهنوردی، کمپینگ و راهنمای خرید لوازم ورزشی از سالار استور",
alternates: { canonical: "/blog" },
};
type Props = {
@@ -35,11 +36,11 @@ export default async function BlogPage({ searchParams }: Props) {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Link
key={post.slug}
href={`/blog/${post.slug}`}
key={post.id || post.slug}
href={post.href || `/blog/${post.slug}`}
className="overflow-hidden rounded-2xl border border-black/5 bg-white hover:border-brand/40"
>
<Media src={post.image} alt="" className="aspect-[16/10]" sizes="400px" />
<Media src={post.image} alt={post.title} className="aspect-[16/10]" sizes="400px" />
<div className="p-4">
<time className="text-xs text-zinc-400">{post.date}</time>
<h2 className="mt-2 text-base font-bold leading-8 line-clamp-2">{post.title}</h2>
+2 -1
View File
@@ -6,7 +6,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `تماس با ما | ${SITE.nameFa}`,
description: SITE.address,
description: `تماس با فروشگاه لوازم ورزشی سالار استور — ${SITE.address}`,
alternates: { canonical: "/contactus" },
};
const contacts = [
+4 -2
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { TopHeader } from "@/components/TopHeader";
import { getTenant } from "@/lib/api";
import { getMenuCategories } from "@/lib/nav";
import { SITE } from "@/lib/site";
import "./globals.css";
@@ -28,19 +29,20 @@ const carter = Carter_One({
});
export const metadata: Metadata = {
metadataBase: new URL(`https://${SITE.domain}`),
title: `${SITE.nameFa} | ${SITE.tagline}`,
description: SITE.aboutShort,
};
export default async function RootLayout({ children }: LayoutProps<"/">) {
const tenant = await getTenant();
const [tenant, menuCategories] = await Promise.all([getTenant(), getMenuCategories()]);
return (
<html lang="fa" dir="rtl" className={`${vazir.variable} ${oswald.variable} ${carter.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-white text-ink">
<AppProviders businessId={tenant ? String(tenant.id) : ""}>
<TopHeader />
<Header />
<Header menuCategories={menuCategories} />
<main className="flex-1">{children}</main>
<Footer />
</AppProviders>
+17
View File
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { STATIC_IMAGE_SLOT_CATALOG } from "@/lib/static-images";
export function GET() {
return NextResponse.json(
{ slots: STATIC_IMAGE_SLOT_CATALOG },
{
headers: {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=60",
},
},
);
}
+7
View File
@@ -1,4 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `صفحه پیدا نشد | ${SITE.nameFa}`,
description: "صفحه مورد نظر در سالار استور پیدا نشد.",
};
export default function NotFound() {
return (
+48 -8
View File
@@ -1,23 +1,63 @@
import { AboutUs } from "@/components/AboutUs";
import { BannerGrid4, BannerRow3 } from "@/components/Banners";
import { BannerGrid4, BannerRow } from "@/components/Banners";
import { BlogCarousel } from "@/components/BlogCarousel";
import { Brands } from "@/components/Brands";
import { Categories } from "@/components/Categories";
import { HeroSlider } from "@/components/HeroSlider";
import { SpecialSale } from "@/components/SpecialSale";
import { TopSelling } from "@/components/TopSelling";
import {
STATIC_IMAGE_KEYS,
getStaticImageSlots,
imagesForSlot,
slotByKey,
} from "@/lib/static-images";
import { SITE } from "@/lib/site";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: `${SITE.nameFa} | ${SITE.tagline}`,
description: SITE.aboutShort,
alternates: { canonical: "/" },
};
export default async function Home() {
const slots = await getStaticImageSlots("home");
const sliderSlot = slotByKey(slots, STATIC_IMAGE_KEYS.slider);
const sideSlot = slotByKey(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider);
const categorySlot = slotByKey(slots, STATIC_IMAGE_KEYS.categories);
const fourSlot = slotByKey(slots, STATIC_IMAGE_KEYS.fourBanner);
const twoRowSlot = slotByKey(slots, STATIC_IMAGE_KEYS.twoBannerRow);
const brandsSlot = slotByKey(slots, STATIC_IMAGE_KEYS.brands);
const aboutSlot = slotByKey(slots, STATIC_IMAGE_KEYS.aboutUsImage);
export default function Home() {
return (
<>
<HeroSlider />
<h1 className="sr-only">
{SITE.nameFa}؛ {SITE.tagline}
</h1>
<HeroSlider
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)}
aspectRatio={sliderSlot?.aspectRatio}
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider)}
sideAspectRatio={sideSlot?.aspectRatio}
/>
<SpecialSale />
<Categories />
<BannerGrid4 />
<Categories
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)}
aspectRatio={categorySlot?.aspectRatio}
/>
<BannerGrid4
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBanner)}
aspectRatio={fourSlot?.aspectRatio}
/>
<TopSelling />
<BannerRow3 />
<Brands />
<AboutUs />
<BannerRow
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerRow)}
aspectRatio={twoRowSlot?.aspectRatio}
/>
<Brands images={imagesForSlot(slots, STATIC_IMAGE_KEYS.brands)} aspectRatio={brandsSlot?.aspectRatio} />
<AboutUs images={imagesForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)} aspectRatio={aboutSlot?.aspectRatio} />
<BlogCarousel />
</>
);
+72
View File
@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { ProductTabs } from "@/components/ProductTabs";
import { ProductView } from "@/components/ProductView";
import { getComments, getProductById, getStoreItemByProduct } from "@/lib/api";
import { productHref, productNameSlug, productTitle } from "@/lib/paths";
import { SITE } from "@/lib/site";
import { torobMetadata } from "@/lib/torob";
type Props = {
params: Promise<{ id: string; slug: string }>;
};
async function loadProduct(id: string) {
return getProductById(id);
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
try {
const product = await loadProduct(id);
if (!product) return { title: SITE.nameFa };
let storeItem = null;
try {
storeItem = await getStoreItemByProduct(product.id);
} catch {
storeItem = null;
}
const title = productTitle(product);
return {
title: `${title} | ${SITE.nameFa}`,
description: product.summary?.trim() || SITE.tagline,
alternates: { canonical: productHref(product) },
...torobMetadata(product, storeItem),
};
} catch {
return { title: SITE.nameFa };
}
}
export default async function ProductDetailsPage({ params }: Props) {
const { id, slug } = await params;
const product = await loadProduct(id);
if (!product) notFound();
const title = productTitle(product);
if (decodeURIComponent(slug) !== productNameSlug(title)) {
permanentRedirect(productHref(product));
}
const [storeItem, comments] = await Promise.all([
getStoreItemByProduct(product.id),
getComments("product", product.id),
]);
return (
<>
<PageHero
title={title}
english={product.title}
crumbs={[{ name: "محصولات", href: "/products" }, { name: title }]}
/>
<section className="container-site py-10 md:py-14">
<ProductView product={product} variants={storeItem?.variants ?? []} />
<ProductTabs productId={product.id} descriptionHtml={product.descriptionHtml} comments={comments} />
</section>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { notFound, permanentRedirect } from "next/navigation";
import { getCategoryById, getCategoryBySlug, getProduct, getProductById } from "@/lib/api";
import { categoryHref, productHref } from "@/lib/paths";
/** Legacy `/products/{id-or-slug}` → canonical product or category URL. */
export default async function LegacyProductOrCategoryRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const productById = await getProductById(id);
if (productById) permanentRedirect(productHref(productById));
const productBySlug = await getProduct(id);
if (productBySlug) permanentRedirect(productHref(productBySlug));
const categoryById = await getCategoryById(id);
if (categoryById) permanentRedirect(categoryHref(categoryById));
const categoryBySlug = await getCategoryBySlug(id);
if (categoryBySlug) permanentRedirect(categoryHref(categoryBySlug));
notFound();
}
-111
View File
@@ -1,111 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { Pagination } from "@/components/Pagination";
import { ProductGrid } from "@/components/ProductGrid";
import { ProductTabs } from "@/components/ProductTabs";
import { ProductView } from "@/components/ProductView";
import { getCategories, getCategoryBySlug, getComments, getProduct, getStoreItemByProduct } from "@/lib/api";
import { listMappedProductsForCategory } from "@/lib/mappers";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
try {
const product = await getProduct(slug);
if (product) {
return {
title: `${product.nameFa || product.title} | ${SITE.nameFa}`,
description: product.summary || SITE.tagline,
};
}
const category = await getCategoryBySlug(slug);
if (category) {
return {
title: `${category.nameFa || category.name} | ${SITE.nameFa}`,
description: SITE.tagline,
};
}
} catch {
// fall through
}
return { title: SITE.nameFa };
}
export default async function ProductOrCategoryPage({ params, searchParams }: Props) {
const { slug } = await params;
const product = await getProduct(slug);
if (product) {
const [storeItem, comments] = await Promise.all([
getStoreItemByProduct(product.id),
getComments("product", product.id),
]);
return (
<>
<PageHero
title={product.nameFa || product.title}
english={product.title}
crumbs={[{ name: "محصولات", href: "/products" }, { name: product.nameFa || product.title }]}
/>
<section className="container-site py-10 md:py-14">
<ProductView product={product} variants={storeItem?.variants ?? []} />
<ProductTabs productId={product.id} descriptionHtml={product.descriptionHtml} comments={comments} />
</section>
</>
);
}
const [category, categories] = await Promise.all([getCategoryBySlug(slug), getCategories()]);
if (!category) notFound();
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const children = categories.filter((item) => item.parentId === category.id);
const data = await listMappedProductsForCategory(
category.id,
children.map((item) => item.id),
page,
16,
);
return (
<>
<PageHero
title={category.nameFa || category.name}
english={category.name}
crumbs={[{ name: "محصولات", href: "/products" }, { name: category.nameFa || category.name }]}
/>
<section className="container-site py-10 md:py-14">
{children.length > 0 && (
<div className="mb-8 flex flex-wrap gap-2">
{children.map((item) => (
<Link
key={item.id}
href={`/products/${item.slug}`}
className="rounded-full border border-black/10 px-4 py-2 text-sm hover:border-brand hover:text-brand"
>
{item.nameFa || item.name}
</Link>
))}
</div>
)}
<ProductGrid products={data.items} />
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
makeHref={(nextPage) => (nextPage > 1 ? `/products/${slug}?page=${nextPage}` : `/products/${slug}`)}
/>
</section>
</>
);
}
@@ -0,0 +1,87 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, permanentRedirect } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { Pagination } from "@/components/Pagination";
import { ProductGrid } from "@/components/ProductGrid";
import { getCategories, getCategoryById } from "@/lib/api";
import { listMappedProductsForCategory } from "@/lib/mappers";
import { categoryHref, categoryNameSlug, categoryTitle } from "@/lib/paths";
import { SITE } from "@/lib/site";
type Props = {
params: Promise<{ id: string; slug: string }>;
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
try {
const category = await getCategoryById(id);
if (!category) return { title: SITE.nameFa };
const title = categoryTitle(category);
return {
title: `${title} | ${SITE.nameFa}`,
description: category.description?.trim() || `خرید ${title} از فروشگاه لوازم ورزشی و کوهنوردی سالار استور`,
alternates: { canonical: categoryHref(category) },
};
} catch {
return { title: SITE.nameFa };
}
}
export default async function CategoryProductsPage({ params, searchParams }: Props) {
const { id, slug } = await params;
const [category, categories] = await Promise.all([getCategoryById(id), getCategories()]);
if (!category || category.isActive === false) notFound();
const title = categoryTitle(category);
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
if (decodeURIComponent(slug) !== categoryNameSlug(title)) {
const href = categoryHref(category);
permanentRedirect(page > 1 ? `${href}?page=${page}` : href);
}
const children = categories.filter((item) => item.parentId === category.id);
const data = await listMappedProductsForCategory(
category.id,
children.map((item) => item.id),
page,
16,
);
const baseHref = categoryHref(category);
return (
<>
<PageHero
title={title}
english={category.name}
crumbs={[{ name: "محصولات", href: "/products" }, { name: title }]}
/>
<section className="container-site py-10 md:py-14">
{children.length > 0 && (
<div className="mb-8 flex flex-wrap gap-2">
{children.map((item) => (
<Link
key={item.id}
href={categoryHref(item)}
className="rounded-full border border-black/10 px-4 py-2 text-sm hover:border-brand hover:text-brand"
>
{item.nameFa || item.name}
</Link>
))}
</div>
)}
<ProductGrid products={data.items} />
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
makeHref={(nextPage) => (nextPage > 1 ? `${baseHref}?page=${nextPage}` : baseHref)}
/>
</section>
</>
);
}
+2 -1
View File
@@ -7,7 +7,8 @@ import { SITE } from "@/lib/site";
export const metadata: Metadata = {
title: `محصولات | ${SITE.nameFa}`,
description: SITE.tagline,
description: "خرید لوازم ورزشی و کوهنوردی از فروشگاه سالار استور",
alternates: { canonical: "/products" },
};
type Props = {
+27 -10
View File
@@ -1,8 +1,19 @@
import Image from "next/image";
import Link from "next/link";
import { SlotImage } from "@/components/SlotImage";
import { SITE } from "@/lib/site";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
export function AboutUs({
images = [],
aspectRatio,
}: {
images?: StaticImageWithUrl[];
aspectRatio?: string;
}) {
const image = images[0];
export function AboutUs() {
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">
@@ -14,15 +25,21 @@ export function AboutUs() {
مشاهده بیشتر
</Link>
</div>
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
<Image
src="/images/logo/logo.png"
alt={SITE.nameFa}
width={360}
height={120}
className="h-auto w-full max-w-sm object-contain"
/>
</div>
{image ? (
<div className="relative overflow-hidden rounded-2xl bg-white" style={slotAspectStyle(aspectRatio, "8:5")}>
<SlotImage image={image} fill alt={slotAlt(image, SITE.nameFa)} />
</div>
) : (
<div className="relative flex aspect-[16/10] items-center justify-center rounded-2xl bg-white p-8 md:p-12">
<Image
src="/images/logo/logo.png"
alt={SITE.nameFa}
width={360}
height={120}
className="h-auto w-full max-w-sm object-contain"
/>
</div>
)}
</div>
</section>
);
+98 -30
View File
@@ -1,48 +1,116 @@
import Image from "next/image";
import Link from "next/link";
import { SlotImage } from "@/components/SlotImage";
import { banners3, banners4 } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
import { Media } from "./Media";
export function BannerGrid4() {
const banner4Overlay = {
background: "linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
};
type BannerSlotProps = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
};
export function BannerGrid4({ images = [], aspectRatio }: BannerSlotProps) {
return (
<section className="container-site mt-12">
<div className="grid grid-cols-2 gap-3 md:gap-4">
{banners4.map((banner) => (
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/9] min-h-[140px]">
<Image src={banner.image} alt="" fill sizes="50vw" className="object-cover object-center" />
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.55) 45%, transparent 100%)",
}}
/>
<div className="absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
<p className="text-base md:text-xl font-extrabold leading-none">{banner.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
{banner.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
</div>
</Link>
))}
{images.length > 0
? images.map((image) => {
const title = image.titleFa?.trim();
const english = image.titleEn?.trim();
const subtitle = image.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
const frame = (
<div
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(aspectRatio, "21:9")}
>
<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} />
<div className="pointer-events-none absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
{title ? (
<p className="text-base md:text-xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{subtitle}</p>
) : null}
</div>
</>
) : null}
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="block">
{frame}
</a>
) : (
<div key={image.id}>{frame}</div>
);
})
: banners4.map((banner) => (
<Link key={banner.href} href={banner.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/9] min-h-[140px]">
<Image src={banner.image} alt={banner.title} fill sizes="50vw" className="object-cover object-center" />
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[70%]" style={banner4Overlay} />
<div className="absolute inset-x-0 bottom-3 md:bottom-4 px-3 text-center text-white">
<p className="text-base md:text-xl font-extrabold leading-none">{banner.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{banner.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{banner.subtitle}</p>
</div>
</Link>
))}
</div>
</section>
);
}
export function BannerRow3() {
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">
{banners3.map((banner) => (
<Link key={banner.href} href={banner.href} className="overflow-hidden rounded-2xl">
<Media src={banner.image} 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
+84 -23
View File
@@ -1,9 +1,22 @@
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() {
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">
@@ -13,28 +26,76 @@ export function Categories() {
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 md:gap-4">
{categories.map((cat) => (
<Link
key={cat.slug}
href={`/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
src={cat.image}
alt={cat.name}
width={88}
height={88}
className="size-[72px] sm:size-20 shrink-0 object-contain"
/>
<span className="min-w-0">
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
<span dir="ltr" className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase">
{cat.english}
</span>
</span>
<DrawFrame />
</Link>
))}
{images.length > 0
? images.map((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(aspectRatio, "1:1")}
>
<SlotImage
image={{ ...image, linkUrl: null }}
alt={slotAlt(image, nameFa || nameEn || "دسته‌بندی")}
imgClassName="h-full w-full object-contain"
/>
</div>
{nameFa || nameEn ? (
<span className="min-w-0">
{nameFa ? (
<span className="block text-lg font-medium text-ink leading-none">{nameFa}</span>
) : null}
{nameEn ? (
<span
dir="ltr"
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
>
{nameEn}
</span>
) : null}
</span>
) : null}
<DrawFrame />
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl}>
{card}
</a>
) : (
<div key={image.id}>{card}</div>
);
})
: categories.map((cat) => {
const api = bySlug.get(cat.slug);
return (
<Link
key={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
src={cat.image}
alt={cat.name}
width={88}
height={88}
className="size-[72px] sm:size-20 shrink-0 object-contain"
/>
<span className="min-w-0">
<span className="block text-lg font-medium text-ink leading-none">{cat.name}</span>
<span
dir="ltr"
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
>
{cat.english}
</span>
</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>
+210 -71
View File
@@ -3,103 +3,242 @@
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { SlotImage } from "@/components/SlotImage";
import { heroSides, heroSlides } from "@/lib/data";
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
const overlayStyle = {
background:
"linear-gradient(to left, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.72) 42%, rgba(0,0,0,0.28) 72%, transparent 100%)",
};
export function HeroSlider() {
type Props = {
images?: StaticImageWithUrl[];
aspectRatio?: string;
sideImages?: StaticImageWithUrl[];
sideAspectRatio?: string;
};
export function HeroSlider({
images = [],
aspectRatio,
sideImages = [],
sideAspectRatio,
}: Props) {
const slides = images;
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const count = slides.length > 0 ? slides.length : heroSlides.length;
useEffect(() => {
if (paused) return;
setIndex((current) => (count ? current % count : 0));
}, [count]);
useEffect(() => {
if (paused || count < 2) return;
const id = window.setInterval(() => {
setIndex((current) => (current + 1) % heroSlides.length);
setIndex((current) => (current + 1) % count);
}, 5000);
return () => window.clearInterval(id);
}, [paused]);
}, [paused, count]);
const sliderStyle = slotAspectStyle(aspectRatio, "21:8");
return (
<section className="container-site pt-5">
<div
className="relative overflow-hidden rounded-2xl aspect-[21/8] min-h-[240px] md:min-h-[360px]"
className="relative overflow-hidden rounded-2xl min-h-[240px] md:min-h-[360px]"
style={sliderStyle ?? { aspectRatio: "21 / 8" }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{heroSlides.map((slide, i) => (
<div
key={slide.title}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<Image
src={slide.image}
alt=""
fill
sizes="1200px"
priority={i === 0}
className="object-cover object-[28%_center]"
{slides.length > 0
? slides.map((slide, i) => {
const title = slide.titleFa?.trim();
const english = slide.titleEn?.trim();
const subtitle = slide.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
return (
<div
key={slide.id}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<SlotImage image={slide} fill alt={slotAlt(slide, `اسلاید ${i + 1}`)} />
{hasCopy ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]"
style={overlayStyle}
/>
<div className="pointer-events-none absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right">
{title ? (
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
{subtitle}
</p>
) : null}
</div>
</>
) : null}
</div>
);
})
: heroSlides.map((slide, i) => (
<div
key={slide.title}
aria-hidden={i !== index}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
}`}
>
<Image
src={slide.image}
alt={slide.title}
fill
sizes="1200px"
priority={i === 0}
className="object-cover object-[28%_center]"
/>
</div>
))}
{slides.length === 0 ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]"
style={overlayStyle}
/>
{heroSlides.map((slide, i) => (
<div
key={`${slide.title}-copy`}
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase"
>
{slide.english}
</p>
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
{slide.subtitle}
</p>
<Link
href={slide.href}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
>
{slide.button}
</Link>
</div>
))}
</>
) : null}
{count > 1 ? (
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
{Array.from({ length: count }, (_, i) => (
<button
key={slides[i]?.id ?? heroSlides[i]?.title ?? i}
type="button"
aria-label={`اسلاید ${i + 1}`}
onClick={() => setIndex(i)}
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
/>
))}
</div>
))}
<div className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]" style={overlayStyle} />
{heroSlides.map((slide, i) => (
<div
key={`${slide.title}-copy`}
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase">
{slide.english}
</p>
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">{slide.subtitle}</p>
<Link
href={slide.href}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
>
{slide.button}
</Link>
</div>
))}
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
{heroSlides.map((slide, i) => (
<button
key={slide.title}
type="button"
aria-label={`اسلاید ${i + 1}`}
onClick={() => setIndex(i)}
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
/>
))}
</div>
) : null}
</div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{heroSides.map((side) => (
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
<Image src={side.image} alt="" fill sizes="600px" className="object-cover object-[28%_center]" />
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]" style={overlayStyle} />
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
<p dir="ltr" className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase">
{side.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
</div>
</Link>
))}
{sideImages.length > 0
? sideImages.map((image) => {
const title = image.titleFa?.trim();
const english = image.titleEn?.trim();
const subtitle = image.subtext?.trim();
const hasCopy = Boolean(title || english || subtitle);
const frame = (
<div
className="relative overflow-hidden rounded-2xl"
style={slotAspectStyle(sideAspectRatio, "13:5")}
>
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
{hasCopy ? (
<>
<div
className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]"
style={overlayStyle}
/>
<div className="pointer-events-none absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
{title ? (
<p className="text-base md:text-xl font-extrabold leading-none">{title}</p>
) : null}
{english ? (
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{english}
</p>
) : null}
{subtitle ? (
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{subtitle}</p>
) : null}
</div>
</>
) : null}
</div>
);
return image.linkUrl ? (
<a key={image.id} href={image.linkUrl} className="block">
{frame}
</a>
) : (
<div key={image.id}>{frame}</div>
);
})
: heroSides.map((side) => (
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
<Image
src={side.image}
alt={side.title}
fill
sizes="600px"
className="object-cover object-[28%_center]"
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]"
style={overlayStyle}
/>
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
<p
dir="ltr"
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
>
{side.english}
</p>
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
</div>
</Link>
))}
</div>
</section>
);
+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 ? (
+49
View File
@@ -0,0 +1,49 @@
import type { StaticImageWithUrl } from "@/lib/static-images";
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
type Props = {
image: StaticImageWithUrl;
alt?: string;
className?: string;
imgClassName?: string;
aspectRatio?: string;
fallbackAspect?: string;
fill?: boolean;
};
export function SlotImage({
image,
alt,
className = "",
imgClassName,
aspectRatio,
fallbackAspect,
fill = false,
}: Props) {
const style = fill ? undefined : slotAspectStyle(aspectRatio, fallbackAspect);
const img = (
// Slot URLs come from Meshkee CDN and may not be in next/image remotePatterns.
// eslint-disable-next-line @next/next/no-img-element
<img
src={image.url}
alt={slotAlt(image, alt)}
style={style}
className={
imgClassName ??
(fill
? "absolute inset-0 h-full w-full object-cover"
: "h-auto w-full object-cover")
}
/>
);
if (!image.linkUrl) {
return className ? <div className={className}>{img}</div> : img;
}
return (
<a href={image.linkUrl} className={className || (fill ? "absolute inset-0 block" : "block")}>
{img}
</a>
);
}
+47 -9
View File
@@ -87,13 +87,18 @@ export type ApiCategory = {
name: string;
nameFa: string | null;
slug: string;
description?: string | null;
isActive?: boolean;
};
export type ApiVariant = {
id: string;
productId?: string;
productTitle?: string | null;
productNameFa?: string | null;
productImage?: string | null;
label?: string | null;
price: number;
price: number | null;
discountedPrice?: number | null;
stockQuantity?: number | null;
sortOrder?: number;
@@ -152,6 +157,18 @@ export async function getProduct(slug: string) {
}
}
export async function getProductById(productId: string) {
try {
const data = await apiGet<{ product: ApiProduct }>(
`${tenant()}/products/by-id/${encodeURIComponent(productId)}`,
);
return data.product ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
export async function getBlogs(query: { page?: number; pageSize?: number; title?: string } = {}) {
return apiGet<Paginated<ApiBlog>>(`${tenant()}/blogs`, query);
}
@@ -166,6 +183,16 @@ export async function getBlog(slug: string) {
}
}
export async function getBlogById(blogId: string) {
try {
const data = await apiGet<{ blog: ApiBlog }>(`${tenant()}/blogs/by-id/${encodeURIComponent(blogId)}`);
return data.blog ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
export async function getStoreSpecials() {
const data = await apiGet<{ items: ApiStoreSpecial[] }>(`${tenant()}/store-specials`);
return data.items ?? [];
@@ -173,8 +200,10 @@ export async function getStoreSpecials() {
export async function getStoreItemByProduct(productId: string) {
try {
const data = await apiGet<{ storeItem: ApiStoreItem }>(`${tenant()}/store-items/by-product/${productId}`);
return data.storeItem;
const data = await apiGet<{ storeItem: ApiStoreItem | null }>(
`${tenant()}/store-items/by-product/${productId}`,
);
return data.storeItem ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
@@ -195,6 +224,21 @@ export async function getCategoryBySlug(slug: string) {
return categories.find((category) => category.slug === slug) ?? null;
}
export async function getCategoryById(categoryId: string) {
try {
const data = await apiGet<{ category: ApiCategory }>(
`${tenant()}/categories/by-id/${encodeURIComponent(categoryId)}`,
);
return data.category ?? null;
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
const categories = await getCategories();
return categories.find((category) => category.id === categoryId) ?? null;
}
throw error;
}
}
export type ApiTenant = {
id: string | number;
name: string;
@@ -219,9 +263,3 @@ export async function getComments(entityType: "product" | "blog", entityId: stri
return [];
}
}
export async function resolveProductSlug(productId: string, name: string) {
if (!name) return "";
const { items } = await getProducts({ name, pageSize: 20 });
return items.find((item) => item.id === productId)?.slug ?? "";
}
+3
View File
@@ -7,6 +7,7 @@ export type Product = {
id: string;
name: string;
slug: string;
href?: string;
price: number;
oldPrice?: number;
stock?: number | null;
@@ -14,8 +15,10 @@ export type Product = {
};
export type Post = {
id?: string;
slug: string;
title: string;
href?: string;
date: string;
image: ImageSrc;
abstract?: string;
+17 -17
View File
@@ -3,13 +3,13 @@ import {
getBlogs,
getProducts,
getStoreSpecials,
resolveProductSlug,
type ApiBlog,
type ApiProduct,
type ApiSpecialItem,
type ApiVariant,
} from "./api";
import { formatDate } from "./format";
import { blogHref, productHref } from "./paths";
function productImage(url?: string | null) {
return url || PLACEHOLDER.product;
@@ -23,35 +23,39 @@ function pickVariantPrice(variants: ApiVariant[]) {
const inStock = variants.filter((variant) => (variant.stockQuantity ?? 0) > 0);
const pool = inStock.length ? inStock : variants;
const best = pool.reduce((current, next) => {
const currentPrice = current.discountedPrice ?? current.price;
const nextPrice = next.discountedPrice ?? next.price;
const currentPrice = current.discountedPrice ?? current.price ?? 0;
const nextPrice = next.discountedPrice ?? next.price ?? 0;
return nextPrice < currentPrice ? next : current;
});
const price = best.discountedPrice ?? best.price ?? 0;
const oldPrice = best.discountedPrice ? best.price : undefined;
const oldPrice = best.discountedPrice ? (best.price ?? undefined) : undefined;
const stock = variants.reduce((sum, variant) => sum + (variant.stockQuantity ?? 0), 0);
return { price, oldPrice, stock };
}
export function mapProduct(item: ApiProduct): Product {
const name = item.nameFa || item.title;
return {
id: item.id,
name: item.nameFa || item.title,
name,
slug: item.slug,
href: productHref(item),
price: item.store?.minPrice ?? 0,
stock: item.store?.inStock ? item.store.variantCount || 1 : 0,
image: productImage(item.thumbnailUrl || item.image || item.thumbnail),
};
}
export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
export function mapSpecialItem(item: ApiSpecialItem): Product {
const { price, oldPrice, stock } = pickVariantPrice(item.variants ?? []);
const name = item.productNameFa || item.productTitle;
return {
id: item.productId,
name: item.productNameFa || item.productTitle,
slug,
name,
slug: "",
href: productHref(item.productId, name),
price,
oldPrice,
stock,
@@ -61,8 +65,10 @@ export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
export function mapBlog(item: ApiBlog): Post {
return {
id: item.id,
slug: item.slug,
title: item.title,
href: blogHref(item),
date: formatDate(item.publishedAt || item.createdAt || ""),
image: item.titleImageUrl || PLACEHOLDER.blog,
abstract: item.abstract?.trim() || undefined,
@@ -75,16 +81,10 @@ export async function getSpecialProducts(key: string): Promise<Product[]> {
const special = specials.find((item) => item.isActive && item.key.toLowerCase() === key.toLowerCase());
if (!special) return [];
const items = special.items
return special.items
.filter((item) => item.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder);
return Promise.all(
items.map(async (item) => {
const slug = await resolveProductSlug(item.productId, item.productTitle || item.productNameFa || "");
return mapSpecialItem(item, slug);
}),
);
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item) => mapSpecialItem(item));
} catch {
return [];
}
+26
View File
@@ -0,0 +1,26 @@
import { getCategories, type ApiCategory } from "./api";
import { parentCategories } from "./data";
import { categoryHref } from "./paths";
export type MenuCategory = {
name: string;
href: string;
children: { name: string; href: string }[];
};
function hrefForSlug(categories: ApiCategory[], slug: string) {
const match = categories.find((category) => category.slug === slug);
return match ? categoryHref(match) : `/products/${slug}`;
}
export async function getMenuCategories(): Promise<MenuCategory[]> {
const categories = await getCategories();
return parentCategories.map((parent) => ({
name: parent.name,
href: hrefForSlug(categories, parent.slug),
children: parent.children.map((child) => ({
name: child.name,
href: hrefForSlug(categories, child.slug),
})),
}));
}
+40
View File
@@ -0,0 +1,40 @@
import type { ApiBlog, ApiCategory, ApiProduct } from "./api";
import { detailPath, slugifyForUrl } from "./seo-slug";
export function productTitle(product: Pick<ApiProduct, "id" | "nameFa" | "title">) {
return product.nameFa?.trim() || product.title?.trim() || String(product.id);
}
export function categoryTitle(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
return category.nameFa?.trim() || category.name?.trim() || String(category.id);
}
/** Public product URL: /products/{id}/{nameFaSlug} — never use legacy DB slug. */
export function productHref(product: Pick<ApiProduct, "id" | "nameFa" | "title"> | string, title?: string | null) {
if (typeof product === "string") {
return detailPath("/products", product, title?.trim() || product);
}
return detailPath("/products", product.id, productTitle(product));
}
/** Public category URL: /products/category/{id}/{nameFaSlug} */
export function categoryHref(category: Pick<ApiCategory, "id" | "nameFa" | "name">) {
return detailPath("/products/category", category.id, categoryTitle(category));
}
/** Public blog URL: /blog/{id}/{titleSlug} */
export function blogHref(blog: Pick<ApiBlog, "id" | "title">) {
return detailPath("/blog", blog.id, blog.title);
}
export function productNameSlug(title: string) {
return slugifyForUrl(title);
}
export function categoryNameSlug(title: string) {
return slugifyForUrl(title);
}
export function blogTitleSlug(title: string) {
return slugifyForUrl(title);
}
+20 -26
View File
@@ -1,30 +1,24 @@
import type { StaticImageData } from "next/image";
import bradenCollum from "@/src/assets/images/samples/braden-collum-9HI8UJMSdZA-unsplash.jpg";
import fabioComparelli from "@/src/assets/images/samples/fabio-comparelli-uq2E2V4LhCY-unsplash.jpg";
import glenJackson from "@/src/assets/images/samples/glen-jackson-mzZVGFfMOkA-unsplash.jpg";
import henryXu from "@/src/assets/images/samples/henry-xu-0XHo26Jb8dk-unsplash.jpg";
import jesseGardner from "@/src/assets/images/samples/jesse-gardner-wTVr4HR4SBI-unsplash.jpg";
import jiscaLucia from "@/src/assets/images/samples/jisca-lucia-HVTVOWDKTCk-unsplash.jpg";
import kevinSchmid from "@/src/assets/images/samples/kevin-schmid-AvEY5UPoJZM-unsplash.jpg";
import paulGreen from "@/src/assets/images/samples/paul-green-gOHfFgwyDNM-unsplash.jpg";
import phillipsJacobe from "@/src/assets/images/samples/phillips-jacobe-3Oq0jNYLp28-unsplash.jpg";
import seanBenesh from "@/src/assets/images/samples/sean-benesh-VnmbcgAfL3Q-unsplash.jpg";
import sylvainMauroux from "@/src/assets/images/samples/sylvain-mauroux-OIuzDRYA3cw-unsplash.jpg";
import victorLarracuente from "@/src/assets/images/samples/victor-larracuente-JQfYGhUcDSg-unsplash.jpg";
/**
* Placeholder art that lives in `public/` (tracked in git).
* Do not import files from `src/assets/images/samples/` — that folder is
* gitignored, so production checkout + `next build` would fail with
* "Module not found" even though a local disk still has the JPGs.
*/
const placeholder = (file: string) => `/images/placeholders/${file}`;
export const samples = {
glenJackson,
henryXu,
jesseGardner,
jiscaLucia,
fabioComparelli,
sylvainMauroux,
phillipsJacobe,
paulGreen,
victorLarracuente,
seanBenesh,
kevinSchmid,
bradenCollum,
glenJackson: placeholder("slider-1.png"),
henryXu: placeholder("slider-2.png"),
jesseGardner: placeholder("slider-3.png"),
jiscaLucia: placeholder("slider-side-1.png"),
fabioComparelli: placeholder("slider-side-2.png"),
sylvainMauroux: placeholder("about.png"),
phillipsJacobe: placeholder("banner-4-1.png"),
paulGreen: placeholder("banner-4-2.png"),
victorLarracuente: placeholder("banner-4-3.png"),
seanBenesh: placeholder("banner-4-4.png"),
kevinSchmid: placeholder("banner-3-1.png"),
bradenCollum: placeholder("banner-3-2.png"),
} as const;
export const sampleList: StaticImageData[] = Object.values(samples);
export const sampleList: string[] = Object.values(samples);
+20
View File
@@ -0,0 +1,20 @@
/**
* Build a URL path segment from a title (Persian / Arabic / Latin).
* Keeps letters and digits; spaces → hyphens; strips other punctuation.
*/
export function slugifyForUrl(value: string, fallback = "item"): string {
const slug = value
.trim()
.normalize("NFC")
.replace(/\s+/g, "-")
.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug || fallback;
}
export function detailPath(base: string, id: string | number, title: string) {
const slug = slugifyForUrl(title);
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
}
+28
View File
@@ -0,0 +1,28 @@
{
"baseUrl": "https://salarstore.com",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/aboutus",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/contactus",
"priority": 0.5
},
{
"path": "/products",
"changefreq": "daily",
"priority": 0.9
}
]
}
+198
View File
@@ -0,0 +1,198 @@
import { API } from "./site";
export type StaticImage = {
id: string;
url?: string | null;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number;
};
export type StaticImageWithUrl = StaticImage & { url: string };
export type StaticImageSlot = {
id: string;
key: string;
pageKey?: string;
kind: "single" | "list";
label?: string;
aspectRatio?: string;
recommendedWidth?: number;
itemCount?: number | null;
sortOrder?: number;
images?: StaticImage[];
};
export type StaticImageCatalogSlot = {
key: string;
label: string;
kind: "single" | "list";
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
/**
* Slot keys the dashboard Refresh button imports from
* GET /meshkee/static-image-slots. Keep aspectRatio + recommendedWidth in
* sync with the live CSS when a slot's size changes.
*/
export const STATIC_IMAGE_KEYS = {
slider: "slider",
twoBannerBelowSlider: "two-banner-below-slider",
categories: "categories",
fourBanner: "four-banner",
twoBannerRow: "two-banner-row",
brands: "brands",
aboutUsImage: "about-us-image",
} as const;
/** Previous catalog keys still returned by the tenant API until dashboard Refresh. */
const SLOT_KEY_ALIASES: Record<string, string[]> = {
"two-banner-below-slider": ["banners-below-slider"],
"four-banner": ["four-banner-section"],
"two-banner-row": ["three-banner-row"],
};
export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
{
key: STATIC_IMAGE_KEYS.slider,
label: "Homepage slider",
kind: "list",
pageKey: "home",
aspectRatio: "21:8",
itemCount: null,
recommendedWidth: 1200,
},
{
key: STATIC_IMAGE_KEYS.twoBannerBelowSlider,
label: "Two banners below slider",
kind: "list",
pageKey: "home",
aspectRatio: "13:5",
itemCount: 2,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.categories,
label: "Homepage categories",
kind: "list",
pageKey: "home",
aspectRatio: "1:1",
itemCount: null,
recommendedWidth: 160,
},
{
key: STATIC_IMAGE_KEYS.fourBanner,
label: "Four banner section",
kind: "list",
pageKey: "home",
aspectRatio: "21:9",
itemCount: 4,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.twoBannerRow,
label: "Two banner row",
kind: "list",
pageKey: "home",
aspectRatio: "12:5",
itemCount: 2,
recommendedWidth: 600,
},
{
key: STATIC_IMAGE_KEYS.brands,
label: "Brand logos",
kind: "list",
pageKey: "home",
aspectRatio: "2:1",
itemCount: null,
recommendedWidth: 196,
},
{
key: STATIC_IMAGE_KEYS.aboutUsImage,
label: "About us images",
kind: "list",
pageKey: "home",
aspectRatio: "8:5",
itemCount: 2,
recommendedWidth: 720,
},
];
export function slotAspectStyle(
ratio?: string | null,
fallback?: string,
): { aspectRatio: string } | undefined {
const value = ratio?.trim() || fallback;
if (!value) return undefined;
return { aspectRatio: value.replace(":", " / ") };
}
export function slotAlt(image: StaticImage, fallback = ""): string {
return image.titleFa?.trim() || image.titleEn?.trim() || fallback;
}
export function slotByKey(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
const aliases = SLOT_KEY_ALIASES[key] ?? [];
return slots.find((slot) => slot.key === key) ?? slots.find((slot) => aliases.includes(slot.key));
}
export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImageWithUrl[] {
const slot = slotByKey(slots, key);
const catalog = STATIC_IMAGE_SLOT_CATALOG.find((item) => item.key === key);
const raw = slot?.images ?? [];
const sorted = [...raw].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const result: StaticImageWithUrl[] = [];
for (const image of sorted) {
const url = typeof image.url === "string" ? image.url.trim() : "";
if (!url) continue;
result.push({ ...image, url });
}
const kind = catalog?.kind ?? slot?.kind ?? "list";
const limit = catalog?.itemCount ?? (kind === "single" ? 1 : null);
if (kind === "single" || limit === 1) return result.slice(0, 1);
if (limit && limit > 0) return result.slice(0, limit);
return result;
}
export function firstImageForSlot(
slots: StaticImageSlot[],
key: string,
): StaticImageWithUrl | undefined {
return imagesForSlot(slots, key)[0];
}
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images`);
if (pageKey) url.searchParams.set("pageKey", pageKey);
const response = await fetch(url, { next: { revalidate: 120 } });
if (!response.ok) return [];
const data = (await response.json()) as { items?: StaticImageSlot[] };
return data.items ?? [];
} catch {
return [];
}
}
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
const keys = [key, ...(SLOT_KEY_ALIASES[key] ?? [])];
for (const candidate of keys) {
try {
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${candidate}`);
const response = await fetch(url, { next: { revalidate: 120 } });
if (!response.ok) continue;
return (await response.json()) as StaticImageSlot;
} catch {
// try next alias
}
}
return null;
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import type { ApiProduct, ApiStoreItem, ApiVariant } from "./api";
function sellingPrice(variant: ApiVariant): number {
const value = variant.discountedPrice ?? variant.price;
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0;
}
function inStock(variant: ApiVariant): boolean {
return variant.stockQuantity === null || (typeof variant.stockQuantity === "number" && variant.stockQuantity > 0);
}
export function pickTorobVariant(variants: ApiVariant[]): ApiVariant | undefined {
if (!variants.length) return undefined;
const available = variants.filter(inStock);
const pool = available.length ? available : variants;
return pool.reduce((cheapest, variant) =>
sellingPrice(variant) < sellingPrice(cheapest) ? variant : cheapest,
);
}
function toHttpsUrl(url?: string | null): string | undefined {
if (!url) return undefined;
const trimmed = url.trim();
if (trimmed.startsWith("https://")) return trimmed;
if (trimmed.startsWith("http://")) return `https://${trimmed.slice("http://".length)}`;
if (trimmed.startsWith("//")) return `https:${trimmed}`;
return undefined;
}
export function torobMetadata(product: ApiProduct, storeItem: ApiStoreItem | null): Pick<Metadata, "other" | "openGraph"> {
const variant = storeItem ? pickTorobVariant(storeItem.variants ?? []) : undefined;
const price = storeItem && variant ? sellingPrice(variant) : 0;
const available = Boolean(storeItem && variant && inStock(variant));
const name = variant?.productNameFa || variant?.productTitle || product.nameFa || product.title;
const image =
toHttpsUrl(variant?.productImage) ||
toHttpsUrl(product.thumbnailUrl) ||
toHttpsUrl(product.image) ||
toHttpsUrl(product.thumbnail) ||
toHttpsUrl(product.images?.[0]?.url);
const other: Record<string, string> = {
product_id: product.id,
product_name: name,
product_price: String(price),
availability: available ? "instock" : "outofstock",
};
if (variant && variant.discountedPrice != null) {
other.product_old_price = String(typeof variant.price === "number" ? Math.trunc(variant.price) : 0);
}
return {
other,
...(image ? { openGraph: { images: [{ url: image }] } } : {}),
};
}
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
localPatterns: [
{
+3 -1
View File
@@ -3,10 +3,12 @@
"version": "0.1.0",
"private": true,
"scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
},
"dependencies": {
"next": "16.3.1",
+93
View File
@@ -0,0 +1,93 @@
/**
* Scans app page routes and writes lib/sitemap-config.generated.json
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const appDir = path.join(root, "app");
const outFile = path.join(root, "lib", "sitemap-config.generated.json");
const EXCLUDE_SEGMENTS = new Set([
"api",
"meshkee",
"admin",
"auth",
"login",
"checkout",
"cart",
"account",
]);
const PRIORITY = {
"/": { changefreq: "daily", priority: 1.0 },
"/products": { changefreq: "daily", priority: 0.9 },
"/blog": { changefreq: "weekly", priority: 0.8 },
"/aboutus": { priority: 0.6 },
"/contactus": { priority: 0.5 },
};
function websiteDomain() {
return (
process.env.NEXT_PUBLIC_DOMAIN ||
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
"salarstore.com"
);
}
function walkPages(dir, segments = []) {
/** @type {string[]} */
const pages = [];
if (!fs.existsSync(dir)) return pages;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
if (/^\(.+\)$/.test(entry.name)) {
pages.push(...walkPages(full, segments));
}
continue;
}
pages.push(...walkPages(full, [...segments, entry.name]));
continue;
}
if (entry.name === "page.tsx" || entry.name === "page.ts" || entry.name === "page.jsx" || entry.name === "page.js") {
const routePath = segments.length ? `/${segments.join("/")}` : "/";
pages.push(routePath);
}
}
return pages;
}
function main() {
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b);
});
const staticPages = paths.map((routePath) => {
const meta = PRIORITY[routePath] ?? { priority: 0.5 };
return { path: routePath, ...meta };
});
const config = {
baseUrl: `https://${websiteDomain()}`,
staticPages,
};
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`);
}
main();