diff --git a/.gitignore b/.gitignore index 5ef6a52..bca18bd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # dependencies /node_modules +/ai-data +/psd /.pnp .pnp.* .yarn/* diff --git a/connect-backend-prompt.txt b/connect-backend-prompt.txt deleted file mode 100644 index e9d0997..0000000 --- a/connect-backend-prompt.txt +++ /dev/null @@ -1,15 +0,0 @@ -Domain: simode.shop -Documentation for backend is:https://api.meshkee.com/docs/website - - -Login or user icon should go to customer.simode.shop - -Create product page and details >> connect to store items -Create blog page and details >> connect to blog - -Product Menu should be a mega menu with level 2 and 3 of categories - skip level 1 - -Search input should be instant search - activate when type more than 2 char - -First carousel >> special-discount -Second carousel >> top-selling diff --git a/images-slot-prompt.txt b/images-slot-prompt.txt deleted file mode 100644 index ea51cf6..0000000 --- a/images-slot-prompt.txt +++ /dev/null @@ -1,13 +0,0 @@ -Wire this page to simode.shop static-image slots. - -Read http://meshkee.com/docs/website (static-images). Env is already set. - -Fetch slots, match by key: -- slider > duplicatable -- two-banner-row > two slot -- one-banner-row → one slot -- aboutus-row → one slot - -Use slot.images (all of them if kind=list, first if kind=single). -url → . linkUrl → wrap in . Empty or fail → keep current placeholder. -Use slot.aspectRatio from the API. Do not invent upload APIs. \ No newline at end of file diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[id]/[slug]/page.tsx similarity index 89% rename from src/app/blog/[slug]/page.tsx rename to src/app/blog/[id]/[slug]/page.tsx index 223c840..d57663c 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[id]/[slug]/page.tsx @@ -1,17 +1,17 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { getBlog } from "@/lib/api"; +import { getBlogById } from "@/lib/api"; export default async function BlogDetailPage({ params, }: { - params: Promise<{ slug: string }>; + params: Promise<{ id: string; slug: string }>; }) { - const { slug } = await params; + const { id } = await params; let blog; try { - ({ blog } = await getBlog(slug)); + ({ blog } = await getBlogById(id)); } catch { notFound(); } diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index b613268..0ea279c 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { getBlogs, safe } from "@/lib/api"; +import { blogHref } from "@/lib/slug"; import Pagination from "@/components/ui/Pagination"; const PAGE_SIZE = 12; @@ -46,7 +47,7 @@ export default async function BlogPage({ return ( [p.id, p])); const specialDiscount = specials.find((s) => s.key === "special-discount" && s.isActive); @@ -82,7 +78,7 @@ export default async function Home() {
- + diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[id]/[slug]/page.tsx similarity index 80% rename from src/app/products/[slug]/page.tsx rename to src/app/products/[id]/[slug]/page.tsx index 9156691..84fa63e 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[id]/[slug]/page.tsx @@ -2,11 +2,12 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { getComments, - getProduct, + getProductById, getProductTechnicalInfo, getStoreItemsByProduct, safe, } from "@/lib/api"; +import { categoryHref } from "@/lib/slug"; import ProductGallery from "@/components/ProductGallery"; import ProductBuyPanel from "@/components/ProductBuyPanel"; import ProductInfoTabs from "@/components/ProductInfoTabs"; @@ -16,13 +17,13 @@ import SectionHeading from "@/components/ui/SectionHeading"; export default async function ProductDetailPage({ params, }: { - params: Promise<{ slug: string }>; + params: Promise<{ id: string; slug: string }>; }) { - const { slug } = await params; + const { id } = await params; let data; try { - data = await getProduct(slug); + data = await getProductById(id); } catch { notFound(); } @@ -31,7 +32,7 @@ export default async function ProductDetailPage({ const [{ items: storeItems }, technicalInfo, { items: comments }] = await Promise.all([ safe(getStoreItemsByProduct(product.id), { items: [] }), - safe(getProductTechnicalInfo(slug), { form: null, values: [] }), + safe(getProductTechnicalInfo(id), { form: null, values: [] }), safe(getComments("product", product.id), { items: [] }), ]); @@ -48,10 +49,15 @@ export default async function ProductDetailPage({ محصولات - {product.categoryNameFa && ( + {product.categoryId && product.categoryNameFa && ( <> / - {product.categoryNameFa} + + {product.categoryNameFa} + )} diff --git a/src/app/products/category/[id]/[slug]/page.tsx b/src/app/products/category/[id]/[slug]/page.tsx new file mode 100644 index 0000000..4d979fb --- /dev/null +++ b/src/app/products/category/[id]/[slug]/page.tsx @@ -0,0 +1,93 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getCategories, getCategoryById, getProducts, safe } from "@/lib/api"; +import { getMegaMenuColumns } from "@/lib/categoryTree"; +import { categoryHref } from "@/lib/slug"; +import type { ApiCategory } from "@/lib/types"; +import ProductCard from "@/components/ui/ProductCard"; +import Pagination from "@/components/ui/Pagination"; + +const PAGE_SIZE = 12; + +export default async function CategoryProductsPage({ + params, + searchParams, +}: { + params: Promise<{ id: string; slug: string }>; + searchParams: Promise<{ page?: string }>; +}) { + const { id } = await params; + const sp = await searchParams; + const page = Math.max(1, Number(sp.page) || 1); + + let category: ApiCategory; + try { + category = await getCategoryById(id); + } catch { + notFound(); + } + + const [categoriesFlat, result] = await Promise.all([ + safe(getCategories(), []), + safe(getProducts({ page, pageSize: PAGE_SIZE, categoryId: id }), { + items: [], + total: 0, + page, + pageSize: PAGE_SIZE, + }), + ]); + + const filterItems = getMegaMenuColumns(categoriesFlat).flatMap((col) => col.children); + const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE)); + + function buildHref(targetPage: number) { + return `${categoryHref(category)}${targetPage > 1 ? `?page=${targetPage}` : ""}`; + } + + return ( +
+
+ + Products + +

{category.nameFa}

+
+ +
+ + همه + + {filterItems.map((cat) => ( + + {cat.nameFa} + + ))} +
+ + {result.items.length === 0 ? ( +

محصولی یافت نشد.

+ ) : ( + <> +
+ {result.items.map((product) => ( + + ))} +
+ + + + )} +
+ ); +} diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index 41f863d..68e15c4 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { getCategories, getProducts, safe } from "@/lib/api"; import { getMegaMenuColumns } from "@/lib/categoryTree"; +import { categoryHref } from "@/lib/slug"; import ProductCard from "@/components/ui/ProductCard"; import Pagination from "@/components/ui/Pagination"; @@ -13,32 +14,26 @@ export const metadata = { export default async function ProductsPage({ searchParams, }: { - searchParams: Promise<{ category?: string; page?: string }>; + searchParams: Promise<{ page?: string }>; }) { const params = await searchParams; const page = Math.max(1, Number(params.page) || 1); - const categoriesFlat = await safe(getCategories(), []); - const categoryColumns = getMegaMenuColumns(categoriesFlat); - const filterItems = categoryColumns.flatMap((col) => col.children); - - const activeCategory = params.category - ? categoriesFlat.find((c) => c.slug === params.category) - : undefined; - - const result = await safe( - getProducts({ page, pageSize: PAGE_SIZE, categoryId: activeCategory?.id }), - { items: [], total: 0, page, pageSize: PAGE_SIZE } - ); + const [categoriesFlat, result] = await Promise.all([ + safe(getCategories(), []), + safe(getProducts({ page, pageSize: PAGE_SIZE }), { + items: [], + total: 0, + page, + pageSize: PAGE_SIZE, + }), + ]); + const filterItems = getMegaMenuColumns(categoriesFlat).flatMap((col) => col.children); const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE)); function buildHref(targetPage: number) { - const qs = new URLSearchParams(); - if (params.category) qs.set("category", params.category); - if (targetPage > 1) qs.set("page", String(targetPage)); - const query = qs.toString(); - return `/products${query ? `?${query}` : ""}`; + return `/products${targetPage > 1 ? `?page=${targetPage}` : ""}`; } return ( @@ -47,31 +42,21 @@ export default async function ProductsPage({ Products -

- {activeCategory ? activeCategory.nameFa : "همه محصولات"} -

+

همه محصولات

همه {filterItems.map((cat) => ( {cat.nameFa} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..0f7f12e --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,12 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL } from "@/lib/config"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/src/app/sitemap-main.xml/route.ts b/src/app/sitemap-main.xml/route.ts new file mode 100644 index 0000000..1826387 --- /dev/null +++ b/src/app/sitemap-main.xml/route.ts @@ -0,0 +1,37 @@ +import { SITE_URL } from "@/lib/config"; +import { getBlogs, getCategories, safe } from "@/lib/api"; +import { getMegaMenuColumns } from "@/lib/categoryTree"; +import { blogHref, categoryHref } from "@/lib/slug"; + +function urlTag(loc: string) { + return ` ${encodeURI(loc)}`; +} + +export async function GET() { + const [categoriesFlat, blogs] = await Promise.all([ + safe(getCategories(), []), + safe(getBlogs({ pageSize: 200 }), { items: [], total: 0, page: 1, pageSize: 200 }), + ]); + + // Both the level-2 group pages and their level-3 children get their own + // category URL, same as the header mega-menu. + const categoryNodes = getMegaMenuColumns(categoriesFlat).flatMap((col) => [col, ...col.children]); + + const staticPaths = ["/", "/products", "/blog"]; + + const urls = [ + ...staticPaths.map((path) => `${SITE_URL}${path}`), + ...categoryNodes.map((cat) => `${SITE_URL}${categoryHref(cat)}`), + ...blogs.items.map((post) => `${SITE_URL}${blogHref(post)}`), + ]; + + const body = ` + +${urls.map(urlTag).join("\n")} + +`; + + return new Response(body, { + headers: { "Content-Type": "application/xml" }, + }); +} diff --git a/src/app/sitemap-products.xml/route.ts b/src/app/sitemap-products.xml/route.ts new file mode 100644 index 0000000..2cadb1d --- /dev/null +++ b/src/app/sitemap-products.xml/route.ts @@ -0,0 +1,24 @@ +import { SITE_URL } from "@/lib/config"; +import { getProducts, safe } from "@/lib/api"; +import { productHref } from "@/lib/slug"; + +export async function GET() { + const result = await safe(getProducts({ pageSize: 1000 }), { + items: [], + total: 0, + page: 1, + pageSize: 1000, + }); + + const urls = result.items.map((p) => `${SITE_URL}${productHref(p)}`); + + const body = ` + +${urls.map((loc) => ` ${encodeURI(loc)}`).join("\n")} + +`; + + return new Response(body, { + headers: { "Content-Type": "application/xml" }, + }); +} diff --git a/src/app/sitemap.xml/route.ts b/src/app/sitemap.xml/route.ts new file mode 100644 index 0000000..c792e08 --- /dev/null +++ b/src/app/sitemap.xml/route.ts @@ -0,0 +1,14 @@ +import { SITE_URL } from "@/lib/config"; + +export async function GET() { + const body = ` + + ${SITE_URL}/sitemap-main.xml + ${SITE_URL}/sitemap-products.xml + +`; + + return new Response(body, { + headers: { "Content-Type": "application/xml" }, + }); +} diff --git a/src/components/BlogCarousel.tsx b/src/components/BlogCarousel.tsx index 8cbaa97..afa1b5d 100644 --- a/src/components/BlogCarousel.tsx +++ b/src/components/BlogCarousel.tsx @@ -4,6 +4,7 @@ import { Swiper, SwiperSlide } from "swiper/react"; import Link from "next/link"; import "swiper/css"; import type { ApiBlog } from "@/lib/types"; +import { blogHref } from "@/lib/slug"; import SectionHeading from "./ui/SectionHeading"; export default function BlogCarousel({ posts }: { posts: ApiBlog[] }) { @@ -27,7 +28,7 @@ export default function BlogCarousel({ posts }: { posts: ApiBlog[] }) { return ( col.children); - if (items.length === 0) return null; - - return ( -
-
- - -
- - {items.map((cat, i) => ( - - -
- {cat.nameFa} -
- - {cat.nameFa} - - -
- ))} -
-
-
-
- ); -} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 9ec9394..97852db 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import Logo from "./Logo"; import { headerNavLinks, siteInfo } from "@/data/site"; +import { categoryHref } from "@/lib/slug"; import type { CategoryNode } from "@/lib/types"; import { InstagramIcon, @@ -66,7 +67,7 @@ export default function Footer({ categoryColumns }: { categoryColumns: CategoryN
    {categoryItems.map((cat) => (
  • - + {cat.nameFa}
  • diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e54b3ed..6a35fd2 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -15,6 +15,7 @@ import { } from "./icons"; import { headerNavLinks } from "@/data/site"; import { CUSTOMER_PORTAL_URL } from "@/lib/config"; +import { categoryHref } from "@/lib/slug"; import type { CategoryNode } from "@/lib/types"; const socialLinks = [ @@ -91,7 +92,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN {categoryColumns.map((col) => (
    {col.nameFa} @@ -100,7 +101,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN {col.children.map((sub) => (
  • {sub.nameFa} @@ -182,7 +183,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN {categoryColumns.map((col) => (
    setMobileOpen(false)} > @@ -192,7 +193,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN {col.children.map((sub) => (
  • setMobileOpen(false)} > diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx index c65fa30..da33df3 100644 --- a/src/components/SearchBox.tsx +++ b/src/components/SearchBox.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { getProducts } from "@/lib/api"; import type { ApiProduct } from "@/lib/types"; import { formatToman } from "@/lib/format"; +import { productHref } from "@/lib/slug"; import { SearchIcon } from "./icons"; const MIN_QUERY_LENGTH = 3; @@ -92,7 +93,7 @@ export default function SearchBox({ results.map((product) => ( setOpen(false)} className="flex items-center gap-3 p-3 hover:bg-surface transition-colors border-b border-line last:border-0" > diff --git a/src/components/ThreeBanners.tsx b/src/components/ThreeBanners.tsx new file mode 100644 index 0000000..1aff319 --- /dev/null +++ b/src/components/ThreeBanners.tsx @@ -0,0 +1,48 @@ +import type { StaticImageSlot } from "@/lib/types"; +import { toCssAspectRatio } from "@/lib/format"; +import SectionHeading from "./ui/SectionHeading"; + +const fallbackBanners = [ + { image: "/images/categories/girls.svg", href: "#" }, + { image: "/images/categories/boys.svg", href: "#" }, + { image: "/images/categories/baby.svg", href: "#" }, +]; + +export default function ThreeBanners({ slot }: { slot?: StaticImageSlot }) { + const images = slot?.images ?? []; + const cssAspectRatio = toCssAspectRatio(slot?.aspectRatio); + const aspectStyle = cssAspectRatio ? { aspectRatio: cssAspectRatio } : undefined; + + const banners = + images.length > 0 + ? images.map((img) => ({ image: img.url, href: img.linkUrl || undefined })) + : fallbackBanners; + + return ( +
    + + +
    + {banners.map((b, i) => { + const Wrapper = b.href ? "a" : "div"; + return ( + + + + ); + })} +
    +
    + ); +} diff --git a/src/components/ui/ProductCard.tsx b/src/components/ui/ProductCard.tsx index c462d14..c956471 100644 --- a/src/components/ui/ProductCard.tsx +++ b/src/components/ui/ProductCard.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import type { ApiProduct, ProductDiscount } from "@/lib/types"; import { formatToman } from "@/lib/format"; +import { productHref } from "@/lib/slug"; import { CheckIcon, PlusIcon } from "../icons"; export default function ProductCard({ @@ -29,7 +30,7 @@ export default function ProductCard({ return (
    diff --git a/src/lib/api.ts b/src/lib/api.ts index 1209b39..843c15b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -63,16 +63,18 @@ export function getProducts( return apiGet>(`/tenants/${SITE_DOMAIN}/products`, params); } -export function getProduct( - slug: string +// id-based lookups — public URLs are id-driven (see src/lib/slug.ts), the +// DB slug (e.g. "...-old-28353") never appears in a route. +export function getProductById( + id: string ): Promise<{ product: ApiProduct; relatedProducts: ApiProduct[] }> { - return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}`); + return apiGet(`/tenants/${SITE_DOMAIN}/products/by-id/${id}`); } export function getProductVariations( - slug: string + productId: string ): Promise<{ variations: ProductVariation[] }> { - return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}/variations`); + return apiGet(`/tenants/${SITE_DOMAIN}/products/by-id/${productId}/variations`); } export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> { @@ -80,9 +82,15 @@ export function getStoreItemsByProduct(productId: string): Promise<{ items: Stor } export function getProductTechnicalInfo( - slug: string + productId: string ): Promise<{ form: unknown; values: TechnicalInfoValue[] }> { - return apiGet(`/tenants/${SITE_DOMAIN}/products/${slug}/technical-info`); + return apiGet(`/tenants/${SITE_DOMAIN}/products/by-id/${productId}/technical-info`); +} + +export function getCategoryById(id: string): Promise { + return apiGet<{ category: ApiCategory }>(`/tenants/${SITE_DOMAIN}/categories/by-id/${id}`).then( + (r) => r.category + ); } export function getComments( @@ -98,8 +106,8 @@ export function getBlogs( return apiGet>(`/tenants/${SITE_DOMAIN}/blogs`, params); } -export function getBlog(slug: string): Promise<{ blog: ApiBlog }> { - return apiGet(`/tenants/${SITE_DOMAIN}/blogs/${slug}`); +export function getBlogById(id: string): Promise<{ blog: ApiBlog }> { + return apiGet(`/tenants/${SITE_DOMAIN}/blogs/by-id/${id}`); } export function getStaticImageSlots(pageKey?: string): Promise { diff --git a/src/lib/config.ts b/src/lib/config.ts index 6b23f58..e258613 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,3 +1,4 @@ export const SITE_DOMAIN = process.env.NEXT_PUBLIC_SITE_DOMAIN || "simode.shop"; export const API_BASE = "https://api.meshkee.com/api/v1"; export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`; +export const SITE_URL = `https://${SITE_DOMAIN}`; diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 0000000..033f99e --- /dev/null +++ b/src/lib/slug.ts @@ -0,0 +1,25 @@ +import type { ApiBlog, ApiProduct } from "./types"; + +// Per SEO spec: keep Farsi letters, turn spaces into "-", strip everything +// else. Deliberately does not transliterate to Latin — Persian slugs rank +// better for Persian-language search. +export function slugifyFa(input: string): string { + return input + .replace(/[^؀-ۿ0-9a-zA-Z\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +// Canonical public URLs — id-driven, DB slugs never appear here. +export function productHref(product: Pick): string { + return `/products/${product.id}/${slugifyFa(product.nameFa || product.title)}`; +} + +export function categoryHref(category: { id: string; nameFa: string }): string { + return `/products/category/${category.id}/${slugifyFa(category.nameFa)}`; +} + +export function blogHref(blog: Pick): string { + return `/blog/${blog.id}/${slugifyFa(blog.title || blog.nameFa || "")}`; +}