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.
|
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
|
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.
|
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.
|
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 |
|
| key | kind | itemCount | aspectRatio | where |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `slider` | list (duplicatable) | unbounded | `21:8` | Homepage hero |
|
| `slider` | list (duplicatable) | unbounded | `21:8` | Homepage hero. Overlay `titleFa` / `titleEn` / `subtext` when set. |
|
||||||
| `banners-below-slider` | list | 2 | `13:5` | Two tiles under the slider |
|
| `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 |
|
| `categories` | list (duplicatable) | unbounded | `1:1` | Category row. `titleFa` + `titleEn` beside the icon. |
|
||||||
| `four-banner-section` | list | 4 | `21:9` | 2×2 banner grid |
|
| `four-banner` | list | 4 | `21:9` | 2×2 banner grid. Overlay `titleFa` / `titleEn` / `subtext` when set. |
|
||||||
| `three-banner-row` | list | 3 | `12:5` | Three-up row |
|
| `two-banner-row` | list | 2 | `12:5` | Two-up row after top-selling. |
|
||||||
| `about-us-image` | single | 1 | `8:5` | Home about block + `/aboutus` hero |
|
| `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:
|
Rules:
|
||||||
|
|
||||||
|
|||||||
+15
-4
@@ -5,27 +5,30 @@ import { SlotImage } from "@/components/SlotImage";
|
|||||||
import { SITE } from "@/lib/site";
|
import { SITE } from "@/lib/site";
|
||||||
import {
|
import {
|
||||||
STATIC_IMAGE_KEYS,
|
STATIC_IMAGE_KEYS,
|
||||||
firstImageForSlot,
|
|
||||||
getStaticImageSlot,
|
getStaticImageSlot,
|
||||||
|
imagesForSlot,
|
||||||
slotAlt,
|
slotAlt,
|
||||||
} from "@/lib/static-images";
|
} from "@/lib/static-images";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `درباره ما | ${SITE.nameFa}`,
|
title: `درباره ما | ${SITE.nameFa}`,
|
||||||
description: SITE.about,
|
description: SITE.about,
|
||||||
|
alternates: { canonical: "/aboutus" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AboutPage() {
|
export default async function AboutPage() {
|
||||||
const slot = await getStaticImageSlot(STATIC_IMAGE_KEYS.aboutUsImage);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHero title="درباره ما" english="About Us" />
|
<PageHero title="درباره ما" english="About Us" />
|
||||||
<section className="container-site py-10 md:py-14">
|
<section className="container-site py-10 md:py-14">
|
||||||
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
|
<div className="relative overflow-hidden rounded-2xl min-h-[280px] md:min-h-[360px]">
|
||||||
{image ? (
|
{hero ? (
|
||||||
<SlotImage image={image} fill alt={slotAlt(image, SITE.nameFa)} />
|
<SlotImage image={hero} fill alt={slotAlt(hero, SITE.nameFa)} />
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<Image
|
||||||
src="/images/pages/about-bg.webp"
|
src="/images/pages/about-bg.webp"
|
||||||
@@ -56,6 +59,13 @@ export default async function AboutPage() {
|
|||||||
<p className="text-zinc-600 leading-8 text-justify">{SITE.about}</p>
|
<p className="text-zinc-600 leading-8 text-justify">{SITE.about}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mx-auto w-full max-w-xs">
|
<div className="relative mx-auto w-full max-w-xs">
|
||||||
|
{side ? (
|
||||||
|
<SlotImage
|
||||||
|
image={side}
|
||||||
|
alt={slotAlt(side, SITE.nameFa)}
|
||||||
|
imgClassName="h-auto w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Image
|
<Image
|
||||||
src="/images/pages/about-side.png"
|
src="/images/pages/about-side.png"
|
||||||
alt={SITE.nameFa}
|
alt={SITE.nameFa}
|
||||||
@@ -63,6 +73,7 @@ export default async function AboutPage() {
|
|||||||
height={603}
|
height={603}
|
||||||
className="h-auto w-full object-contain"
|
className="h-auto w-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, permanentRedirect } from "next/navigation";
|
||||||
import { Media } from "@/components/Media";
|
import { Media } from "@/components/Media";
|
||||||
import { PageHero } from "@/components/PageHero";
|
import { PageHero } from "@/components/PageHero";
|
||||||
import { RichContent } from "@/components/RichContent";
|
import { RichContent } from "@/components/RichContent";
|
||||||
import { getBlog } from "@/lib/api";
|
import { getBlogById } from "@/lib/api";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { PLACEHOLDER } from "@/lib/data";
|
import { PLACEHOLDER } from "@/lib/data";
|
||||||
|
import { blogHref, blogTitleSlug } from "@/lib/paths";
|
||||||
import { SITE } from "@/lib/site";
|
import { SITE } from "@/lib/site";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ id: string; slug: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const blog = await getBlog(slug);
|
const blog = await getBlogById(id);
|
||||||
if (!blog) return { title: SITE.nameFa };
|
if (!blog) return { title: SITE.nameFa };
|
||||||
return {
|
return {
|
||||||
title: `${blog.title} | ${SITE.nameFa}`,
|
title: `${blog.title} | ${SITE.nameFa}`,
|
||||||
description: blog.abstract || SITE.aboutShort,
|
description: blog.abstract?.trim() || SITE.aboutShort,
|
||||||
|
alternates: { canonical: blogHref(blog) },
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { title: SITE.nameFa };
|
return { title: SITE.nameFa };
|
||||||
@@ -27,10 +29,14 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function BlogDetailPage({ params }: Props) {
|
export default async function BlogDetailPage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { id, slug } = await params;
|
||||||
const blog = await getBlog(slug);
|
const blog = await getBlogById(id);
|
||||||
if (!blog) notFound();
|
if (!blog) notFound();
|
||||||
|
|
||||||
|
if (decodeURIComponent(slug) !== blogTitleSlug(blog.title)) {
|
||||||
|
permanentRedirect(blogHref(blog));
|
||||||
|
}
|
||||||
|
|
||||||
const author = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ");
|
const author = [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ");
|
||||||
|
|
||||||
return (
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: `وبلاگ | ${SITE.nameFa}`,
|
title: `وبلاگ | ${SITE.nameFa}`,
|
||||||
description: "آخرین مقالات سالار استور",
|
description: "مقالات کوهنوردی، کمپینگ و راهنمای خرید لوازم ورزشی از سالار استور",
|
||||||
|
alternates: { canonical: "/blog" },
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
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">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link
|
<Link
|
||||||
key={post.slug}
|
key={post.id || post.slug}
|
||||||
href={`/blog/${post.slug}`}
|
href={post.href || `/blog/${post.slug}`}
|
||||||
className="overflow-hidden rounded-2xl border border-black/5 bg-white hover:border-brand/40"
|
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">
|
<div className="p-4">
|
||||||
<time className="text-xs text-zinc-400">{post.date}</time>
|
<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>
|
<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 = {
|
export const metadata: Metadata = {
|
||||||
title: `تماس با ما | ${SITE.nameFa}`,
|
title: `تماس با ما | ${SITE.nameFa}`,
|
||||||
description: SITE.address,
|
description: `تماس با فروشگاه لوازم ورزشی سالار استور — ${SITE.address}`,
|
||||||
|
alternates: { canonical: "/contactus" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const contacts = [
|
const contacts = [
|
||||||
|
|||||||
+4
-2
@@ -5,6 +5,7 @@ import { Footer } from "@/components/Footer";
|
|||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
import { TopHeader } from "@/components/TopHeader";
|
import { TopHeader } from "@/components/TopHeader";
|
||||||
import { getTenant } from "@/lib/api";
|
import { getTenant } from "@/lib/api";
|
||||||
|
import { getMenuCategories } from "@/lib/nav";
|
||||||
import { SITE } from "@/lib/site";
|
import { SITE } from "@/lib/site";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -28,19 +29,20 @@ const carter = Carter_One({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(`https://${SITE.domain}`),
|
||||||
title: `${SITE.nameFa} | ${SITE.tagline}`,
|
title: `${SITE.nameFa} | ${SITE.tagline}`,
|
||||||
description: SITE.aboutShort,
|
description: SITE.aboutShort,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
const tenant = await getTenant();
|
const [tenant, menuCategories] = await Promise.all([getTenant(), getMenuCategories()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl" className={`${vazir.variable} ${oswald.variable} ${carter.variable} h-full antialiased`}>
|
<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">
|
<body className="min-h-full flex flex-col bg-white text-ink">
|
||||||
<AppProviders businessId={tenant ? String(tenant.id) : ""}>
|
<AppProviders businessId={tenant ? String(tenant.id) : ""}>
|
||||||
<TopHeader />
|
<TopHeader />
|
||||||
<Header />
|
<Header menuCategories={menuCategories} />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</AppProviders>
|
</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 Link from "next/link";
|
||||||
|
import { SITE } from "@/lib/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `صفحه پیدا نشد | ${SITE.nameFa}`,
|
||||||
|
description: "صفحه مورد نظر در سالار استور پیدا نشد.",
|
||||||
|
};
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+28
-16
@@ -1,5 +1,5 @@
|
|||||||
import { AboutUs } from "@/components/AboutUs";
|
import { AboutUs } from "@/components/AboutUs";
|
||||||
import { BannerGrid4, BannerRow3 } from "@/components/Banners";
|
import { BannerGrid4, BannerRow } from "@/components/Banners";
|
||||||
import { BlogCarousel } from "@/components/BlogCarousel";
|
import { BlogCarousel } from "@/components/BlogCarousel";
|
||||||
import { Brands } from "@/components/Brands";
|
import { Brands } from "@/components/Brands";
|
||||||
import { Categories } from "@/components/Categories";
|
import { Categories } from "@/components/Categories";
|
||||||
@@ -8,44 +8,56 @@ import { SpecialSale } from "@/components/SpecialSale";
|
|||||||
import { TopSelling } from "@/components/TopSelling";
|
import { TopSelling } from "@/components/TopSelling";
|
||||||
import {
|
import {
|
||||||
STATIC_IMAGE_KEYS,
|
STATIC_IMAGE_KEYS,
|
||||||
firstImageForSlot,
|
|
||||||
getStaticImageSlots,
|
getStaticImageSlots,
|
||||||
imagesForSlot,
|
imagesForSlot,
|
||||||
slotByKey,
|
slotByKey,
|
||||||
} from "@/lib/static-images";
|
} 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() {
|
export default async function Home() {
|
||||||
const slots = await getStaticImageSlots("home");
|
const slots = await getStaticImageSlots("home");
|
||||||
const sliderSlot = slotByKey(slots, STATIC_IMAGE_KEYS.slider);
|
const sliderSlot = slotByKey(slots, STATIC_IMAGE_KEYS.slider);
|
||||||
const sideSlot = slotByKey(slots, STATIC_IMAGE_KEYS.bannersBelowSlider);
|
const sideSlot = slotByKey(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider);
|
||||||
const fourSlot = slotByKey(slots, STATIC_IMAGE_KEYS.fourBannerSection);
|
const categorySlot = slotByKey(slots, STATIC_IMAGE_KEYS.categories);
|
||||||
const threeSlot = slotByKey(slots, STATIC_IMAGE_KEYS.threeBannerRow);
|
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);
|
const aboutSlot = slotByKey(slots, STATIC_IMAGE_KEYS.aboutUsImage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<h1 className="sr-only">
|
||||||
|
{SITE.nameFa}؛ {SITE.tagline}
|
||||||
|
</h1>
|
||||||
<HeroSlider
|
<HeroSlider
|
||||||
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)}
|
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.slider)}
|
||||||
aspectRatio={sliderSlot?.aspectRatio}
|
aspectRatio={sliderSlot?.aspectRatio}
|
||||||
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.bannersBelowSlider)}
|
sideImages={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerBelowSlider)}
|
||||||
sideAspectRatio={sideSlot?.aspectRatio}
|
sideAspectRatio={sideSlot?.aspectRatio}
|
||||||
/>
|
/>
|
||||||
<SpecialSale />
|
<SpecialSale />
|
||||||
<Categories images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)} />
|
<Categories
|
||||||
|
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.categories)}
|
||||||
|
aspectRatio={categorySlot?.aspectRatio}
|
||||||
|
/>
|
||||||
<BannerGrid4
|
<BannerGrid4
|
||||||
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBannerSection)}
|
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.fourBanner)}
|
||||||
aspectRatio={fourSlot?.aspectRatio}
|
aspectRatio={fourSlot?.aspectRatio}
|
||||||
/>
|
/>
|
||||||
<TopSelling />
|
<TopSelling />
|
||||||
<BannerRow3
|
<BannerRow
|
||||||
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.threeBannerRow)}
|
images={imagesForSlot(slots, STATIC_IMAGE_KEYS.twoBannerRow)}
|
||||||
aspectRatio={threeSlot?.aspectRatio}
|
aspectRatio={twoRowSlot?.aspectRatio}
|
||||||
/>
|
|
||||||
<Brands />
|
|
||||||
<AboutUs
|
|
||||||
image={firstImageForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)}
|
|
||||||
aspectRatio={aboutSlot?.aspectRatio}
|
|
||||||
/>
|
/>
|
||||||
|
<Brands images={imagesForSlot(slots, STATIC_IMAGE_KEYS.brands)} aspectRatio={brandsSlot?.aspectRatio} />
|
||||||
|
<AboutUs images={imagesForSlot(slots, STATIC_IMAGE_KEYS.aboutUsImage)} aspectRatio={aboutSlot?.aspectRatio} />
|
||||||
<BlogCarousel />
|
<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 = {
|
export const metadata: Metadata = {
|
||||||
title: `محصولات | ${SITE.nameFa}`,
|
title: `محصولات | ${SITE.nameFa}`,
|
||||||
description: SITE.tagline,
|
description: "خرید لوازم ورزشی و کوهنوردی از فروشگاه سالار استور",
|
||||||
|
alternates: { canonical: "/products" },
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import type { StaticImageWithUrl } from "@/lib/static-images";
|
|||||||
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
||||||
|
|
||||||
export function AboutUs({
|
export function AboutUs({
|
||||||
image,
|
images = [],
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
}: {
|
}: {
|
||||||
image?: StaticImageWithUrl;
|
images?: StaticImageWithUrl[];
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const image = images[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="about" className="container-site mt-14">
|
<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">
|
<div className="grid md:grid-cols-2 gap-8 items-center rounded-2xl bg-zinc-50 p-5 md:p-8">
|
||||||
|
|||||||
+12
-8
@@ -30,7 +30,7 @@ export function BannerGrid4({ images = [], aspectRatio }: BannerSlotProps) {
|
|||||||
className="relative overflow-hidden rounded-2xl"
|
className="relative overflow-hidden rounded-2xl"
|
||||||
style={slotAspectStyle(aspectRatio, "21:9")}
|
style={slotAspectStyle(aspectRatio, "21:9")}
|
||||||
>
|
>
|
||||||
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
|
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
|
||||||
{hasCopy ? (
|
{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-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 (
|
return (
|
||||||
<section className="container-site mt-12">
|
<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.length > 0
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4">
|
||||||
? images.map((image) => (
|
{images.map((image) => (
|
||||||
<div key={image.id} className="overflow-hidden rounded-2xl">
|
<div key={image.id} className="overflow-hidden rounded-2xl">
|
||||||
<SlotImage
|
<SlotImage
|
||||||
image={image}
|
image={image}
|
||||||
alt={slotAlt(image)}
|
alt={slotAlt(image, "بنر سالار استور")}
|
||||||
imgClassName="h-auto w-full object-cover"
|
imgClassName="h-auto w-full object-cover"
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
fallbackAspect="12:5"
|
fallbackAspect="12:5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
: banners3.map((banner) => (
|
</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">
|
<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" />
|
<Media src={banner.image} alt={banner.alt} className="aspect-[2.4/1]" sizes="33vw" />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export async function BlogCarousel() {
|
|||||||
<Carousel ariaLabel="مقالات">
|
<Carousel ariaLabel="مقالات">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link
|
<Link
|
||||||
key={post.slug}
|
key={post.id || post.slug}
|
||||||
href={`/blog/${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"
|
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">
|
<div className="p-4">
|
||||||
<time className="text-xs text-zinc-400">{post.date}</time>
|
<time className="text-xs text-zinc-400">{post.date}</time>
|
||||||
<h3 className="mt-2 text-sm leading-7 line-clamp-2">{post.title}</h3>
|
<h3 className="mt-2 text-sm leading-7 line-clamp-2">{post.title}</h3>
|
||||||
|
|||||||
+39
-3
@@ -1,18 +1,54 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { brands } from "@/lib/data";
|
import { brands } from "@/lib/data";
|
||||||
|
import type { StaticImageWithUrl } from "@/lib/static-images";
|
||||||
|
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
||||||
import { Carousel } from "./Carousel";
|
import { Carousel } from "./Carousel";
|
||||||
|
|
||||||
export function Brands() {
|
type Props = {
|
||||||
|
images?: StaticImageWithUrl[];
|
||||||
|
aspectRatio?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Brands({ images = [], aspectRatio }: Props) {
|
||||||
return (
|
return (
|
||||||
<section className="container-site mt-12">
|
<section className="container-site mt-12">
|
||||||
<h2 className="section-title mb-6">برندها</h2>
|
<h2 className="section-title mb-6">برندها</h2>
|
||||||
<Carousel ariaLabel="برندها">
|
<Carousel ariaLabel="برندها">
|
||||||
{brands.map((brand) => (
|
{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
|
<div
|
||||||
key={brand.name}
|
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"
|
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" />
|
<Image
|
||||||
|
src={brand.image}
|
||||||
|
alt={brand.name}
|
||||||
|
width={180}
|
||||||
|
height={80}
|
||||||
|
className="max-h-[72px] w-auto object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Carousel>
|
</Carousel>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function CartPopup({ open, onClose }: Props) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="size-20 shrink-0 overflow-hidden rounded-xl border border-black/5"
|
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>
|
</Link>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+29
-12
@@ -2,11 +2,21 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { SlotImage } from "@/components/SlotImage";
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { categories } from "@/lib/data";
|
import { categories } from "@/lib/data";
|
||||||
|
import { getCategories } from "@/lib/api";
|
||||||
|
import { categoryHref } from "@/lib/paths";
|
||||||
import type { StaticImageWithUrl } from "@/lib/static-images";
|
import type { StaticImageWithUrl } from "@/lib/static-images";
|
||||||
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
||||||
import { DrawFrame } from "./DrawFrame";
|
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 (
|
return (
|
||||||
<section className="container-site mt-12">
|
<section className="container-site mt-12">
|
||||||
<div className="flex items-end justify-between mb-6">
|
<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">
|
<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.length > 0
|
||||||
? images.map((image) => {
|
? images.map((image) => {
|
||||||
const label = slotAlt(image);
|
const nameFa = image.titleFa?.trim();
|
||||||
|
const nameEn = image.titleEn?.trim();
|
||||||
const card = (
|
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="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
|
<SlotImage
|
||||||
image={{ ...image, linkUrl: null }}
|
image={{ ...image, linkUrl: null }}
|
||||||
alt={label}
|
alt={slotAlt(image, nameFa || nameEn || "دستهبندی")}
|
||||||
imgClassName="h-full w-full object-contain"
|
imgClassName="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{label || image.subtext ? (
|
{nameFa || nameEn ? (
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
{label ? (
|
{nameFa ? (
|
||||||
<span className="block text-lg font-medium text-ink leading-none">{label}</span>
|
<span className="block text-lg font-medium text-ink leading-none">{nameFa}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{image.subtext ? (
|
{nameEn ? (
|
||||||
<span
|
<span
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
|
className="mt-1 block font-oswald text-[10px] font-normal tracking-[0.32em] text-zinc-400 uppercase"
|
||||||
>
|
>
|
||||||
{image.subtext}
|
{nameEn}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
@@ -54,10 +68,12 @@ export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
|
|||||||
<div key={image.id}>{card}</div>
|
<div key={image.id}>{card}</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
: categories.map((cat) => (
|
: categories.map((cat) => {
|
||||||
|
const api = bySlug.get(cat.slug);
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={cat.slug}
|
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"
|
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
|
<Image
|
||||||
@@ -78,7 +94,8 @@ export function Categories({ images = [] }: { images?: StaticImageWithUrl[] }) {
|
|||||||
</span>
|
</span>
|
||||||
<DrawFrame />
|
<DrawFrame />
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function Footer() {
|
|||||||
<div className="container-site grid grid-cols-2 md:grid-cols-4 gap-6 py-8">
|
<div className="container-site grid grid-cols-2 md:grid-cols-4 gap-6 py-8">
|
||||||
{features.map((item) => (
|
{features.map((item) => (
|
||||||
<div key={item.title} className="flex items-center gap-3">
|
<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>
|
<h3 className="text-sm font-medium text-ink">{item.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+12
-12
@@ -4,7 +4,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { customerLoginUrl } from "@/lib/customer";
|
import { customerLoginUrl } from "@/lib/customer";
|
||||||
import { parentCategories } from "@/lib/data";
|
import type { MenuCategory } from "@/lib/nav";
|
||||||
import { SITE } from "@/lib/site";
|
import { SITE } from "@/lib/site";
|
||||||
import { useCart } from "@/lib/cart";
|
import { useCart } from "@/lib/cart";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
@@ -20,7 +20,7 @@ const nav = [
|
|||||||
{ name: "تماس با ما", href: "/contactus" },
|
{ name: "تماس با ما", href: "/contactus" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Header() {
|
export function Header({ menuCategories = [] }: { menuCategories?: MenuCategory[] }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mega, setMega] = useState(false);
|
const [mega, setMega] = useState(false);
|
||||||
const [cartOpen, setCartOpen] = useState(false);
|
const [cartOpen, setCartOpen] = useState(false);
|
||||||
@@ -60,16 +60,16 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
{mega && (
|
{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">
|
<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) => (
|
{menuCategories.map((cat) => (
|
||||||
<div key={cat.slug}>
|
<div key={cat.href}>
|
||||||
<Link href={`/products/${cat.slug}`} className="block mb-3 text-sm font-bold text-brand">
|
<Link href={cat.href} className="block mb-3 text-sm font-bold text-brand">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</Link>
|
</Link>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{cat.children.slice(0, 8).map((child) => (
|
{cat.children.slice(0, 8).map((child) => (
|
||||||
<li key={child.slug}>
|
<li key={child.href}>
|
||||||
<Link
|
<Link
|
||||||
href={`/products/${child.slug}`}
|
href={child.href}
|
||||||
className="block text-[13px] text-zinc-600 hover:text-ink"
|
className="block text-[13px] text-zinc-600 hover:text-ink"
|
||||||
>
|
>
|
||||||
{child.name}
|
{child.name}
|
||||||
@@ -131,15 +131,15 @@ export function Header() {
|
|||||||
<SearchBox className="mb-5 rounded-full bg-zinc-100 px-3" onNavigate={() => setOpen(false)} />
|
<SearchBox className="mb-5 rounded-full bg-zinc-100 px-3" onNavigate={() => setOpen(false)} />
|
||||||
<p className="text-xs text-zinc-400 mb-2">محصولات</p>
|
<p className="text-xs text-zinc-400 mb-2">محصولات</p>
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
{parentCategories.map((cat) => (
|
{menuCategories.map((cat) => (
|
||||||
<div key={cat.slug}>
|
<div key={cat.href}>
|
||||||
<Link href={`/products/${cat.slug}`} className="font-bold text-brand" onClick={() => setOpen(false)}>
|
<Link href={cat.href} className="font-bold text-brand" onClick={() => setOpen(false)}>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</Link>
|
</Link>
|
||||||
<ul className="mt-2 grid grid-cols-2 gap-1">
|
<ul className="mt-2 grid grid-cols-2 gap-1">
|
||||||
{cat.children.slice(0, 6).map((child) => (
|
{cat.children.slice(0, 6).map((child) => (
|
||||||
<li key={child.slug}>
|
<li key={child.href}>
|
||||||
<Link href={`/products/${child.slug}`} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
|
<Link href={child.href} className="text-sm text-zinc-600" onClick={() => setOpen(false)}>
|
||||||
{child.name}
|
{child.name}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function HeroSlider({
|
|||||||
className="relative overflow-hidden rounded-2xl"
|
className="relative overflow-hidden rounded-2xl"
|
||||||
style={slotAspectStyle(sideAspectRatio, "13:5")}
|
style={slotAspectStyle(sideAspectRatio, "13:5")}
|
||||||
>
|
>
|
||||||
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
|
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image, "بنر سالار استور")} />
|
||||||
{hasCopy ? (
|
{hasCopy ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ export function ProductCard({ product }: { product: Product }) {
|
|||||||
const unavailable = product.stock === 0 || product.price === 0;
|
const unavailable = product.stock === 0 || product.price === 0;
|
||||||
|
|
||||||
return (
|
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 && (
|
{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">
|
<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}%
|
{off}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="relative">
|
<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>
|
</div>
|
||||||
<h3 className="mt-2 px-1 text-[13px] leading-6 text-ink line-clamp-2 min-h-12">{product.name}</h3>
|
<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">
|
<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="rounded-2xl p-4 md:p-5" style={{ background: gradients[tone] }}>
|
||||||
<div className="flex items-stretch gap-3">
|
<div className="flex items-stretch gap-3">
|
||||||
<div className="relative w-[150px] sm:w-[190px] shrink-0 min-h-[300px]">
|
<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>
|
</div>
|
||||||
<Carousel ariaLabel={ariaLabel} className="min-w-0 flex-1 py-1" tight>
|
<Carousel ariaLabel={ariaLabel} className="min-w-0 flex-1 py-1" tight>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ export function ProductView({ product, variants }: Props) {
|
|||||||
const [qty, setQty] = useState(1);
|
const [qty, setQty] = useState(1);
|
||||||
const [added, setAdded] = useState(false);
|
const [added, setAdded] = useState(false);
|
||||||
|
|
||||||
|
const name = product.nameFa || product.title;
|
||||||
const variant = variants.find((item) => item.id === variantId) ?? variants[0];
|
const variant = variants.find((item) => item.id === variantId) ?? variants[0];
|
||||||
const price = variant ? (variant.discountedPrice ?? variant.price) : (product.store?.minPrice ?? 0);
|
const price = variant ? (variant.discountedPrice ?? variant.price ?? 0) : (product.store?.minPrice ?? 0);
|
||||||
const oldPrice = variant?.discountedPrice ? variant.price : undefined;
|
const oldPrice = variant?.discountedPrice ? (variant.price ?? undefined) : undefined;
|
||||||
const stock = variant ? (variant.stockQuantity ?? 0) : product.store?.inStock ? 1 : 0;
|
const stock = variant ? (variant.stockQuantity ?? 0) : product.store?.inStock ? 1 : 0;
|
||||||
const unavailable = !price || stock === 0;
|
const unavailable = !price || stock === 0;
|
||||||
const off = discountPercent(price, oldPrice);
|
const off = discountPercent(price, oldPrice);
|
||||||
@@ -45,11 +46,11 @@ export function ProductView({ product, variants }: Props) {
|
|||||||
{off}%
|
{off}%
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<div className="mt-3 grid grid-cols-6 gap-2">
|
<div className="mt-3 grid grid-cols-6 gap-2">
|
||||||
{images.map((src) => (
|
{images.map((src, index) => (
|
||||||
<button
|
<button
|
||||||
key={src}
|
key={src}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -58,7 +59,7 @@ export function ProductView({ product, variants }: Props) {
|
|||||||
src === image ? "border-brand" : "border-black/10"
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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>}
|
{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 && (
|
{product.title && product.nameFa && (
|
||||||
<p dir="ltr" className="mt-1 text-sm text-zinc-400">
|
<p dir="ltr" className="mt-1 text-sm text-zinc-400">
|
||||||
{product.title}
|
{product.title}
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ export function SearchBox({ className = "", onNavigate }: Props) {
|
|||||||
return (
|
return (
|
||||||
<li key={product.id}>
|
<li key={product.id}>
|
||||||
<Link
|
<Link
|
||||||
href={product.slug ? `/products/${product.slug}` : `/products?q=${encodeURIComponent(query)}`}
|
href={product.href || `/products?q=${encodeURIComponent(query)}`}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className="flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-50"
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-[13px] leading-6">{product.name}</p>
|
<p className="truncate text-[13px] leading-6">{product.name}</p>
|
||||||
{unavailable ? (
|
{unavailable ? (
|
||||||
|
|||||||
+47
-9
@@ -87,13 +87,18 @@ export type ApiCategory = {
|
|||||||
name: string;
|
name: string;
|
||||||
nameFa: string | null;
|
nameFa: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
description?: string | null;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApiVariant = {
|
export type ApiVariant = {
|
||||||
id: string;
|
id: string;
|
||||||
|
productId?: string;
|
||||||
|
productTitle?: string | null;
|
||||||
|
productNameFa?: string | null;
|
||||||
|
productImage?: string | null;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
price: number;
|
price: number | null;
|
||||||
discountedPrice?: number | null;
|
discountedPrice?: number | null;
|
||||||
stockQuantity?: number | null;
|
stockQuantity?: number | null;
|
||||||
sortOrder?: number;
|
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 } = {}) {
|
export async function getBlogs(query: { page?: number; pageSize?: number; title?: string } = {}) {
|
||||||
return apiGet<Paginated<ApiBlog>>(`${tenant()}/blogs`, query);
|
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() {
|
export async function getStoreSpecials() {
|
||||||
const data = await apiGet<{ items: ApiStoreSpecial[] }>(`${tenant()}/store-specials`);
|
const data = await apiGet<{ items: ApiStoreSpecial[] }>(`${tenant()}/store-specials`);
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
@@ -173,8 +200,10 @@ export async function getStoreSpecials() {
|
|||||||
|
|
||||||
export async function getStoreItemByProduct(productId: string) {
|
export async function getStoreItemByProduct(productId: string) {
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ storeItem: ApiStoreItem }>(`${tenant()}/store-items/by-product/${productId}`);
|
const data = await apiGet<{ storeItem: ApiStoreItem | null }>(
|
||||||
return data.storeItem;
|
`${tenant()}/store-items/by-product/${productId}`,
|
||||||
|
);
|
||||||
|
return data.storeItem ?? null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.status === 404) return null;
|
if (error instanceof ApiError && error.status === 404) return null;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -195,6 +224,21 @@ export async function getCategoryBySlug(slug: string) {
|
|||||||
return categories.find((category) => category.slug === slug) ?? null;
|
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 = {
|
export type ApiTenant = {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -219,9 +263,3 @@ export async function getComments(entityType: "product" | "blog", entityId: stri
|
|||||||
return [];
|
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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
href?: string;
|
||||||
price: number;
|
price: number;
|
||||||
oldPrice?: number;
|
oldPrice?: number;
|
||||||
stock?: number | null;
|
stock?: number | null;
|
||||||
@@ -14,8 +15,10 @@ export type Product = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Post = {
|
export type Post = {
|
||||||
|
id?: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
href?: string;
|
||||||
date: string;
|
date: string;
|
||||||
image: ImageSrc;
|
image: ImageSrc;
|
||||||
abstract?: string;
|
abstract?: string;
|
||||||
|
|||||||
+17
-17
@@ -3,13 +3,13 @@ import {
|
|||||||
getBlogs,
|
getBlogs,
|
||||||
getProducts,
|
getProducts,
|
||||||
getStoreSpecials,
|
getStoreSpecials,
|
||||||
resolveProductSlug,
|
|
||||||
type ApiBlog,
|
type ApiBlog,
|
||||||
type ApiProduct,
|
type ApiProduct,
|
||||||
type ApiSpecialItem,
|
type ApiSpecialItem,
|
||||||
type ApiVariant,
|
type ApiVariant,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import { formatDate } from "./format";
|
import { formatDate } from "./format";
|
||||||
|
import { blogHref, productHref } from "./paths";
|
||||||
|
|
||||||
function productImage(url?: string | null) {
|
function productImage(url?: string | null) {
|
||||||
return url || PLACEHOLDER.product;
|
return url || PLACEHOLDER.product;
|
||||||
@@ -23,35 +23,39 @@ function pickVariantPrice(variants: ApiVariant[]) {
|
|||||||
const inStock = variants.filter((variant) => (variant.stockQuantity ?? 0) > 0);
|
const inStock = variants.filter((variant) => (variant.stockQuantity ?? 0) > 0);
|
||||||
const pool = inStock.length ? inStock : variants;
|
const pool = inStock.length ? inStock : variants;
|
||||||
const best = pool.reduce((current, next) => {
|
const best = pool.reduce((current, next) => {
|
||||||
const currentPrice = current.discountedPrice ?? current.price;
|
const currentPrice = current.discountedPrice ?? current.price ?? 0;
|
||||||
const nextPrice = next.discountedPrice ?? next.price;
|
const nextPrice = next.discountedPrice ?? next.price ?? 0;
|
||||||
return nextPrice < currentPrice ? next : current;
|
return nextPrice < currentPrice ? next : current;
|
||||||
});
|
});
|
||||||
|
|
||||||
const price = best.discountedPrice ?? best.price ?? 0;
|
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);
|
const stock = variants.reduce((sum, variant) => sum + (variant.stockQuantity ?? 0), 0);
|
||||||
|
|
||||||
return { price, oldPrice, stock };
|
return { price, oldPrice, stock };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapProduct(item: ApiProduct): Product {
|
export function mapProduct(item: ApiProduct): Product {
|
||||||
|
const name = item.nameFa || item.title;
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.nameFa || item.title,
|
name,
|
||||||
slug: item.slug,
|
slug: item.slug,
|
||||||
|
href: productHref(item),
|
||||||
price: item.store?.minPrice ?? 0,
|
price: item.store?.minPrice ?? 0,
|
||||||
stock: item.store?.inStock ? item.store.variantCount || 1 : 0,
|
stock: item.store?.inStock ? item.store.variantCount || 1 : 0,
|
||||||
image: productImage(item.thumbnailUrl || item.image || item.thumbnail),
|
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 { price, oldPrice, stock } = pickVariantPrice(item.variants ?? []);
|
||||||
|
const name = item.productNameFa || item.productTitle;
|
||||||
return {
|
return {
|
||||||
id: item.productId,
|
id: item.productId,
|
||||||
name: item.productNameFa || item.productTitle,
|
name,
|
||||||
slug,
|
slug: "",
|
||||||
|
href: productHref(item.productId, name),
|
||||||
price,
|
price,
|
||||||
oldPrice,
|
oldPrice,
|
||||||
stock,
|
stock,
|
||||||
@@ -61,8 +65,10 @@ export function mapSpecialItem(item: ApiSpecialItem, slug: string): Product {
|
|||||||
|
|
||||||
export function mapBlog(item: ApiBlog): Post {
|
export function mapBlog(item: ApiBlog): Post {
|
||||||
return {
|
return {
|
||||||
|
id: item.id,
|
||||||
slug: item.slug,
|
slug: item.slug,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
href: blogHref(item),
|
||||||
date: formatDate(item.publishedAt || item.createdAt || ""),
|
date: formatDate(item.publishedAt || item.createdAt || ""),
|
||||||
image: item.titleImageUrl || PLACEHOLDER.blog,
|
image: item.titleImageUrl || PLACEHOLDER.blog,
|
||||||
abstract: item.abstract?.trim() || undefined,
|
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());
|
const special = specials.find((item) => item.isActive && item.key.toLowerCase() === key.toLowerCase());
|
||||||
if (!special) return [];
|
if (!special) return [];
|
||||||
|
|
||||||
const items = special.items
|
return special.items
|
||||||
.filter((item) => item.isActive)
|
.filter((item) => item.isActive)
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((item) => mapSpecialItem(item));
|
||||||
return Promise.all(
|
|
||||||
items.map(async (item) => {
|
|
||||||
const slug = await resolveProductSlug(item.productId, item.productTitle || item.productNameFa || "");
|
|
||||||
return mapSpecialItem(item, slug);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+49
-22
@@ -44,13 +44,21 @@ export type StaticImageCatalogSlot = {
|
|||||||
*/
|
*/
|
||||||
export const STATIC_IMAGE_KEYS = {
|
export const STATIC_IMAGE_KEYS = {
|
||||||
slider: "slider",
|
slider: "slider",
|
||||||
bannersBelowSlider: "banners-below-slider",
|
twoBannerBelowSlider: "two-banner-below-slider",
|
||||||
categories: "categories",
|
categories: "categories",
|
||||||
fourBannerSection: "four-banner-section",
|
fourBanner: "four-banner",
|
||||||
threeBannerRow: "three-banner-row",
|
twoBannerRow: "two-banner-row",
|
||||||
|
brands: "brands",
|
||||||
aboutUsImage: "about-us-image",
|
aboutUsImage: "about-us-image",
|
||||||
} as const;
|
} 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[] = [
|
export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
||||||
{
|
{
|
||||||
key: STATIC_IMAGE_KEYS.slider,
|
key: STATIC_IMAGE_KEYS.slider,
|
||||||
@@ -62,8 +70,8 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
|||||||
recommendedWidth: 1200,
|
recommendedWidth: 1200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: STATIC_IMAGE_KEYS.bannersBelowSlider,
|
key: STATIC_IMAGE_KEYS.twoBannerBelowSlider,
|
||||||
label: "Banners below slider",
|
label: "Two banners below slider",
|
||||||
kind: "list",
|
kind: "list",
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
aspectRatio: "13:5",
|
aspectRatio: "13:5",
|
||||||
@@ -80,7 +88,7 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
|||||||
recommendedWidth: 160,
|
recommendedWidth: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: STATIC_IMAGE_KEYS.fourBannerSection,
|
key: STATIC_IMAGE_KEYS.fourBanner,
|
||||||
label: "Four banner section",
|
label: "Four banner section",
|
||||||
kind: "list",
|
kind: "list",
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
@@ -89,21 +97,30 @@ export const STATIC_IMAGE_SLOT_CATALOG: StaticImageCatalogSlot[] = [
|
|||||||
recommendedWidth: 600,
|
recommendedWidth: 600,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: STATIC_IMAGE_KEYS.threeBannerRow,
|
key: STATIC_IMAGE_KEYS.twoBannerRow,
|
||||||
label: "Three banner row",
|
label: "Two banner row",
|
||||||
kind: "list",
|
kind: "list",
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
aspectRatio: "12:5",
|
aspectRatio: "12:5",
|
||||||
itemCount: 3,
|
itemCount: 2,
|
||||||
recommendedWidth: 480,
|
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,
|
key: STATIC_IMAGE_KEYS.aboutUsImage,
|
||||||
label: "About us image",
|
label: "About us images",
|
||||||
kind: "single",
|
kind: "list",
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
aspectRatio: "8:5",
|
aspectRatio: "8:5",
|
||||||
itemCount: 1,
|
itemCount: 2,
|
||||||
recommendedWidth: 720,
|
recommendedWidth: 720,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -121,8 +138,14 @@ export function slotAlt(image: StaticImage, fallback = ""): string {
|
|||||||
return image.titleFa?.trim() || image.titleEn?.trim() || fallback;
|
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[] {
|
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 raw = slot?.images ?? [];
|
||||||
const sorted = [...raw].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
const sorted = [...raw].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||||
const result: StaticImageWithUrl[] = [];
|
const result: StaticImageWithUrl[] = [];
|
||||||
@@ -131,7 +154,11 @@ export function imagesForSlot(slots: StaticImageSlot[], key: string): StaticImag
|
|||||||
if (!url) continue;
|
if (!url) continue;
|
||||||
result.push({ ...image, url });
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +169,6 @@ export function firstImageForSlot(
|
|||||||
return imagesForSlot(slots, key)[0];
|
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[]> {
|
export async function getStaticImageSlots(pageKey?: string): Promise<StaticImageSlot[]> {
|
||||||
try {
|
try {
|
||||||
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images`);
|
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> {
|
export async function getStaticImageSlot(key: string): Promise<StaticImageSlot | null> {
|
||||||
|
const keys = [key, ...(SLOT_KEY_ALIASES[key] ?? [])];
|
||||||
|
for (const candidate of keys) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${key}`);
|
const url = new URL(`${API.base}/tenants/${API.domain}/website/static-images/${candidate}`);
|
||||||
const response = await fetch(url, { next: { revalidate: 120 } });
|
const response = await fetch(url, { next: { revalidate: 120 } });
|
||||||
if (!response.ok) return null;
|
if (!response.ok) continue;
|
||||||
return (await response.json()) as StaticImageSlot;
|
return (await response.json()) as StaticImageSlot;
|
||||||
} catch {
|
} catch {
|
||||||
|
// try next alias
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
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",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prebuild": "node scripts/generate-sitemap-config.mjs",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.3.1",
|
"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