From 0cb9aa57e290c495dad851e38177b62052750859 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 22 Aug 2026 23:52:45 +0330 Subject: [PATCH] Use Meshkee {id}/{fa-slug} detail URLs for products and blogs. Resolve pages via by-id APIs, slugify Farsi titles for public paths, and redirect legacy slug-only URLs to the canonical form. Co-authored-by: Cursor --- Ai-data/prompt-seo.txt | 8 +- CONTEXT.md | 14 ++- app/api/search/route.ts | 2 +- app/blog/[id]/[slug]/page.tsx | 99 +++++++++++++++ app/blog/[slug]/page.tsx | 85 ++----------- app/blog/page.tsx | 2 +- app/products/[id]/[slug]/page.tsx | 183 ++++++++++++++++++++++++++++ app/products/[id]/page.tsx | 183 ++-------------------------- components/home/BlogCarousel.tsx | 2 +- components/layout/InstantSearch.tsx | 7 +- components/ui/ProductCard.tsx | 3 +- lib/blog.ts | 20 ++- lib/seo-slug.ts | 20 +++ lib/store.ts | 105 +++++++++------- 14 files changed, 426 insertions(+), 307 deletions(-) create mode 100644 app/blog/[id]/[slug]/page.tsx create mode 100644 app/products/[id]/[slug]/page.tsx create mode 100644 lib/seo-slug.ts diff --git a/Ai-data/prompt-seo.txt b/Ai-data/prompt-seo.txt index 9177cc1..58cb317 100644 --- a/Ai-data/prompt-seo.txt +++ b/Ai-data/prompt-seo.txt @@ -2,11 +2,15 @@ - Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API). - Publish GET /meshkee/sitemap-config.json generated at build time from app routes. - Include public static routes automatically; exclude auth/checkout/cart/admin paths. -- Omit templates unless this site uses non-default detail URLs. +- Canonical detail URLs (Meshkee defaults — omit templates unless customized): + - Product: /products/{id}/{nameFaSlug} → GET /tenants/{domain}/products/by-id/{id} + - Blog: /blog/{id}/{titleSlug} → GET /tenants/{domain}/blogs/by-id/{id} + - Portfolio: /portfolios/{id}/{titleFaSlug} → GET /tenants/{domain}/portfolios/by-id/{id} +- Slugify: Farsi letters kept, spaces → -, strip other punctuation. Do not use legacy DB slug in public URLs. - After deploy: business dashboard → Website → Settings → Sync sitemap config (optional for CMS URLs; needed for static pages). ### On-page SEO (every public page) - Unique and meta description on every crawlable page - Exactly one H1; H2/H3 for real sections - All content images must have meaningful alt (CMS title when available) -- Prefer CMS fields for titles/descriptions; never leave Next defaults \ No newline at end of file +- Prefer CMS fields for titles/descriptions; never leave Next defaults diff --git a/CONTEXT.md b/CONTEXT.md index c2d9cfd..11e9af3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,11 +40,10 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the | Path | Source | | --- | --- | | `/` | Homepage + static image slots + store specials + latest blogs | -| `/products` | Store items (`GET /tenants/{domain}/store-items`) | -| `/products?categoryId=` | Same, filtered | -| `/products/[variantId]` | Store item variant + product description/gallery + comments | -| `/blog` | `GET /tenants/{domain}/blogs` (falls back to `lib/data.ts` `blogPosts`) | -| `/blog/[slug]` | Blog details + comments | +| `/products` | Catalog / store items list | +| `/products/[id]/[slug]` | Product detail via `GET .../products/by-id/{id}` | +| `/blog` | Blog list | +| `/blog/[id]/[slug]` | Blog detail via `GET .../blogs/by-id/{id}` | | `/about` | About us | | `/contact` | Contact us | | `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button | @@ -97,7 +96,10 @@ Products list/search: prefer store-items; if empty, fall back to `GET /products` - Do **not** ship `public/sitemap.xml` or `public/robots.txt` (nginx proxies to Meshkee API). - Build generates `lib/sitemap-config.generated.json` (`npm run generate:sitemap-config` / `prebuild`); served at `GET /meshkee/sitemap-config.json`. - After deploy: business dashboard → Website → Settings → Sync sitemap config. -- Templates omitted (defaults `/products/{slug}`, `/blog/{slug}` match this site). +- Detail URLs (Meshkee defaults — omit `templates` unless customized): + - Product: `/products/{id}/{nameFaSlug}` — slugify `nameFa` (Farsi kept, spaces → `-`); resolve with `products/by-id/{id}`. Never use legacy DB slug in public URLs. + - Blog: `/blog/{id}/{titleSlug}` — `blogs/by-id/{id}`. +- Legacy `/products/{slug}` and `/blog/{slug}` redirect to the canonical `{id}/{slug}` form. - Every public page has unique title/description; product/blog prefer CMS fields. ## Original business data (do not invent replacements) diff --git a/app/api/search/route.ts b/app/api/search/route.ts index d209387..7dd3669 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -36,7 +36,7 @@ export async function GET(request: Request) { return Response.json({ items: (catalogData.items ?? []).map((item) => ({ - id: item.slug || item.id, + id: item.id, productId: item.id, productNameFa: item.nameFa, productTitle: item.title, diff --git a/app/blog/[id]/[slug]/page.tsx b/app/blog/[id]/[slug]/page.tsx new file mode 100644 index 0000000..9f5f1a4 --- /dev/null +++ b/app/blog/[id]/[slug]/page.tsx @@ -0,0 +1,99 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { Container } from "@/components/Container"; +import { PageHero } from "@/components/layout/PageHero"; +import { ProductTabs } from "@/components/product/ProductTabs"; +import { Placeholder } from "@/components/Placeholder"; +import { blogHref, blogImage, blogTitleSlug, fetchBlogById } from "@/lib/blog"; +import { fetchComments } from "@/lib/comments"; +import { getBlogPost } from "@/lib/data"; +import { formatFaDate } from "@/lib/format"; + +export const revalidate = 60; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}): Promise<Metadata> { + const { id } = await params; + const apiPost = await fetchBlogById(id); + if (apiPost) return { title: apiPost.title, description: apiPost.abstract ?? undefined }; + const local = getBlogPost(id); + if (!local) return { title: "مقاله" }; + return { title: local.title, description: local.excerpt }; +} + +export default async function BlogPostPage({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}) { + const { id, slug } = await params; + const apiPost = await fetchBlogById(id); + + if (apiPost) { + const canonical = blogTitleSlug(apiPost.title); + if (decodeURIComponent(slug) !== canonical) { + redirect(blogHref(apiPost)); + } + + const comments = await fetchComments("blog", apiPost.id); + return ( + <> + <PageHero kicker="Blog" title={apiPost.title} image={blogImage(apiPost)} /> + <section className="py-16 sm:py-24"> + <Container className="max-w-3xl"> + <p className="text-[13px] text-muted"> + {formatFaDate(apiPost.publishedAt || apiPost.createdAt)} + </p> + {apiPost.abstract ? <p className="mt-6 text-[16px] leading-9 text-muted">{apiPost.abstract}</p> : null} + {apiPost.mainTextHtml ? ( + <div + className="rich-text mt-8 text-[16px] leading-9 text-muted" + dangerouslySetInnerHTML={{ __html: apiPost.mainTextHtml }} + /> + ) : null} + <ProductTabs showDescription={false} comments={comments} /> + <Link href="/blog" className="mt-10 inline-flex items-center gap-3 text-[14px] font-bold text-brand"> + بازگشت به مقالات + <span className="h-px w-10 bg-brand" /> + </Link> + </Container> + </section> + </> + ); + } + + const post = getBlogPost(id); + if (!post) notFound(); + + const canonical = blogTitleSlug(post.title); + if (decodeURIComponent(slug) !== canonical) { + redirect(blogHref({ id: post.slug, title: post.title })); + } + + return ( + <> + <PageHero kicker="Blog" title={post.title} image={post.image} /> + <section className="py-16 sm:py-24"> + <Container className="max-w-3xl"> + <p className="text-[13px] text-muted">{post.date}</p> + <div className="mt-8 space-y-5 text-[16px] leading-9 text-muted"> + {post.body.map((paragraph) => ( + <p key={paragraph}>{paragraph}</p> + ))} + </div> + <div className="mt-8 overflow-hidden rounded-2xl"> + <Placeholder src={post.image} alt={post.title} className="aspect-[16/9] w-full object-cover" /> + </div> + <Link href="/blog" className="mt-10 inline-flex items-center gap-3 text-[14px] font-bold text-brand"> + بازگشت به مقالات + <span className="h-px w-10 bg-brand" /> + </Link> + </Container> + </section> + </> + ); +} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index d4eeff0..d9b9690 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -1,89 +1,22 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { Container } from "@/components/Container"; -import { PageHero } from "@/components/layout/PageHero"; -import { ProductTabs } from "@/components/product/ProductTabs"; -import { Placeholder } from "@/components/Placeholder"; -import { blogImage, fetchBlog } from "@/lib/blog"; -import { fetchComments } from "@/lib/comments"; +import { notFound, redirect } from "next/navigation"; +import { blogHref, fetchBlogBySlug } from "@/lib/blog"; import { getBlogPost } from "@/lib/data"; -import { formatFaDate } from "@/lib/format"; export const revalidate = 60; -export async function generateMetadata({ - params, -}: { - params: Promise<{ slug: string }>; -}): Promise<Metadata> { - const { slug } = await params; - const apiPost = await fetchBlog(slug); - if (apiPost) return { title: apiPost.title, description: apiPost.abstract ?? undefined }; - const local = getBlogPost(slug); - if (!local) return { title: "مقاله" }; - return { title: local.title, description: local.excerpt }; -} - -export default async function BlogPostPage({ +/** Legacy `/blog/{slug}` → canonical `/blog/{id}/{titleSlug}`. */ +export default async function LegacyBlogRedirect({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; - const apiPost = await fetchBlog(slug); - if (apiPost) { - const comments = await fetchComments("blog", apiPost.id); - return ( - <> - <PageHero kicker="Blog" title={apiPost.title} image={blogImage(apiPost)} /> - <section className="py-16 sm:py-24"> - <Container className="max-w-3xl"> - <p className="text-[13px] text-muted"> - {formatFaDate(apiPost.publishedAt || apiPost.createdAt)} - </p> - {apiPost.abstract ? <p className="mt-6 text-[16px] leading-9 text-muted">{apiPost.abstract}</p> : null} - {apiPost.mainTextHtml ? ( - <div - className="rich-text mt-8 text-[16px] leading-9 text-muted" - dangerouslySetInnerHTML={{ __html: apiPost.mainTextHtml }} - /> - ) : null} - <ProductTabs showDescription={false} comments={comments} /> - <Link href="/blog" className="mt-10 inline-flex items-center gap-3 text-[14px] font-bold text-brand"> - بازگشت به مقالات - <span className="h-px w-10 bg-brand" /> - </Link> - </Container> - </section> - </> - ); - } + const apiPost = await fetchBlogBySlug(slug); + if (apiPost) redirect(blogHref(apiPost)); - const post = getBlogPost(slug); - if (!post) notFound(); + const local = getBlogPost(slug); + if (local) redirect(blogHref({ id: local.slug, title: local.title })); - return ( - <> - <PageHero kicker="Blog" title={post.title} image={post.image} /> - <section className="py-16 sm:py-24"> - <Container className="max-w-3xl"> - <p className="text-[13px] text-muted">{post.date}</p> - <div className="mt-8 space-y-5 text-[16px] leading-9 text-muted"> - {post.body.map((paragraph) => ( - <p key={paragraph}>{paragraph}</p> - ))} - </div> - <div className="mt-8 overflow-hidden rounded-2xl"> - <Placeholder src={post.image} alt={post.title} className="aspect-[16/9] w-full object-cover" /> - </div> - <Link href="/blog" className="mt-10 inline-flex items-center gap-3 text-[14px] font-bold text-brand"> - بازگشت به مقالات - <span className="h-px w-10 bg-brand" /> - </Link> - </Container> - </section> - </> - ); + notFound(); } diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 6bd1a63..93a2580 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -80,7 +80,7 @@ export default async function BlogPage({ {blogPosts.map((post) => ( <Link key={post.slug} - href={`/blog/${post.slug}`} + href={blogHref({ id: post.slug, title: post.title })} className="group block rounded-2xl border border-line bg-white p-3" > <div className="overflow-hidden rounded-xl"> diff --git a/app/products/[id]/[slug]/page.tsx b/app/products/[id]/[slug]/page.tsx new file mode 100644 index 0000000..fb7b1de --- /dev/null +++ b/app/products/[id]/[slug]/page.tsx @@ -0,0 +1,183 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { Container } from "@/components/Container"; +import { ProductGallery } from "@/components/product/ProductGallery"; +import { ProductTabs } from "@/components/product/ProductTabs"; +import { fetchComments } from "@/lib/comments"; +import { formatPrice } from "@/lib/format"; +import { + brandName, + catalogTitle, + fetchCatalogProductById, + fetchStoreItemByProduct, + galleryUrls, + productHref, + productNameSlug, + variantTitle, + type CatalogProduct, + type StoreVariant, +} from "@/lib/store"; + +export const revalidate = 60; + +async function resolveProduct(id: string): Promise<{ + variant: StoreVariant | null; + catalog: CatalogProduct | null; + productId?: string; +}> { + const catalog = await fetchCatalogProductById(id); + if (catalog) { + const store = await fetchStoreItemByProduct(String(catalog.id)); + const firstVariant = store.storeItem?.variants?.[0] ?? null; + return { + variant: firstVariant, + catalog, + productId: String(catalog.id), + }; + } + + const store = await fetchStoreItemByProduct(id); + const firstVariant = store.storeItem?.variants?.[0]; + if (firstVariant) { + const linked = await fetchCatalogProductById(id); + return { + variant: { ...firstVariant, productId: id }, + catalog: linked, + productId: id, + }; + } + + return { variant: null, catalog: null }; +} + +function stripHtml(html?: string | null) { + if (!html?.trim()) return undefined; + const text = html + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + return text.slice(0, 180) || undefined; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}): Promise<Metadata> { + const { id } = await params; + const resolved = await resolveProduct(id); + const title = + (resolved.catalog ? catalogTitle(resolved.catalog) : null) || + (resolved.variant ? variantTitle(resolved.variant) : null); + const description = + resolved.catalog?.summary?.trim() || + stripHtml(resolved.catalog?.descriptionHtml) || + undefined; + return { + title: title || "محصول", + description, + }; +} + +export default async function ProductDetailsPage({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}) { + const { id, slug } = await params; + const { variant, catalog, productId } = await resolveProduct(id); + if (!variant && !catalog) notFound(); + + const title = + (catalog ? catalogTitle(catalog) : null) || (variant ? variantTitle(variant) : "محصول"); + const canonicalSlug = productNameSlug(title); + if (decodeURIComponent(slug) !== canonicalSlug) { + redirect(productHref(productId || id, title)); + } + + const comments = productId ? await fetchComments("product", productId) : []; + const sibling = productId ? await fetchStoreItemByProduct(productId) : { storeItem: undefined }; + + const images = galleryUrls(catalog, variant?.productImage || catalog?.image || catalog?.thumbnailUrl); + const price = variant?.discountedPrice ?? variant?.price ?? catalog?.store?.minPrice ?? null; + const oldPrice = variant?.discountedPrice != null ? variant.price : null; + const brand = brandName(catalog); + const variants = sibling.storeItem?.variants ?? []; + const inStock = + variant != null + ? variant.stockQuantity == null || variant.stockQuantity > 0 + : catalog?.store?.inStock !== false; + + return ( + <section className="py-10 sm:py-14"> + <Container> + <p className="text-[13px] text-muted"> + <Link href="/" className="hover:text-brand"> + خانه + </Link> + <span className="mx-2">/</span> + <Link href="/products" className="hover:text-brand"> + محصولات + </Link> + <span className="mx-2">/</span> + {title} + </p> + + <div className="mt-5 grid gap-8 lg:grid-cols-[0.9fr_1.1fr]"> + <ProductGallery images={images} alt={title} /> + <div> + {brand ? <p className="text-[13px] font-medium text-brand">{brand}</p> : null} + {catalog?.categoryNameFa ? ( + <p className="mt-1 text-[12px] text-muted">{catalog.categoryNameFa}</p> + ) : null} + <h1 className="mt-2 text-[22px] font-extrabold leading-9 text-ink">{title}</h1> + {variant?.label && variant.label !== title ? ( + <p className="mt-2 text-[13px] text-muted">{variant.label}</p> + ) : null} + + <div className="mt-5 flex items-end gap-3"> + {oldPrice ? <p className="text-[13px] text-muted line-through">{formatPrice(oldPrice)} تومان</p> : null} + <p className="text-[22px] font-bold text-ink"> + {price != null ? ( + <> + {formatPrice(price)} + <span className="mr-1 text-[13px] font-medium text-muted">تومان</span> + </> + ) : ( + <span className="text-[16px] font-medium text-muted">برای قیمت تماس بگیرید</span> + )} + </p> + </div> + <p className={`mt-2 text-[13px] ${inStock ? "text-emerald-600" : "text-brand"}`}> + {inStock ? "موجود در فروشگاه" : "ناموجود"} + </p> + + {variants.length > 1 ? ( + <div className="mt-5 flex flex-wrap gap-2"> + {variants.map((item) => ( + <Link + key={item.id} + href={productHref(productId || id, variantTitle(item))} + className={`rounded-lg border px-3 py-1.5 text-[12px] ${item.id === variant?.id ? "border-brand bg-paper text-ink" : "border-line bg-white text-muted"}`} + > + {item.label || variantTitle(item)} + </Link> + ))} + </div> + ) : null} + + <Link + href="/contact" + className="mt-6 inline-flex h-12 items-center justify-center rounded-xl bg-brand px-6 text-[14px] font-bold text-white hover:bg-brand-dark" + > + استعلام و سفارش + </Link> + </div> + </div> + + <ProductTabs descriptionHtml={catalog?.descriptionHtml || catalog?.summary} comments={comments} /> + </Container> + </section> + ); +} diff --git a/app/products/[id]/page.tsx b/app/products/[id]/page.tsx index 2b15d1d..fffbcee 100644 --- a/app/products/[id]/page.tsx +++ b/app/products/[id]/page.tsx @@ -1,185 +1,26 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { Container } from "@/components/Container"; -import { ProductGallery } from "@/components/product/ProductGallery"; -import { ProductTabs } from "@/components/product/ProductTabs"; -import { fetchComments } from "@/lib/comments"; -import { formatPrice } from "@/lib/format"; +import { notFound, redirect } from "next/navigation"; import { - brandName, + catalogTitle, + fetchCatalogProductById, fetchCatalogProductBySlug, - fetchCatalogProductByTitle, - fetchStoreItemByProduct, - fetchStoreVariant, - galleryUrls, - variantTitle, - type CatalogProduct, - type StoreVariant, + productHref, } from "@/lib/store"; export const revalidate = 60; -async function resolveProduct(id: string): Promise<{ - variant: StoreVariant | null; - catalog: CatalogProduct | null; - productId?: string; -}> { - const variant = await fetchStoreVariant(id); - if (variant) { - const catalog = await fetchCatalogProductByTitle( - variant.productNameFa || variant.productTitle, - variant.productId, - ); - return { variant, catalog, productId: variant.productId }; - } - - const bySlug = await fetchCatalogProductBySlug(id); - if (bySlug) { - return { variant: null, catalog: bySlug, productId: bySlug.id }; - } - - const byProduct = await fetchStoreItemByProduct(id); - const firstVariant = byProduct.storeItem?.variants?.[0]; - if (firstVariant) { - const catalog = await fetchCatalogProductByTitle( - firstVariant.productNameFa || firstVariant.productTitle, - id, - ); - return { - variant: { - ...firstVariant, - productId: id, - }, - catalog, - productId: id, - }; - } - - return { variant: null, catalog: null }; -} - -export async function generateMetadata({ +/** Legacy `/products/{id-or-slug}` → canonical `/products/{id}/{nameFaSlug}`. */ +export default async function LegacyProductRedirect({ params, }: { params: Promise<{ id: string }>; -}): Promise<Metadata> { +}) { const { id } = await params; - const resolved = await resolveProduct(id); - const title = - resolved.catalog?.nameFa || - resolved.catalog?.title || - (resolved.variant ? variantTitle(resolved.variant) : null); - const description = - resolved.catalog?.summary?.trim() || - stripHtml(resolved.catalog?.descriptionHtml) || - undefined; - return { - title: title || "محصول", - description, - }; -} -function stripHtml(html?: string | null) { - if (!html?.trim()) return undefined; - const text = html - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); - return text.slice(0, 180) || undefined; -} + const byId = await fetchCatalogProductById(id); + if (byId) redirect(productHref(byId)); -export default async function ProductDetailsPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const { variant, catalog, productId } = await resolveProduct(id); - if (!variant && !catalog) notFound(); + const bySlug = await fetchCatalogProductBySlug(id); + if (bySlug) redirect(productHref(bySlug)); - const comments = productId ? await fetchComments("product", productId) : []; - const sibling = - productId && variant - ? await fetchStoreItemByProduct(productId) - : { storeItem: undefined }; - - const images = galleryUrls(catalog, variant?.productImage || catalog?.image || catalog?.thumbnailUrl); - const price = variant?.discountedPrice ?? variant?.price ?? catalog?.store?.minPrice ?? null; - const oldPrice = variant?.discountedPrice != null ? variant.price : null; - const title = catalog?.nameFa || catalog?.title || (variant ? variantTitle(variant) : "محصول"); - const brand = brandName(catalog); - const variants = sibling.storeItem?.variants ?? []; - const inStock = - variant != null - ? variant.stockQuantity == null || variant.stockQuantity > 0 - : catalog?.store?.inStock !== false; - - return ( - <section className="py-10 sm:py-14"> - <Container> - <p className="text-[13px] text-muted"> - <Link href="/" className="hover:text-brand"> - خانه - </Link> - <span className="mx-2">/</span> - <Link href="/products" className="hover:text-brand"> - محصولات - </Link> - <span className="mx-2">/</span> - {title} - </p> - - <div className="mt-5 grid gap-8 lg:grid-cols-[0.9fr_1.1fr]"> - <ProductGallery images={images} alt={title} /> - <div> - {brand ? <p className="text-[13px] font-medium text-brand">{brand}</p> : null} - {catalog?.categoryNameFa ? ( - <p className="mt-1 text-[12px] text-muted">{catalog.categoryNameFa}</p> - ) : null} - <h1 className="mt-2 text-[22px] font-extrabold leading-9 text-ink">{title}</h1> - {variant?.label && variant.label !== title ? ( - <p className="mt-2 text-[13px] text-muted">{variant.label}</p> - ) : null} - - <div className="mt-5 flex items-end gap-3"> - {oldPrice ? <p className="text-[13px] text-muted line-through">{formatPrice(oldPrice)} تومان</p> : null} - <p className="text-[22px] font-bold text-ink"> - {price != null ? ( - <> - {formatPrice(price)} - <span className="mr-1 text-[13px] font-medium text-muted">تومان</span> - </> - ) : ( - <span className="text-[16px] font-medium text-muted">برای قیمت تماس بگیرید</span> - )} - </p> - </div> - <p className={`mt-2 text-[13px] ${inStock ? "text-emerald-600" : "text-brand"}`}> - {inStock ? "موجود در فروشگاه" : "ناموجود"} - </p> - - {variants.length > 1 ? ( - <div className="mt-5 flex flex-wrap gap-2"> - {variants.map((item) => ( - <Link - key={item.id} - href={`/products/${encodeURIComponent(item.id)}`} - className={`rounded-lg border px-3 py-1.5 text-[12px] ${item.id === variant?.id ? "border-brand bg-paper text-ink" : "border-line bg-white text-muted"}`} - > - {item.label || variantTitle(item)} - </Link> - ))} - </div> - ) : null} - - <Link - href="/contact" - className="mt-6 inline-flex h-12 items-center justify-center rounded-xl bg-brand px-6 text-[14px] font-bold text-white hover:bg-brand-dark" - > - استعلام و سفارش - </Link> - </div> - </div> - - <ProductTabs descriptionHtml={catalog?.descriptionHtml || catalog?.summary} comments={comments} /> - </Container> - </section> - ); + notFound(); } diff --git a/components/home/BlogCarousel.tsx b/components/home/BlogCarousel.tsx index 7f7387f..5d5a75e 100644 --- a/components/home/BlogCarousel.tsx +++ b/components/home/BlogCarousel.tsx @@ -101,7 +101,7 @@ export function BlogCarousel({ key={post.slug} className="w-[min(85vw,340px)] shrink-0 snap-start rounded-2xl border border-line bg-white p-3" > - <Link href={`/blog/${post.slug}`} className="group block"> + <Link href={blogHref({ id: post.slug, title: post.title })} className="group block"> <div className="overflow-hidden rounded-xl"> <Placeholder src={post.image} diff --git a/components/layout/InstantSearch.tsx b/components/layout/InstantSearch.tsx index e10b8a5..b74321e 100644 --- a/components/layout/InstantSearch.tsx +++ b/components/layout/InstantSearch.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; import { Search } from "lucide-react"; import { Placeholder } from "@/components/Placeholder"; import { formatPrice } from "@/lib/format"; -import { variantTitle, type StoreVariant } from "@/lib/store"; +import { productHref, variantTitle, type StoreVariant } from "@/lib/store"; export function InstantSearch({ compact = false, className = "" }: { compact?: boolean; className?: string }) { const router = useRouter(); @@ -88,7 +88,10 @@ export function InstantSearch({ compact = false, className = "" }: { compact?: b return ( <li key={item.id}> <Link - href={`/products/${encodeURIComponent(item.id)}`} + href={productHref( + item.productId || item.id, + item.productNameFa || item.productTitle || item.label, + )} className="flex items-center gap-3 px-3 py-2 hover:bg-paper" onClick={() => setOpen(false)} > diff --git a/components/ui/ProductCard.tsx b/components/ui/ProductCard.tsx index d870722..45b49fe 100644 --- a/components/ui/ProductCard.tsx +++ b/components/ui/ProductCard.tsx @@ -3,9 +3,10 @@ import { ArrowUpLeft } from "lucide-react"; import { Placeholder } from "@/components/Placeholder"; import { faDigits, formatPrice } from "@/lib/format"; import type { Product } from "@/lib/data"; +import { productHref } from "@/lib/store"; export function ProductCard({ product }: { product: Product }) { - const href = product.href ?? `/products/${encodeURIComponent(product.id)}`; + const href = product.href ?? productHref(product.id, product.title); return ( <article className="group flex h-full flex-col rounded-2xl border border-line bg-white p-3"> diff --git a/lib/blog.ts b/lib/blog.ts index 7ff412d..cecd20b 100644 --- a/lib/blog.ts +++ b/lib/blog.ts @@ -1,4 +1,5 @@ import { meshkeeGet, tenantPath } from "@/lib/meshkee"; +import { detailPath, slugifyForUrl } from "@/lib/seo-slug"; export type BlogPost = { id: string; @@ -32,7 +33,16 @@ export async function fetchBlogs(params: { page?: number; pageSize?: number } = }); } -export async function fetchBlog(slug: string) { +export async function fetchBlogById(id: string) { + const data = await meshkeeGet<{ blog?: BlogPost }>( + tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`), + {}, + ); + return data.blog ?? null; +} + +/** @deprecated Prefer fetchBlogById for public detail pages. */ +export async function fetchBlogBySlug(slug: string) { const data = await meshkeeGet<{ blog?: BlogPost }>(tenantPath(`/blogs/${encodeURIComponent(slug)}`), {}); return data.blog ?? null; } @@ -41,6 +51,10 @@ export function blogImage(post: BlogPost) { return post.titleImageUrl || "/placeholders/blog.svg"; } -export function blogHref(post: BlogPost) { - return `/blog/${encodeURIComponent(post.slug)}`; +export function blogHref(post: Pick<BlogPost, "id" | "title">) { + return detailPath("/blog", post.id, post.title); +} + +export function blogTitleSlug(title: string) { + return slugifyForUrl(title); } diff --git a/lib/seo-slug.ts b/lib/seo-slug.ts new file mode 100644 index 0000000..861b2a2 --- /dev/null +++ b/lib/seo-slug.ts @@ -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)}`; +} diff --git a/lib/store.ts b/lib/store.ts index 5592636..52ad061 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -1,5 +1,6 @@ import type { Product } from "@/lib/data"; import { meshkeeGet, tenantPath } from "@/lib/meshkee"; +import { detailPath, slugifyForUrl } from "@/lib/seo-slug"; export type StoreVariant = { id: string; @@ -85,17 +86,45 @@ function inStock(quantity?: number | null) { return quantity == null || quantity > 0; } +export function brandName(product?: CatalogProduct | null) { + const brand = product?.brand; + if (!brand) return ""; + if (typeof brand === "string") return brand; + return brand.nameFa || brand.name || ""; +} + export function variantTitle(variant: StoreVariant) { return variant.productNameFa?.trim() || variant.productTitle?.trim() || variant.label?.trim() || "محصول"; } +export function catalogTitle(product: Pick<CatalogProduct, "nameFa" | "title" | "id">) { + return product.nameFa?.trim() || product.title?.trim() || String(product.id); +} + +/** Public product URL: /products/{id}/{nameFaSlug} — never use legacy DB slug. */ +export function productHref( + productOrId: string | number | Pick<CatalogProduct, "id" | "nameFa" | "title">, + title?: string | null, +) { + if (typeof productOrId === "object") { + return detailPath("/products", productOrId.id, catalogTitle(productOrId)); + } + return detailPath("/products", productOrId, title?.trim() || String(productOrId)); +} + +export function productNameSlug(title: string) { + return slugifyForUrl(title); +} + export function variantToProduct(variant: StoreVariant): Product { const price = asNumber(variant.discountedPrice) ?? asNumber(variant.price); const oldPrice = asNumber(variant.discountedPrice) != null ? asNumber(variant.price) ?? undefined : undefined; + const productId = variant.productId ?? variant.id; + const title = variantTitle(variant); return { - id: String(variant.id), - href: `/products/${encodeURIComponent(String(variant.id))}`, - title: variantTitle(variant), + id: String(productId), + href: productHref(productId, title), + title, brand: variant.selections?.map((item) => item.value).filter(Boolean).join(" / ") || variant.label || "", price: price ?? undefined, oldPrice, @@ -105,7 +134,7 @@ export function variantToProduct(variant: StoreVariant): Product { }; } -export function specialItemToProduct(item: StoreSpecialProduct, slug?: string | null): Product | null { +export function specialItemToProduct(item: StoreSpecialProduct): Product | null { if (item.isActive === false) return null; const variant = item.variants?.[0]; @@ -124,13 +153,9 @@ export function specialItemToProduct(item: StoreSpecialProduct, slug?: string | if (!title && !item.productImage) return null; const id = String(item.productId ?? item.id); - const href = slug - ? `/products/${encodeURIComponent(slug)}` - : `/products?q=${encodeURIComponent(title || id)}`; - return { id, - href, + href: productHref(id, title || id), title: title || "محصول", brand: "", image: item.productImage || PLACEHOLDER, @@ -186,19 +211,9 @@ export async function fetchSpecialProducts(key: string) { .filter((item) => item.isActive !== false) .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); - const products = ( - await Promise.all( - rawItems.map(async (item) => { - if (item.variants?.[0]) return specialItemToProduct(item); - - const catalog = await fetchCatalogProductByTitle( - item.productNameFa || item.productTitle, - item.productId, - ); - return specialItemToProduct(item, catalog?.slug); - }), - ) - ).filter((item): item is Product => Boolean(item)); + const products = rawItems + .map((item) => specialItemToProduct(item)) + .filter((item): item is Product => Boolean(item)); return { special, products }; } @@ -238,8 +253,8 @@ export function catalogProductToCard(product: CatalogProduct): Product { const price = asNumber(product.store?.minPrice); return { id: String(product.id), - href: product.slug ? `/products/${encodeURIComponent(product.slug)}` : `/products?q=${encodeURIComponent(product.nameFa || product.title || product.id)}`, - title: product.nameFa?.trim() || product.title?.trim() || "محصول", + href: productHref(product), + title: catalogTitle(product), brand: brandName(product) || product.categoryNameFa || "", price: price ?? undefined, image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER, @@ -247,21 +262,32 @@ export function catalogProductToCard(product: CatalogProduct): Product { }; } -export async function fetchCatalogProductByTitle(title?: string | null, productId?: string | null) { - if (!title?.trim() && !productId) return null; - const query = title?.trim() || String(productId); - const data = await meshkeeGet<Paged<CatalogProduct>>( - tenantPath(`/products?name=${encodeURIComponent(query)}&pageSize=12`), - { items: [], total: 0, page: 1, pageSize: 12 }, +export async function fetchCatalogProductById(productId: string) { + const data = await meshkeeGet<{ product?: CatalogProduct }>( + tenantPath(`/products/by-id/${encodeURIComponent(productId)}`), + {}, ); - const match = - data.items.find((item) => String(item.id) === String(productId)) ?? - data.items[0] ?? - null; - if (!match?.slug) return match; - return fetchCatalogProductBySlug(match.slug); + return data.product ?? null; } +export async function fetchCatalogProductByTitle(title?: string | null, productId?: string | null) { + if (productId) { + const byId = await fetchCatalogProductById(String(productId)); + if (byId) return byId; + } + if (!title?.trim()) return null; + const data = await meshkeeGet<Paged<CatalogProduct>>( + tenantPath(`/products?name=${encodeURIComponent(title.trim())}&pageSize=12`), + { items: [], total: 0, page: 1, pageSize: 12 }, + ); + return ( + data.items.find((item) => String(item.id) === String(productId)) ?? + data.items[0] ?? + null + ); +} + +/** @deprecated Prefer fetchCatalogProductById for public detail pages. */ export async function fetchCatalogProductBySlug(slug: string) { const data = await meshkeeGet<{ product?: CatalogProduct }>( tenantPath(`/products/${encodeURIComponent(slug)}`), @@ -269,10 +295,3 @@ export async function fetchCatalogProductBySlug(slug: string) { ); return data.product ?? null; } - -export function brandName(product?: CatalogProduct | null) { - const brand = product?.brand; - if (!brand) return ""; - if (typeof brand === "string") return brand; - return brand.nameFa || brand.name || ""; -}