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:
co-authored by
Cursor
parent
f42c30a4b7
commit
c84147e765
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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/AboutUs.tsx,app/page.tsx,app/aboutus/page.tsx
|
||||
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
|
||||
---
|
||||
|
||||
@@ -14,4 +14,4 @@ When you change a slot’s layout, CSS aspect ratio, or displayed width:
|
||||
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`, `banners-below-slider`, `categories`, `four-banner-section`, `three-banner-row`, `about-us-image`.
|
||||
Keys: `slider` (duplicatable), `two-banner-below-slider` (2), `categories` (duplicatable), `four-banner` (4), `two-banner-row` (2), `brands` (duplicatable), `about-us-image` (2).
|
||||
|
||||
+9
-6
@@ -25,12 +25,15 @@ Source of truth: `lib/static-images.ts` (`STATIC_IMAGE_SLOT_CATALOG`).
|
||||
|
||||
| key | kind | itemCount | aspectRatio | where |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `slider` | list (duplicatable) | unbounded | `21:8` | Homepage hero |
|
||||
| `banners-below-slider` | list | 2 | `13:5` | Two tiles under the slider |
|
||||
| `categories` | list (duplicatable) | unbounded | `1:1` | Category row |
|
||||
| `four-banner-section` | list | 4 | `21:9` | 2×2 banner grid |
|
||||
| `three-banner-row` | list | 3 | `12:5` | Three-up row |
|
||||
| `about-us-image` | single | 1 | `8:5` | Home about block + `/aboutus` hero |
|
||||
| `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:
|
||||
|
||||
|
||||
+22
-11
@@ -5,27 +5,30 @@ import { SlotImage } from "@/components/SlotImage";
|
||||
import { SITE } from "@/lib/site";
|
||||
import {
|
||||
STATIC_IMAGE_KEYS,
|
||||
firstImageForSlot,
|
||||
getStaticImageSlot,
|
||||
imagesForSlot,
|
||||
slotAlt,
|
||||
} from "@/lib/static-images";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `درباره ما | ${SITE.nameFa}`,
|
||||
description: SITE.about,
|
||||
alternates: { canonical: "/aboutus" },
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const slot = await getStaticImageSlot(STATIC_IMAGE_KEYS.aboutUsImage);
|
||||
const image = slot ? firstImageForSlot([slot], STATIC_IMAGE_KEYS.aboutUsImage) : undefined;
|
||||
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 ? (
|
||||
<SlotImage image={image} fill alt={slotAlt(image, SITE.nameFa)} />
|
||||
{hero ? (
|
||||
<SlotImage image={hero} fill alt={slotAlt(hero, SITE.nameFa)} />
|
||||
) : (
|
||||
<Image
|
||||
src="/images/pages/about-bg.webp"
|
||||
@@ -56,13 +59,21 @@ export default async 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 (
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
+28
-16
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
@@ -8,44 +8,56 @@ import { SpecialSale } from "@/components/SpecialSale";
|
||||
import { TopSelling } from "@/components/TopSelling";
|
||||
import {
|
||||
STATIC_IMAGE_KEYS,
|
||||
firstImageForSlot,
|
||||
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.bannersBelowSlider);
|
||||
const fourSlot = slotByKey(slots, STATIC_IMAGE_KEYS.fourBannerSection);
|
||||
const threeSlot = slotByKey(slots, STATIC_IMAGE_KEYS.threeBannerRow);
|
||||
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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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.bannersBelowSlider)}
|
||||
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider)}
|
||||
sideAspectRatio={sideSlot?.aspectRatio}
|
||||
/>
|
||||
<SpecialSale />
|
||||
<Categories images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)} />
|
||||
<Categories
|
||||
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)}
|
||||
aspectRatio={categorySlot?.aspectRatio}
|
||||
/>
|
||||
<BannerGrid4
|
||||
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBannerSection)}
|
||||
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBanner)}
|
||||
aspectRatio={fourSlot?.aspectRatio}
|
||||
/>
|
||||
<TopSelling />
|
||||
<BannerRow3
|
||||
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
||||
aspectRatio={threeSlot?.aspectRatio}
|
||||
/>
|
||||
<Brands />
|
||||
<AboutUs
|
||||
image={firstImageForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)}
|
||||
aspectRatio={aboutSlot?.aspectRatio}
|
||||
<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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
+47
-9
@@ -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 ?? "";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+53
-26
@@ -44,13 +44,21 @@ export type StaticImageCatalogSlot = {
|
||||
*/
|
||||
export const STATIC_IMAGE_KEYS = {
|
||||
slider: "slider",
|
||||
bannersBelowSlider: "banners-below-slider",
|
||||
twoBannerBelowSlider: "two-banner-below-slider",
|
||||
categories: "categories",
|
||||
fourBannerSection: "four-banner-section",
|
||||
threeBannerRow: "three-banner-row",
|
||||
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,
|
||||
@@ -62,8 +70,8 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.bannersBelowSlider,
|
||||
label: "Banners below slider",
|
||||
key: STATIC_IMAGE_KEYS.twoBannerBelowSlider,
|
||||
label: "Two banners below slider",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "13:5",
|
||||
@@ -80,7 +88,7 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
||||
recommendedWidth: 160,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.fourBannerSection,
|
||||
key: STATIC_IMAGE_KEYS.fourBanner,
|
||||
label: "Four banner section",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
@@ -89,21 +97,30 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
||||
recommendedWidth: 600,
|
||||
},
|
||||
{
|
||||
key: STATIC_IMAGE_KEYS.threeBannerRow,
|
||||
label: "Three banner row",
|
||||
key: STATIC_IMAGE_KEYS.twoBannerRow,
|
||||
label: "Two banner row",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "12:5",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 480,
|
||||
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 image",
|
||||
kind: "single",
|
||||
label: "About us images",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "8:5",
|
||||
itemCount: 1,
|
||||
itemCount: 2,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
];
|
||||
@@ -121,8 +138,14 @@ 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 = slots.find((item) => item.key === key);
|
||||
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[] = [];
|
||||
@@ -131,7 +154,11 @@ export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImag
|
||||
if (!url) continue;
|
||||
result.push({ ...image, url });
|
||||
}
|
||||
if (slot?.kind === "single") return result.slice(0, 1);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -142,10 +169,6 @@ export function firstImageForSlot(
|
||||
return imagesForSlot(slots, key)[0];
|
||||
}
|
||||
|
||||
export function slotByKey(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
|
||||
return slots.find((slot) => slot.key === key);
|
||||
}
|
||||
|
||||
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
|
||||
try {
|
||||
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images`);
|
||||
@@ -160,12 +183,16 @@ export async function getStaticImageSlots(pageKey?: string): Promise<StaticImage
|
||||
}
|
||||
|
||||
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
|
||||
try {
|
||||
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${key}`);
|
||||
const response = await fetch(url, { next: { revalidate: 120 } });
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as StaticImageSlot;
|
||||
} catch {
|
||||
return 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;
|
||||
}
|
||||
|
||||
@@ -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 }] } } : {}),
|
||||
};
|
||||
}
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user