diff --git a/Ai-data/prompt-seo.txt b/Ai-data/prompt-seo.txt new file mode 100644 index 0000000..9177cc1 --- /dev/null +++ b/Ai-data/prompt-seo.txt @@ -0,0 +1,12 @@ +### Sitemap + robots.txt +- 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. +- 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 diff --git a/CONTEXT.md b/CONTEXT.md index db60b62..c2d9cfd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,11 +48,12 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the | `/about` | About us | | `/contact` | Contact us | | `/meshkee/static-image-slots` | Slot catalog for the dashboard Refresh button | +| `/meshkee/sitemap-config.json` | Build-time route manifest for Meshkee sitemap sync | | `/api/search?q=` | Instant product search (`store-items?name=`, min 3 chars) | ## Homepage order -`Hero` → `Services` → three product carousels → `CategoryShowcase` → `AboutPreview` → five-banner (2+3) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`. +`Hero` → `Services` → اقلام مصرفی → `CategoryShowcase` → قطعات یخچال → `AboutPreview` → قطعات ماشین لباسشویی → five-banner (2+3) → `BlogCarousel` → `two-banner-row` → `Stats` → `WideBanner` → `Branches`. ## Homepage carousels (store specials) @@ -62,7 +63,9 @@ This site is **catalog-only** (no shopping cart). Login / user icon goes to the 2. `refrigrator-parts` — قطعات یخچال 3. `washing-machine-parts` — قطعات ماشین لباسشویی -If a special has no `items`, keep sample products from `lib/data.ts`. Use `variants[0].id` as the product URL. +BRB specials use `source: "product"` (items may have empty `variants[]`). Map product fields directly; resolve slug via catalog for detail links. If a special has no `items`, keep sample products from `lib/data.ts`. + +Products list/search: prefer store-items; if empty, fall back to `GET /products` catalog. ## Catalog @@ -89,6 +92,14 @@ If a special has no `items`, keep sample products from `lib/data.ts`. Use `varia `url` → img, `linkUrl` → wrap in a link, empty/fail → placeholder. Use `slot.aspectRatio`. If a slot’s size/ratio changes in CSS, update `STATIC_IMAGE_SLOTS` in the same change. +## SEO + +- 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). +- Every public page has unique title/description; product/blog prefer CMS fields. + ## Original business data (do not invent replacements) **About:** 30+ years. Sells home appliances, spare parts, and repairs small appliances. Wholesale and retail. diff --git a/app/api/search/route.ts b/app/api/search/route.ts index a6c2a60..d209387 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -7,12 +7,43 @@ export async function GET(request: Request) { return Response.json({ items: [] }); } + const encoded = encodeURIComponent(query); + const base = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}`; + try { - const response = await fetch( - `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/store-items?name=${encodeURIComponent(query)}&pageSize=8`, - { cache: "no-store" }, - ); - return Response.json(await response.json()); + const storeResponse = await fetch(`${base}/store-items?name=${encoded}&pageSize=8`, { + cache: "no-store", + }); + const storeData = (await storeResponse.json()) as { items?: unknown[] }; + if (storeData.items?.length) { + return Response.json(storeData); + } + + const catalogResponse = await fetch(`${base}/products?name=${encoded}&pageSize=8`, { + cache: "no-store", + }); + const catalogData = (await catalogResponse.json()) as { + items?: Array<{ + id: string; + nameFa?: string | null; + title?: string | null; + slug?: string | null; + thumbnailUrl?: string | null; + image?: string | null; + store?: { minPrice?: number | null; maxPrice?: number | null }; + }>; + }; + + return Response.json({ + items: (catalogData.items ?? []).map((item) => ({ + id: item.slug || item.id, + productId: item.id, + productNameFa: item.nameFa, + productTitle: item.title, + productImage: item.thumbnailUrl || item.image, + price: item.store?.minPrice ?? item.store?.maxPrice ?? null, + })), + }); } catch { return Response.json({ items: [] }); } diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 9fc6d1d..d4eeff0 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -40,7 +40,7 @@ export default async function BlogPostPage({ <PageHero kicker="Blog" title={apiPost.title} image={blogImage(apiPost)} /> <section className="py-16 sm:py-24"> <Container className="max-w-3xl"> - <p className="font-latin text-[12px] tracking-[0.18em] text-muted"> + <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} @@ -69,7 +69,7 @@ export default async function BlogPostPage({ <PageHero kicker="Blog" title={post.title} image={post.image} /> <section className="py-16 sm:py-24"> <Container className="max-w-3xl"> - <p className="font-latin text-[12px] tracking-[0.18em] text-muted">{post.date}</p> + <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> diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 581350c..6bd1a63 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -29,22 +29,26 @@ export default async function BlogPage({ return ( <> - <PageHero kicker="Blog" title="مقالات" image="/images/slides/slide-2.jpg" /> + <PageHero kicker="Blog" title="مقالات" image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg" /> <section className="py-16 sm:py-24"> <Container> {usingApi ? ( <> - <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"> + <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> {result.items.map((post) => ( - <Link key={post.id} href={blogHref(post)} className="group block"> - <div className="overflow-hidden rounded-2xl"> + <Link + key={post.id} + href={blogHref(post)} + className="group block rounded-2xl border border-line bg-white p-3" + > + <div className="overflow-hidden rounded-xl"> <Placeholder src={blogImage(post)} alt={post.title} className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105" /> </div> - <p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted"> + <p className="mt-3 text-[12px] text-muted"> {formatFaDate(post.publishedAt || post.createdAt)} </p> <h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2> @@ -72,17 +76,21 @@ export default async function BlogPage({ ) : null} </> ) : ( - <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"> + <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> {blogPosts.map((post) => ( - <Link key={post.slug} href={`/blog/${post.slug}`} className="group block"> - <div className="overflow-hidden rounded-2xl"> + <Link + key={post.slug} + href={`/blog/${post.slug}`} + className="group block rounded-2xl border border-line bg-white p-3" + > + <div className="overflow-hidden rounded-xl"> <Placeholder src={post.image} alt={post.title} className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105" /> </div> - <p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">{post.date}</p> + <p className="mt-3 text-[12px] text-muted">{post.date}</p> <h2 className="mt-2 text-[18px] font-bold leading-7 group-hover:text-brand">{post.title}</h2> <p className="mt-2 line-clamp-2 text-[14px] leading-7 text-muted">{post.excerpt}</p> </Link> diff --git a/app/layout.tsx b/app/layout.tsx index 06ec00f..1d593bf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer"; import { Header } from "@/components/layout/Header"; import { TopBar } from "@/components/layout/TopBar"; import { getNavCategories } from "@/lib/categories"; +import { websiteDomain } from "@/lib/config"; import { site } from "@/lib/data"; const vazir = Vazirmatn({ @@ -22,6 +23,7 @@ const montserrat = Montserrat({ }); export const metadata: Metadata = { + metadataBase: new URL(`https://${websiteDomain}`), title: { default: `${site.name} | ${site.tagline}`, template: `%s | ${site.name}`, diff --git a/app/meshkee/sitemap-config.json/route.ts b/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..998558b --- /dev/null +++ b/app/meshkee/sitemap-config.json/route.ts @@ -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 }); +} diff --git a/app/page.tsx b/app/page.tsx index 8891dc4..9b6253c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { AboutPreview } from "@/components/home/AboutPreview"; import { BannerRow } from "@/components/home/BannerRow"; import { BlogCarousel } from "@/components/home/BlogCarousel"; @@ -17,6 +18,7 @@ import { refrigeratorPartsFallback, services, showcaseCategories, + site, slides, threeBannerRow, twoBannerRow, @@ -28,6 +30,13 @@ import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static export const revalidate = 60; +export const metadata: Metadata = { + title: { + absolute: `${site.name} | ${site.tagline}`, + }, + description: site.description, +}; + export default async function HomePage() { const [slots, consumables, refrigerator, washing, blogs] = await Promise.all([ fetchStaticImageSlots("home"), @@ -141,20 +150,20 @@ export default async function HomePage() { subtitle="قطعات مصرفی پرفروش برای تعمیرگاه و مصرف‌کننده" products={consumables.products.length ? consumables.products : consumablesFallback} /> + <CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} /> <ProductCarousel id="refrigerator-parts" title={refrigerator.special?.title || "قطعات یخچال"} subtitle="قطعات یخچال و فریزر" products={refrigerator.products.length ? refrigerator.products : refrigeratorPartsFallback} /> + <AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} /> <ProductCarousel id="washing-machine-parts" title={washing.special?.title || "قطعات ماشین لباسشویی"} subtitle="قطعات لباسشویی خانگی و صنعتی" products={washing.products.length ? washing.products : washingMachinePartsFallback} /> - <CategoryShowcase items={categories.items} aspectRatio={categories.aspectRatio} /> - <AboutPreview image={aboutImage.items[0]} aspectRatio={aboutImage.aspectRatio} /> <BannerRow banners={fiveBanners.items.slice(0, 2)} columns={2} aspectRatio={fiveBanners.aspectRatio} /> <BannerRow banners={fiveBanners.items.slice(2, 5)} columns={3} aspectRatio={fiveBanners.aspectRatio} /> <BlogCarousel posts={blogs.items} fallback={blogPosts} /> diff --git a/app/products/[id]/page.tsx b/app/products/[id]/page.tsx index 8caf13d..2b15d1d 100644 --- a/app/products/[id]/page.tsx +++ b/app/products/[id]/page.tsx @@ -8,45 +8,108 @@ import { fetchComments } from "@/lib/comments"; import { formatPrice } from "@/lib/format"; import { brandName, + fetchCatalogProductBySlug, fetchCatalogProductByTitle, fetchStoreItemByProduct, fetchStoreVariant, galleryUrls, 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 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({ params, }: { params: Promise<{ id: string }>; }): Promise<Metadata> { const { id } = await params; - const variant = await fetchStoreVariant(id); + 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: variant ? variantTitle(variant) : "محصول", + 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; +} + export default async function ProductDetailsPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const variant = await fetchStoreVariant(id); - if (!variant) notFound(); + const { variant, catalog, productId } = await resolveProduct(id); + if (!variant && !catalog) notFound(); - const [catalog, storeItem, comments] = await Promise.all([ - fetchCatalogProductByTitle(variant.productNameFa || variant.productTitle, variant.productId), - variant.productId ? fetchStoreItemByProduct(variant.productId) : Promise.resolve({ storeItem: undefined }), - variant.productId ? fetchComments("product", variant.productId) : Promise.resolve([]), - ]); + const comments = productId ? await fetchComments("product", productId) : []; + const sibling = + productId && variant + ? await fetchStoreItemByProduct(productId) + : { storeItem: undefined }; - const images = galleryUrls(catalog, variant.productImage); - const price = variant.discountedPrice ?? variant.price; - const oldPrice = variant.discountedPrice != null ? variant.price : null; - const title = catalog?.nameFa || variantTitle(variant); + 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 = storeItem.storeItem?.variants ?? []; - const inStock = variant.stockQuantity == null || variant.stockQuantity > 0; + 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"> @@ -67,8 +130,11 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{ <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 ? ( + {variant?.label && variant.label !== title ? ( <p className="mt-2 text-[13px] text-muted">{variant.label}</p> ) : null} @@ -95,7 +161,7 @@ export default async function ProductDetailsPage({ params }: { params: Promise<{ <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"}`} + 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> diff --git a/app/products/page.tsx b/app/products/page.tsx index 95038fe..04c5642 100644 --- a/app/products/page.tsx +++ b/app/products/page.tsx @@ -1,10 +1,16 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Container } from "@/components/Container"; +import { PageHero } from "@/components/layout/PageHero"; import { ProductCard } from "@/components/ui/ProductCard"; -import { fetchStoreItems, variantToProduct } from "@/lib/store"; import { getNavCategories } from "@/lib/categories"; import { faDigits } from "@/lib/format"; +import { + catalogProductToCard, + fetchCatalogProducts, + fetchStoreItems, + variantToProduct, +} from "@/lib/store"; export const revalidate = 60; @@ -22,67 +28,70 @@ export default async function ProductsPage({ const page = Math.max(1, Number(params.page) || 1); const categoryId = params.categoryId; const q = params.q?.trim(); - const [result, nav] = await Promise.all([ - fetchStoreItems({ page, pageSize: 20, categoryId, name: q && q.length > 2 ? q : undefined }), + const name = q && q.length > 2 ? q : undefined; + + const [storeResult, catalogResult, nav] = await Promise.all([ + fetchStoreItems({ page, pageSize: 20, categoryId, name }), + fetchCatalogProducts({ page, pageSize: 20, categoryId, name }), getNavCategories(), ]); - const products = result.items.map(variantToProduct); - const pageCount = Math.max(1, Math.ceil((result.total || products.length) / (result.pageSize || 20))); + + const fromStore = storeResult.items.map(variantToProduct); + const products = fromStore.length ? fromStore : catalogResult.items.map(catalogProductToCard); + const total = fromStore.length ? storeResult.total : catalogResult.total; + const pageSize = fromStore.length ? storeResult.pageSize : catalogResult.pageSize; + const pageCount = Math.max(1, Math.ceil((total || products.length) / (pageSize || 20))); const categoryTitle = nav .flatMap((item) => [item, ...item.children.map((child) => ({ ...child, children: [] as never[] }))]) .find((item) => item.id === categoryId)?.title; + const heroTitle = categoryTitle || (q ? `جستجو: ${q}` : "محصولات"); return ( - <section className="py-10 sm:py-14"> - <Container> - <div className="mb-6"> - <p className="text-[13px] text-muted"> - <Link href="/" className="hover:text-brand"> - خانه - </Link> - <span className="mx-2">/</span> - محصولات - </p> - <h1 className="mt-2 text-[22px] font-extrabold text-ink"> - {categoryTitle || (q ? `جستجو: ${q}` : "همه محصولات")} - </h1> - {result.total ? <p className="mt-1 text-[13px] text-muted">{faDigits(result.total)} کالا</p> : null} - </div> + <> + <PageHero + kicker="Products" + title={heroTitle} + image="/images/lifestyle/franco-debartolo-AXC2vD7y_ho-unsplash.jpg" + /> + <section className="py-10 sm:py-14"> + <Container> + {total ? <p className="mb-6 text-[13px] text-muted">{faDigits(total)} کالا</p> : null} - {products.length === 0 ? ( - <div className="rounded-2xl border border-line bg-white px-6 py-16 text-center"> - <p className="text-[15px] font-bold text-ink">محصولی برای نمایش وجود ندارد</p> - <p className="mt-2 text-[13px] text-muted">به‌زودی موجودی فروشگاه از پنل Meshkee اینجا دیده می‌شود.</p> - </div> - ) : ( - <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> - {products.map((product) => ( - <ProductCard key={product.id} product={product} /> - ))} - </div> - )} + {products.length === 0 ? ( + <div className="rounded-2xl border border-line bg-white px-6 py-16 text-center"> + <p className="text-[15px] font-bold text-ink">محصولی برای نمایش وجود ندارد</p> + <p className="mt-2 text-[13px] text-muted">به‌زودی موجودی فروشگاه از پنل Meshkee اینجا دیده می‌شود.</p> + </div> + ) : ( + <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> + {products.map((product) => ( + <ProductCard key={product.id} product={product} /> + ))} + </div> + )} - {pageCount > 1 ? ( - <div className="mt-8 flex justify-center gap-2"> - {Array.from({ length: pageCount }, (_, index) => { - const number = index + 1; - const query = new URLSearchParams(); - if (categoryId) query.set("categoryId", categoryId); - if (q) query.set("q", q); - query.set("page", String(number)); - return ( - <Link - key={number} - href={`/products?${query.toString()}`} - className={`grid size-10 place-items-center rounded-xl text-[13px] ${number === page ? "bg-brand text-white" : "border border-line bg-white text-ink"}`} - > - {faDigits(number)} - </Link> - ); - })} - </div> - ) : null} - </Container> - </section> + {pageCount > 1 ? ( + <div className="mt-8 flex justify-center gap-2"> + {Array.from({ length: pageCount }, (_, index) => { + const number = index + 1; + const query = new URLSearchParams(); + if (categoryId) query.set("categoryId", categoryId); + if (q) query.set("q", q); + query.set("page", String(number)); + return ( + <Link + key={number} + href={`/products?${query.toString()}`} + className={`grid size-10 place-items-center rounded-xl text-[13px] ${number === page ? "bg-brand text-white" : "border border-line bg-white text-ink"}`} + > + {faDigits(number)} + </Link> + ); + })} + </div> + ) : null} + </Container> + </section> + </> ); } diff --git a/components/home/BlogCarousel.tsx b/components/home/BlogCarousel.tsx index 987d247..7f7387f 100644 --- a/components/home/BlogCarousel.tsx +++ b/components/home/BlogCarousel.tsx @@ -37,53 +37,56 @@ export function BlogCarousel({ return ( <section id="blog" className="py-20 sm:py-24"> <Container> - <div className="mb-10 flex items-end justify-between gap-4"> - <div> - <div className="flex items-center gap-3"> - <span className="h-[3px] w-8 bg-brand" /> - <p className="section-kicker">Blog</p> + <div className="mb-10"> + <div className="flex items-center gap-3"> + <span className="h-[3px] w-8 bg-brand" /> + <p className="section-kicker">Blog</p> + </div> + <div className="mt-3 flex items-center justify-between gap-4"> + <h2 className="text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2> + <div className="flex shrink-0 items-center gap-2"> + <button + type="button" + onClick={() => scrollByCard(1)} + className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand" + aria-label="قبلی" + > + <ChevronRight className="size-4" /> + </button> + <button + type="button" + onClick={() => scrollByCard(-1)} + className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand" + aria-label="بعدی" + > + <ChevronLeft className="size-4" /> + </button> + <Link href="/blog" className="hidden rounded-xl bg-ink px-5 py-2.5 text-[13px] text-white hover:bg-brand sm:inline-flex"> + مشاهده همه + </Link> </div> - <h2 className="mt-3 text-3xl font-extrabold tracking-tight sm:text-[40px]">آخرین مقالات</h2> - <p className="mt-2 max-w-xl text-[14px] leading-7 text-muted"> - نکته‌هایی از تعمیرگاه و بورس قطعات — برای مصرف‌کننده و همکار. - </p> - </div> - <div className="flex items-center gap-2"> - <button - type="button" - onClick={() => scrollByCard(1)} - className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand" - aria-label="قبلی" - > - <ChevronRight className="size-4" /> - </button> - <button - type="button" - onClick={() => scrollByCard(-1)} - className="grid size-10 place-items-center rounded-full border border-line bg-white text-ink hover:border-brand" - aria-label="بعدی" - > - <ChevronLeft className="size-4" /> - </button> - <Link href="/blog" className="hidden rounded-xl bg-ink px-5 py-2.5 text-[13px] text-white hover:bg-brand sm:inline-flex"> - مشاهده همه - </Link> </div> + <p className="mt-2 max-w-xl text-[14px] leading-7 text-muted"> + نکته‌هایی از تعمیرگاه و بورس قطعات — برای مصرف‌کننده و همکار. + </p> </div> <div ref={scroller} className="no-scrollbar flex snap-x snap-mandatory gap-5 overflow-x-auto"> {usingApi ? posts.map((post) => ( - <article key={post.id} className="w-[min(85vw,340px)] shrink-0 snap-start"> + <article + key={post.id} + className="w-[min(85vw,340px)] shrink-0 snap-start rounded-2xl border border-line bg-white p-3" + > <Link href={blogHref(post)} className="group block"> - <div className="overflow-hidden rounded-2xl"> + <div className="overflow-hidden rounded-xl"> <Placeholder src={blogImage(post)} alt={post.title} className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105" /> </div> - <p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted"> + <p className="mt-3 text-[12px] text-muted"> {formatFaDate(post.publishedAt || post.createdAt)} </p> <h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3> @@ -94,16 +97,19 @@ export function BlogCarousel({ </article> )) : fallback.map((post) => ( - <article key={post.slug} className="w-[min(85vw,340px)] shrink-0 snap-start"> + <article + 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"> - <div className="overflow-hidden rounded-2xl"> + <div className="overflow-hidden rounded-xl"> <Placeholder src={post.image} alt={post.title} className="aspect-[16/10] w-full object-cover transition-transform duration-700 group-hover:scale-105" /> </div> - <p className="mt-4 font-latin text-[11px] tracking-[0.18em] text-muted">{post.date}</p> + <p className="mt-3 text-[12px] text-muted">{post.date}</p> <h3 className="mt-2 text-[16px] font-bold leading-7 group-hover:text-brand">{post.title}</h3> <p className="mt-2 line-clamp-2 text-[13px] leading-7 text-muted">{post.excerpt}</p> </Link> diff --git a/components/home/Hero.tsx b/components/home/Hero.tsx index 0020cde..c444665 100644 --- a/components/home/Hero.tsx +++ b/components/home/Hero.tsx @@ -1,156 +1,220 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { Placeholder } from "@/components/Placeholder"; import { SlotLink } from "@/components/SlotLink"; -import { parseAspectRatio } from "@/lib/static-images"; import type { SlotMediaItem } from "@/lib/static-images"; -function circularOffset(index: number, active: number, length: number) { - let diff = index - active; - if (diff > length / 2) diff -= length; - if (diff < -length / 2) diff += length; - return diff; -} +const SLIDE_W = "min(1280px, calc(100vw - 5rem))"; +const GAP = "1rem"; +const INTERVAL_MS = 6000; +const TRANSITION_MS = 700; -const slideWidth = "min(1280px, calc(100vw - 5rem))"; - -export function Hero({ slides, aspectRatio }: { slides: SlotMediaItem[]; aspectRatio?: string }) { +export function Hero({ slides }: { slides: SlotMediaItem[]; aspectRatio?: string }) { const [active, setActive] = useState(0); + const [shift, setShift] = useState(0); + const [animating, setAnimating] = useState(false); const [paused, setPaused] = useState(false); - const ratio = parseAspectRatio(aspectRatio); + const lock = useRef(false); + const shiftRef = useRef(0); + const countRef = useRef(slides.length); + countRef.current = slides.length; + + const finishTransition = () => { + if (!lock.current) return; + lock.current = false; + const delta = shiftRef.current; + const total = countRef.current; + shiftRef.current = 0; + setActive((current) => (current + delta + total) % total); + setShift(0); + setAnimating(false); + }; + + const go = (direction: -1 | 1) => { + if (countRef.current < 2 || lock.current) return; + lock.current = true; + shiftRef.current = direction; + setAnimating(true); + setShift(direction); + window.setTimeout(() => { + if (lock.current && shiftRef.current === direction) finishTransition(); + }, TRANSITION_MS + 80); + }; useEffect(() => { setActive(0); + setShift(0); + shiftRef.current = 0; + setAnimating(false); + lock.current = false; }, [slides.length]); useEffect(() => { - if (slides.length < 2 || paused) return; - const timer = setInterval(() => { - setActive((current) => (current + 1) % slides.length); - }, 6000); - return () => clearInterval(timer); - }, [paused, slides.length]); - - const go = (direction: number) => { - if (slides.length < 2) return; - setActive((current) => (current + direction + slides.length) % slides.length); - }; + if (slides.length < 2 || paused || animating) return; + const timer = window.setInterval(() => go(1), INTERVAL_MS); + return () => window.clearInterval(timer); + }, [paused, animating, slides.length, active]); if (slides.length === 0) return null; + const count = slides.length; + const offsets = + count === 1 + ? ([0] as number[]) + : shift > 0 + ? ([-1, 0, 1, 2] as number[]) + : shift < 0 + ? ([-2, -1, 0, 1] as number[]) + : ([-1, 0, 1] as number[]); + return ( <section className="relative overflow-x-clip bg-white pt-5 pb-8"> <div - className={ratio ? "relative min-h-[340px]" : "relative h-[min(62vh,640px)] min-h-[340px]"} - style={ratio ? { aspectRatio: ratio, maxHeight: 640 } : undefined} + className="relative h-[min(62vh,640px)] min-h-[340px]" + style={{ ["--slide-w" as string]: SLIDE_W, ["--slide-gap" as string]: GAP }} dir="ltr" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} > - {slides.map((item, i) => { - const offset = circularOffset(i, active, slides.length); - const isCenter = offset === 0; - const isPeek = Math.abs(offset) === 1; + <div + className="absolute inset-0" + style={{ + transform: `translateX(calc(${-shift} * (var(--slide-w) + var(--slide-gap))))`, + transition: animating + ? `transform ${TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)` + : "none", + }} + onTransitionEnd={(event) => { + if (event.target !== event.currentTarget) return; + if (event.propertyName !== "transform") return; + finishTransition(); + }} + > + {offsets.map((offset) => { + const index = (active + offset + count * 10) % count; + const item = slides[index]; + const isActiveSlot = offset === shift; - return ( - <article - key={item.id} - className="absolute top-0 h-full w-[min(1280px,calc(100vw-5rem))]" - style={{ - left: `calc(50% - (${slideWidth}) / 2 + ${offset} * ((${slideWidth}) + 1rem))`, - zIndex: isCenter ? 3 : isPeek ? 2 : 1, - opacity: isCenter || isPeek ? 1 : 0, - pointerEvents: isCenter || isPeek ? "auto" : "none", - transition: "left 700ms cubic-bezier(0.22, 1, 0.36, 1), opacity 500ms ease", - }} - > - {isCenter ? ( - <SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl"> - <Placeholder - src={item.image} - fallbackSrc={item.fallbackSrc} - alt={item.title ?? ""} - className="h-full w-full object-cover" - /> - <div - className="absolute inset-x-4 bottom-4 z-10 rounded-2xl px-5 py-3.5 sm:inset-x-5 sm:bottom-5 sm:px-7 sm:py-4" - style={{ - background: - "linear-gradient(180deg, rgb(48 52 56 / 0.72) 0%, rgb(18 20 22 / 0.82) 100%)", - backdropFilter: "blur(22px) saturate(180%)", - WebkitBackdropFilter: "blur(22px) saturate(180%)", - border: "1px solid rgb(255 255 255 / 0.22)", - boxShadow: - "inset 0 1px 0 rgb(255 255 255 / 0.28), 0 10px 28px rgb(0 0 0 / 0.28)", - }} - > - <div dir="rtl" className="flex items-center justify-between gap-4"> - <div className="flex min-w-0 items-center gap-3 sm:gap-5"> - <h1 className="truncate text-[16px] font-extrabold text-white sm:text-[22px]"> - {item.title} - </h1> - <span className="hidden h-[3px] w-10 shrink-0 rounded-full bg-brand sm:block" /> - <span className="hidden h-6 w-px shrink-0 bg-white/20 sm:block" /> - {item.kicker ? ( - <p className="font-latin hidden shrink-0 text-[11px] tracking-[0.2em] text-white/60 uppercase sm:block"> - {item.kicker} - </p> - ) : null} + return ( + <article + key={`slot-${offset}`} + className="absolute top-0 h-full" + style={{ + width: "var(--slide-w)", + left: `calc(50% - var(--slide-w) / 2 + ${offset} * (var(--slide-w) + var(--slide-gap)))`, + zIndex: isActiveSlot ? 3 : 2, + }} + > + {isActiveSlot ? ( + <SlotLink href={item.href} className="relative block h-full overflow-hidden rounded-2xl"> + <Placeholder + src={item.image} + fallbackSrc={item.fallbackSrc} + alt={item.title ?? ""} + className="h-full w-full object-cover" + /> + <div + className="absolute inset-x-4 bottom-4 z-10 rounded-2xl px-5 py-3.5 sm:inset-x-5 sm:bottom-5 sm:px-7 sm:py-4" + style={{ + background: + "linear-gradient(180deg, rgb(48 52 56 / 0.72) 0%, rgb(18 20 22 / 0.82) 100%)", + backdropFilter: "blur(22px) saturate(180%)", + WebkitBackdropFilter: "blur(22px) saturate(180%)", + border: "1px solid rgb(255 255 255 / 0.22)", + boxShadow: + "inset 0 1px 0 rgb(255 255 255 / 0.28), 0 10px 28px rgb(0 0 0 / 0.28)", + }} + > + <div dir="rtl" className="flex items-center justify-between gap-4"> + <div className="flex min-w-0 items-center gap-3 sm:gap-5"> + <h1 className="truncate text-[16px] font-extrabold text-white sm:text-[22px]"> + {item.title || "فروشگاه برادران برقعی"} + </h1> + <span className="hidden h-[3px] w-10 shrink-0 rounded-full bg-brand sm:block" /> + <span className="hidden h-6 w-px shrink-0 bg-white/20 sm:block" /> + {item.kicker ? ( + <p className="font-latin hidden shrink-0 text-[11px] tracking-[0.2em] text-white/60 uppercase sm:block"> + {item.kicker} + </p> + ) : null} + </div> + <span className="inline-flex shrink-0 rounded-xl bg-brand px-5 py-2.5 text-[13px] font-medium text-white sm:px-6 sm:py-3 sm:text-[14px]"> + مشاهده قطعات + </span> </div> - <span className="inline-flex shrink-0 rounded-xl bg-brand px-5 py-2.5 text-[13px] font-medium text-white sm:px-6 sm:py-3 sm:text-[14px]"> - مشاهده قطعات - </span> </div> - </div> - </SlotLink> - ) : ( - <button - type="button" - className="relative block h-full w-full overflow-hidden rounded-2xl" - onClick={() => setActive(i)} - aria-label={item.title} - > - <Placeholder src={item.image} fallbackSrc={item.fallbackSrc} alt="" className="h-full w-full object-cover" /> - <span className="absolute inset-0 bg-white/35" /> - </button> - )} - </article> - ); - })} - - <div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-[min(1280px,calc(100vw-5rem))] -translate-x-1/2"> - <button - type="button" - onClick={() => go(1)} - className="pointer-events-auto absolute top-1/2 right-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm" - aria-label="اسلاید راست" - > - <ChevronRight className="size-5" /> - </button> - <button - type="button" - onClick={() => go(-1)} - className="pointer-events-auto absolute top-1/2 left-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm" - aria-label="اسلاید چپ" - > - <ChevronLeft className="size-5" /> - </button> + </SlotLink> + ) : ( + <button + type="button" + className="relative block h-full w-full overflow-hidden rounded-2xl" + onClick={() => go(offset < shift ? -1 : 1)} + aria-label={item.title} + disabled={animating} + > + <Placeholder + src={item.image} + fallbackSrc={item.fallbackSrc} + alt={item.title ?? ""} + className="h-full w-full object-cover" + /> + <span className="absolute inset-0 bg-white/35" /> + </button> + )} + </article> + ); + })} </div> + + {count > 1 ? ( + <div + className="pointer-events-none absolute inset-y-0 left-1/2 z-10 -translate-x-1/2" + style={{ width: "var(--slide-w)" }} + > + <button + type="button" + onClick={() => go(1)} + disabled={animating} + className="pointer-events-auto absolute top-1/2 right-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm disabled:opacity-60" + aria-label="اسلاید راست" + > + <ChevronRight className="size-5" /> + </button> + <button + type="button" + onClick={() => go(-1)} + disabled={animating} + className="pointer-events-auto absolute top-1/2 left-3 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white text-ink shadow-sm disabled:opacity-60" + aria-label="اسلاید چپ" + > + <ChevronLeft className="size-5" /> + </button> + </div> + ) : null} </div> - <div className="relative z-10 mt-5 flex justify-center gap-2"> - {slides.map((item, i) => ( - <button - key={item.id} - type="button" - onClick={() => setActive(i)} - className={`h-1.5 rounded-full transition-all ${i === active ? "w-8 bg-brand" : "w-3 bg-ink/20"}`} - aria-label={`اسلاید ${i + 1}`} - /> - ))} - </div> + {count > 1 ? ( + <div className="relative z-10 mt-5 flex justify-center gap-2"> + {slides.map((item, i) => ( + <button + key={item.id} + type="button" + disabled={animating} + onClick={() => { + if (i === active || lock.current) return; + const forward = (i - active + count) % count; + const backward = (active - i + count) % count; + if (forward <= backward) go(1); + else go(-1); + }} + className={`h-1.5 rounded-full transition-all ${i === active && shift === 0 ? "w-8 bg-brand" : "w-3 bg-ink/20"}`} + aria-label={`اسلاید ${i + 1}`} + /> + ))} + </div> + ) : null} </section> ); } diff --git a/components/home/Services.tsx b/components/home/Services.tsx index 4161e95..e3157d2 100644 --- a/components/home/Services.tsx +++ b/components/home/Services.tsx @@ -23,7 +23,7 @@ export function Services({ items }: { items: SlotMediaItem[] }) { <Placeholder src={service.image} fallbackSrc={service.fallbackSrc} - alt="" + alt={service.title ?? ""} className="mt-1 size-16 shrink-0 object-contain sm:size-[4.5rem]" /> ) : ( diff --git a/components/home/WideBanner.tsx b/components/home/WideBanner.tsx index a9df79a..aa90283 100644 --- a/components/home/WideBanner.tsx +++ b/components/home/WideBanner.tsx @@ -2,7 +2,6 @@ import { Container } from "@/components/Container"; import { Placeholder } from "@/components/Placeholder"; import { SlotLink } from "@/components/SlotLink"; import { glossyBar } from "@/lib/glossy"; -import { parseAspectRatio } from "@/lib/static-images"; import type { SlotMediaItem } from "@/lib/static-images"; const fallbackCopy = { @@ -11,37 +10,32 @@ const fallbackCopy = { href: "/contact", }; -export function WideBanner({ image, aspectRatio }: { image?: SlotMediaItem; aspectRatio?: string }) { +export function WideBanner({ image }: { image?: SlotMediaItem; aspectRatio?: string }) { const src = image?.image ?? "/images/banners/wide.jpg"; const href = image?.href || fallbackCopy.href; - const ratio = parseAspectRatio(aspectRatio); return ( - <section - className={`relative overflow-hidden ${ratio ? "min-h-[280px]" : "h-[280px] sm:h-[360px]"}`} - style={ratio ? { aspectRatio: ratio } : undefined} - > + <section className="relative h-[160px] overflow-hidden bg-ink sm:h-[200px]"> <SlotLink href={href} className="absolute inset-0"> <Placeholder src={src} fallbackSrc={image?.fallbackSrc ?? "/images/banners/wide.jpg"} - alt="" + alt={image?.title || fallbackCopy.title} className="h-full w-full object-cover" /> <span className="absolute inset-0 bg-ink/20" /> </SlotLink> <Container className="relative flex h-full items-center"> - <div className="max-w-xl rounded-2xl px-8 py-7" style={glossyBar}> + <div className="max-w-xl rounded-2xl px-6 py-4 sm:px-8 sm:py-5" style={glossyBar}> <p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase"> {image?.kicker || fallbackCopy.kicker} </p> - <p className="mt-3 text-[22px] font-bold leading-9 text-white sm:text-[26px]"> + <p className="mt-1.5 line-clamp-2 text-[18px] font-bold leading-7 text-white sm:text-[22px]"> {image?.title || fallbackCopy.title} </p> - {image?.text ? <p className="mt-2 text-[14px] leading-7 text-white/75">{image.text}</p> : null} <SlotLink href={href} - className="mt-5 inline-flex rounded-xl bg-brand px-6 py-3 text-[14px] font-medium text-white hover:bg-brand-dark" + className="mt-3 inline-flex rounded-xl bg-brand px-5 py-2.5 text-[13px] font-medium text-white hover:bg-brand-dark" > تماس با ما </SlotLink> diff --git a/components/layout/PageHero.tsx b/components/layout/PageHero.tsx index 7d955a0..06b9294 100644 --- a/components/layout/PageHero.tsx +++ b/components/layout/PageHero.tsx @@ -1,6 +1,6 @@ -import { GlassBox } from "@/components/GlassBox"; import { Placeholder } from "@/components/Placeholder"; import { Container } from "@/components/Container"; +import { glossyBar } from "@/lib/glossy"; export function PageHero({ kicker, @@ -12,17 +12,15 @@ export function PageHero({ image: string; }) { return ( - <section className="relative h-[42vh] min-h-[280px] overflow-hidden bg-ink"> - <Placeholder src={image} alt="" className="absolute inset-0 h-full w-full object-cover" /> - <div className="absolute inset-0 bg-gradient-to-t from-black/40 to-black/10" /> - <div className="absolute inset-x-0 bottom-0"> - <Container className="px-4 sm:px-6"> - <GlassBox frostSrc={image} className="relative mb-6 inline-block max-w-lg px-8 py-7"> - <p className="section-kicker">{kicker}</p> - <h1 className="mt-2 text-3xl font-extrabold sm:text-5xl">{title}</h1> - </GlassBox> - </Container> - </div> + <section className="relative h-[160px] overflow-hidden bg-ink sm:h-[200px]"> + <Placeholder src={image} alt={title} className="absolute inset-0 h-full w-full object-cover" /> + <div className="absolute inset-0 bg-ink/75" /> + <Container className="relative flex h-full items-center px-4 sm:px-6"> + <div className="max-w-xl rounded-2xl px-6 py-4 sm:px-8 sm:py-5" style={glossyBar}> + <p className="font-latin text-[10px] font-bold tracking-[0.32em] text-white/60 uppercase">{kicker}</p> + <h1 className="mt-1.5 line-clamp-2 text-[22px] font-extrabold leading-snug text-white sm:text-[28px]">{title}</h1> + </div> + </Container> </section> ); } diff --git a/components/product/ProductGallery.tsx b/components/product/ProductGallery.tsx index 210218c..b1d644b 100644 --- a/components/product/ProductGallery.tsx +++ b/components/product/ProductGallery.tsx @@ -17,7 +17,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string <div> <button type="button" - className="relative block w-full overflow-hidden rounded-2xl bg-paper" + className="relative block aspect-square w-full overflow-hidden rounded-2xl bg-paper" onClick={() => setLightbox(true)} onMouseEnter={() => setZoomed(true)} onMouseLeave={() => { @@ -35,10 +35,10 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string src={current} fallbackSrc="/placeholders/product.svg" alt={alt} - className="aspect-square w-full object-contain transition-transform duration-150" + className="absolute inset-0 size-full object-cover transition-transform duration-150" /> <span - className="pointer-events-none absolute inset-0 bg-contain bg-center bg-no-repeat" + className="pointer-events-none absolute inset-0 bg-cover bg-center bg-no-repeat" style={{ backgroundImage: `url(${current})`, transform: zoomed ? "scale(1.85)" : "scale(1)", @@ -56,7 +56,7 @@ export function ProductGallery({ images, alt }: { images: string[]; alt: string onClick={() => setActive(index)} className={`size-16 shrink-0 overflow-hidden rounded-xl border ${index === active ? "border-brand" : "border-line"}`} > - <Placeholder src={image} className="size-full object-contain" /> + <Placeholder src={image} alt="" className="size-full object-cover" /> </button> ))} </div> diff --git a/components/ui/ProductCard.tsx b/components/ui/ProductCard.tsx index 643e8b6..d870722 100644 --- a/components/ui/ProductCard.tsx +++ b/components/ui/ProductCard.tsx @@ -9,7 +9,7 @@ export function ProductCard({ product }: { product: Product }) { return ( <article className="group flex h-full flex-col rounded-2xl border border-line bg-white p-3"> - <Link href={href} className="relative overflow-hidden rounded-xl bg-paper"> + <Link href={href} className="relative block aspect-square overflow-hidden rounded-xl bg-paper"> {product.discount ? ( <span className="absolute top-2 right-2 z-10 rounded-md bg-brand px-1.5 py-0.5 text-[11px] font-bold text-white"> {faDigits(product.discount)}٪ @@ -18,7 +18,8 @@ export function ProductCard({ product }: { product: Product }) { <Placeholder src={product.image} fallbackSrc="/placeholders/product.svg" - className="aspect-square w-full object-contain transition group-hover:scale-[1.03]" + alt={product.title} + className="absolute inset-0 size-full object-cover transition group-hover:scale-[1.03]" /> </Link> {product.brand ? ( diff --git a/lib/sitemap-config.generated.json b/lib/sitemap-config.generated.json new file mode 100644 index 0000000..04185cf --- /dev/null +++ b/lib/sitemap-config.generated.json @@ -0,0 +1,28 @@ +{ + "baseUrl": "https://brb-store.com", + "staticPages": [ + { + "path": "/", + "changefreq": "daily", + "priority": 1 + }, + { + "path": "/about", + "priority": 0.6 + }, + { + "path": "/blog", + "changefreq": "weekly", + "priority": 0.8 + }, + { + "path": "/contact", + "priority": 0.5 + }, + { + "path": "/products", + "changefreq": "daily", + "priority": 0.9 + } + ] +} diff --git a/lib/static-images.ts b/lib/static-images.ts index 77e5e05..9793dc5 100644 --- a/lib/static-images.ts +++ b/lib/static-images.ts @@ -104,7 +104,7 @@ export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [ label: "Full-row contact banner", kind: "single", pageKey: "home", - aspectRatio: "32:9", + aspectRatio: "64:9", itemCount: 1, recommendedWidth: 1920, }, diff --git a/lib/store.ts b/lib/store.ts index 6774501..5592636 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -30,6 +30,8 @@ export type StoreSpecialProduct = { productTitle?: string | null; productNameFa?: string | null; productImage?: string | null; + isActive?: boolean; + sortOrder?: number | null; variants?: StoreVariant[]; }; @@ -48,6 +50,12 @@ export type CatalogProduct = { categoryNameFa?: string | null; categoryName?: string | null; commentCount?: number; + store?: { + variantCount?: number | null; + minPrice?: number | null; + maxPrice?: number | null; + inStock?: boolean | null; + } | null; }; export type Paged<T> = { @@ -97,16 +105,37 @@ export function variantToProduct(variant: StoreVariant): Product { }; } -export function specialItemToProduct(item: StoreSpecialProduct): Product | null { +export function specialItemToProduct(item: StoreSpecialProduct, slug?: string | null): Product | null { + if (item.isActive === false) return null; + const variant = item.variants?.[0]; - if (!variant) return null; - return variantToProduct({ - ...variant, - productId: item.productId ?? variant.productId, - productTitle: item.productTitle ?? variant.productTitle, - productNameFa: item.productNameFa ?? variant.productNameFa, - productImage: item.productImage ?? variant.productImage, - }); + if (variant) { + return variantToProduct({ + ...variant, + productId: item.productId ?? variant.productId, + productTitle: item.productTitle ?? variant.productTitle, + productNameFa: item.productNameFa ?? variant.productNameFa, + productImage: item.productImage ?? variant.productImage, + }); + } + + // Product-source specials often ship with empty variants[]. + const title = item.productNameFa?.trim() || item.productTitle?.trim(); + 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, + title: title || "محصول", + brand: "", + image: item.productImage || PLACEHOLDER, + inStock: true, + }; } export async function fetchStoreItems(params: { @@ -144,14 +173,33 @@ export async function fetchStoreItemByProduct(productId: string) { } export async function fetchStoreSpecials() { - const data = await meshkeeGet<{ items?: StoreSpecial[] }>(tenantPath("/store-specials"), { items: [] }); + const data = await meshkeeGet<{ items?: StoreSpecial[]; source?: string }>(tenantPath("/store-specials"), { + items: [], + }); return data.items ?? []; } export async function fetchSpecialProducts(key: string) { const specials = await fetchStoreSpecials(); const special = specials.find((item) => item.key === key); - const products = (special?.items ?? []).map(specialItemToProduct).filter((item): item is Product => Boolean(item)); + const rawItems = [...(special?.items ?? [])] + .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)); + return { special, products }; } @@ -167,22 +215,59 @@ export function galleryUrls(product?: CatalogProduct | null, fallback?: string | return urls.length ? urls : [PLACEHOLDER]; } +export async function fetchCatalogProducts(params: { + page?: number; + pageSize?: number; + categoryId?: string; + name?: string; +} = {}) { + const query = new URLSearchParams(); + query.set("page", String(params.page ?? 1)); + query.set("pageSize", String(params.pageSize ?? 20)); + if (params.categoryId) query.set("categoryId", params.categoryId); + if (params.name) query.set("name", params.name); + return meshkeeGet<Paged<CatalogProduct>>(tenantPath(`/products?${query.toString()}`), { + items: [], + total: 0, + page: 1, + pageSize: params.pageSize ?? 20, + }); +} + +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() || "محصول", + brand: brandName(product) || product.categoryNameFa || "", + price: price ?? undefined, + image: product.thumbnailUrl || product.image || product.thumbnail || PLACEHOLDER, + inStock: product.store?.inStock !== false, + }; +} + export async function fetchCatalogProductByTitle(title?: string | null, productId?: string | null) { - if (!title?.trim()) return null; + if (!title?.trim() && !productId) return null; + const query = title?.trim() || String(productId); const data = await meshkeeGet<Paged<CatalogProduct>>( - tenantPath(`/products?name=${encodeURIComponent(title.trim())}&pageSize=8`), - { items: [], total: 0, page: 1, pageSize: 8 }, + tenantPath(`/products?name=${encodeURIComponent(query)}&pageSize=12`), + { items: [], total: 0, page: 1, pageSize: 12 }, ); const match = data.items.find((item) => String(item.id) === String(productId)) ?? data.items[0] ?? null; if (!match?.slug) return match; - const detail = await meshkeeGet<{ product?: CatalogProduct }>( - tenantPath(`/products/${encodeURIComponent(match.slug)}`), + return fetchCatalogProductBySlug(match.slug); +} + +export async function fetchCatalogProductBySlug(slug: string) { + const data = await meshkeeGet<{ product?: CatalogProduct }>( + tenantPath(`/products/${encodeURIComponent(slug)}`), {}, ); - return detail.product ?? match; + return data.product ?? null; } export function brandName(product?: CatalogProduct | null) { diff --git a/package.json b/package.json index e96e984..f1d6ea7 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,12 @@ "version": "0.1.0", "private": true, "scripts": { + "prebuild": "node scripts/generate-sitemap-config.mjs", "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "generate:sitemap-config": "node scripts/generate-sitemap-config.mjs" }, "dependencies": { "lucide-react": "^0.539.0", diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..59d742b --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -0,0 +1,94 @@ +/** + * 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 }, + "/about": { priority: 0.6 }, + "/contact": { priority: 0.5 }, +}; + +function websiteDomain() { + return ( + process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || + process.env.WEBSITE_DOMAIN || + process.env.NEXT_PUBLIC_MESHKEE_DOMAIN || + "brb-store.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)) { + // Skip dynamic segments and route groups for staticPages; groups recurse without path. + 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();