From 11fd1cf4543f090b5f36d621f6ed89bdd0e80e55 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Thu, 3 Sep 2026 09:31:15 +0330 Subject: [PATCH] Connect Meshkee catalog, customer login, and SEO sitemap config. Wire products and articles to the public API, send guests to the customer portal, and publish sitemap-config for Meshkee nginx. Co-authored-by: Cursor --- CONTEXT.md | 107 ++++-- next.config.ts | 7 + package.json | 4 +- scripts/generate-sitemap-config.mjs | 101 ++++++ src/app/aboutus/page.tsx | 12 +- src/app/api/auth/handoff/route.ts | 17 + src/app/api/auth/me/route.ts | 10 + src/app/api/comments/route.ts | 29 ++ src/app/articles/[id]/[slug]/page.tsx | 113 +++++++ src/app/articles/page.tsx | 49 ++- src/app/contactus/page.tsx | 15 +- src/app/globals.css | 107 ++++++ src/app/layout.tsx | 45 ++- src/app/meshkee/sitemap-config.json/route.ts | 17 + src/app/not-found.tsx | 24 ++ src/app/page.tsx | 12 +- src/app/products/[id]/[slug]/page.tsx | 122 +++++++ src/app/products/page.tsx | 50 ++- src/components/AboutPreview.tsx | 12 +- src/components/AccountMenu.tsx | 136 ++++++++ src/components/CommentsBox.tsx | 98 ++++++ src/components/Footer.tsx | 2 +- src/components/Header.tsx | 17 +- src/components/PageHeader.tsx | 9 +- src/components/Pagination.tsx | 35 ++ src/components/PostCard.tsx | 38 +++ src/components/ProductCard.tsx | 37 ++ src/components/ProductGallery.tsx | 105 ++++++ src/components/ProductTabs.tsx | 77 +++++ src/components/PromoBanner.tsx | 4 +- src/components/Providers.tsx | 13 + src/components/SohanFeature.tsx | 2 +- src/components/WhyUs.tsx | 6 +- src/lib/config.ts | 14 + src/lib/format.ts | 5 + src/lib/meshkee-server.ts | 42 +++ src/lib/meshkee.ts | 338 +++++++++++++++++++ src/lib/seo.ts | 67 ++++ src/lib/session.tsx | 188 +++++++++++ src/lib/shared-auth-cookie.ts | 121 +++++++ src/lib/site.ts | 3 +- src/lib/sitemap-config.generated.json | 33 ++ src/lib/slugify.ts | 15 + src/lib/urls.ts | 19 ++ 44 files changed, 2159 insertions(+), 118 deletions(-) create mode 100644 scripts/generate-sitemap-config.mjs create mode 100644 src/app/api/auth/handoff/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/comments/route.ts create mode 100644 src/app/articles/[id]/[slug]/page.tsx create mode 100644 src/app/meshkee/sitemap-config.json/route.ts create mode 100644 src/app/not-found.tsx create mode 100644 src/app/products/[id]/[slug]/page.tsx create mode 100644 src/components/AccountMenu.tsx create mode 100644 src/components/CommentsBox.tsx create mode 100644 src/components/Pagination.tsx create mode 100644 src/components/PostCard.tsx create mode 100644 src/components/ProductCard.tsx create mode 100644 src/components/ProductGallery.tsx create mode 100644 src/components/ProductTabs.tsx create mode 100644 src/components/Providers.tsx create mode 100644 src/lib/config.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/meshkee-server.ts create mode 100644 src/lib/meshkee.ts create mode 100644 src/lib/seo.ts create mode 100644 src/lib/session.tsx create mode 100644 src/lib/shared-auth-cookie.ts create mode 100644 src/lib/sitemap-config.generated.json create mode 100644 src/lib/slugify.ts create mode 100644 src/lib/urls.ts diff --git a/CONTEXT.md b/CONTEXT.md index 5a6c105..7c541b0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,50 +2,85 @@ ## Project -- Persian RTL storefront clone of `sohansalehnia.com` (سوهان صالح نیا, traditional Qom sohan sweets). +- Persian RTL storefront clone of `sohansalehnia.com` (سوهان صالح نیا). - Next.js 16 App Router, React 19, TypeScript, Tailwind 4, `src/app`. - Language: `fa`, direction: `rtl`. -- Local images live in `public/images`. Fonts (Iran Yekan, Montserrat ExtraLight, Fontello/WMi icons) live in `public/fonts`. +- Local images: `public/images`. Fonts: `public/fonts`. -## Scope (this clone) +## Meshkee API -Implemented pages: +- Docs: `https://api.meshkee.com/docs/website` +- API base: `https://api.meshkee.com/api/v1` +- Tenant domain: `sohansalehnia.com` (`TENANT_DOMAIN` in `src/lib/meshkee.ts`, `src/lib/config.ts`) +- All public fetches go through `src/lib/meshkee.ts`. Authenticated calls go through `src/app/api/*` + `src/lib/meshkee-server.ts`. +- Tenant id is `32` (`Salehnia Sohan` / `salehnia-sohan`). Enabled modules: products, blog, finance. -- `/` homepage -- `/aboutus` about -- `/contactus` contact (form is client-side only; no backend submit yet) -- `/products` and `/articles` are placeholders. Products, workshops, and blog will be read from Meshkee later. +## Auth / account + +- Guest login/user icon → `https://customer.sohansalehnia.com` +- Logged-in header shows `{name} عزیز` with dropdown: پروفایل من, خروج, and مدیریت وبسایت if admin. +- Profile: `https://customer.sohansalehnia.com/profile` +- Admin dashboard: `https://business.sohansalehnia.com` via `/api/auth/handoff` +- Session: `src/lib/session.tsx` + shared cookies in `src/lib/shared-auth-cookie.ts` +- Favicon: `GET /tenants/sohansalehnia.com/website/favicon`. If `faviconUrl` is empty, fall back to `/images/logo.png`. + +## Routes + +| Path | Source | +|---|---| +| `/` | static homepage clone | +| `/aboutus` | static about | +| `/contactus` | static contact (form still local) | +| `/products` | `GET /tenants/{domain}/products` | +| `/products/[id]/[slug]` | product by id + technical-info + comments + similar | +| `/articles` | `GET /tenants/{domain}/blogs` | +| `/articles/[id]/[slug]` | blog by id + comments | + +Product detail: lightbox gallery, hover zoom, tabs (توضیحات / اطلاعات فنی / دیدگاه‌ها). Comments require login; guests see a login box. + +## SEO + sitemap + +- Do **not** add `public/sitemap.xml` or `public/robots.txt` (nginx proxies both to Meshkee API). +- Live sitemap is an index: `/sitemap.xml` → `/sitemap-main.xml` + `/sitemap-products.xml`. +- Static pages: `GET /meshkee/sitemap-config.json` (generated at build from app routes). +- Generator: `scripts/generate-sitemap-config.mjs` → `src/lib/sitemap-config.generated.json` +- Run manually: `npm run generate:sitemap-config` +- Excludes `api`, `meshkee`, `admin`, `auth`, `login`, `checkout`, `cart`, `account` +- Blog / product details are **not** listed in sitemap-config (Meshkee XML covers CMS URLs) +- After deploy: business dashboard → Website → Settings → Sync sitemap config (needed for static pages) + +### Canonical URL shapes + +Meshkee defaults — omit `templates` unless customized. This site customizes **blog** to `/articles`: + +- Product: `/products/{id}/{nameFaSlug}` → `GET /tenants/{domain}/products/by-id/{id}` +- Category: `/products/category/{id}/{nameFaSlug}` (not built; catalog empty) +- Blog: `/articles/{id}/{titleSlug}` → `GET /tenants/{domain}/blogs/by-id/{id}` (`templates.blog` in sitemap-config) +- Portfolio: not built +- Slugify (`src/lib/slugify.ts`): Farsi letters kept, spaces → `-`, strip other punctuation. Do not use legacy DB slug in public URLs. + +### On-page SEO + +- Helper: `src/lib/seo.ts` (`pageMetadata`, unique absolute ``, clipped meta description ~158 chars, canonical, OG) +- Homepage title is unique (`سوهان صالح نیا | تولید و عرضه سوهان سنتی قم`) and does not rely on the Next.js default +- Exactly one visible H1 per page (PageHeader wraps kicker + gold title; homepage H1 is the product-types row). Do not use `sr-only` H1. +- H2/H3 for real sections (feature block, why-us, similar/related, comments) +- Prefer CMS `nameFa` / `title` / `summary` / `descriptionHtml` for product and article metadata +- Content images have meaningful `alt` (CMS title when available). Decorative pattern uses `alt=""` +- `src/app/not-found.tsx`: unique title + one H1, `noindex` ## Brand -| Token | Value | Use | -|---|---|---| -| Primary | `#706C01` | links, buttons, product titles, branch details | -| Gold | `#868215` | section accents, counters, English labels | -| Page | `#F9F9F9` | page background, header, footer | -| Heading | `#4B4B4B` | titles | -| Muted | `#5E5E5E` | labels, footer copy | -| Icon gray | `#939393` | social icons | +| Token | Value | +|---|---| +| Primary | `#706C01` | +| Gold | `#868215` | +| Page | `#F9F9F9` | +| Heading | `#4B4B4B` | +| Muted | `#5E5E5E` | -Logo: `public/images/logo.png` (from the live Meshkee theme). Hero, banners, product icons, pattern, and sohan photos are downloaded from the original theme CDN. +## Notes -## Content source - -Copy, addresses, phones, map embed, and section structure are taken from the live Meshkee site. Do not invent replacement marketing copy unless asked. - -Branches: - -- شعبه یکم: ميدان جهاد،باجك اول،نبش كوي ٤٤ — `02537728181` -- شعبه دوم: قم،بلوار الغدیر،روبروی دانشگاه قم — `02537728181` - -Login still points to `https://app.sohansalehnia.com/auth/login`. - -## Footer credit - -Bottom row follows the SafeTeb Meshkee credit pattern (`Designed And Developed By / Meshkee E-Commerce Team` with animated frame + white Meshkee logo), recolored to the olive/gold theme. Component: `src/components/MeshkeeCredit.tsx`. - -## Next work - -- Wire `/products` and `/articles` (and any workshops) to Meshkee public API. -- Replace contact form local success state with the real endpoint. +- Products and blogs may be empty until published in Meshkee. +- Do not invent Meshkee endpoints. Product specs use `.../products/by-id/{id}/technical-info` (join `form.fields[].id` ↔ `values[].fieldId`). - Keep `CONTEXT.md` updated when routes, brand tokens, or data sources change. diff --git a/next.config.ts b/next.config.ts index 42f9bab..8e53f44 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,13 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { formats: ["image/avif", "image/webp"], + remotePatterns: [ + { protocol: "https", hostname: "c804387.parspack.net" }, + { protocol: "https", hostname: "*.parspack.net" }, + { protocol: "https", hostname: "*.parminstorage.ir" }, + { protocol: "https", hostname: "cdn.meshkee.com" }, + { protocol: "https", hostname: "api.meshkee.com" }, + ], }, }; diff --git a/package.json b/package.json index 109c3ab..8c41a47 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 --port 3001", "build": "next build", "start": "next start --port 3001", - "lint": "eslint" + "lint": "eslint", + "generate:sitemap-config": "node scripts/generate-sitemap-config.mjs" }, "dependencies": { "next": "16.3.0", diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..3885c47 --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -0,0 +1,101 @@ +/** + * Scans app page routes and writes src/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, "src", "app"); +const outFile = path.join(root, "src", "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: "weekly", priority: 0.9 }, + "/articles": { changefreq: "weekly", priority: 0.8 }, + "/aboutus": { changefreq: "monthly", priority: 0.6 }, + "/contactus": { changefreq: "monthly", priority: 0.5 }, +}; + +function websiteDomain() { + return ( + process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || + process.env.WEBSITE_DOMAIN || + "sohansalehnia.com" + ).replace(/^https?:\/\//, "").replace(/\/+$/, ""); +} + +function walkPages(dir, segments = []) { + /** @type {string[]} */ + const pages = []; + if (!fs.existsSync(dir)) return pages; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const full = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (EXCLUDE_SEGMENTS.has(entry.name)) continue; + if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) { + if (/^\(.+\)$/.test(entry.name)) { + pages.push(...walkPages(full, segments)); + } + continue; + } + pages.push(...walkPages(full, [...segments, entry.name])); + continue; + } + + if ( + entry.name === "page.tsx" || + entry.name === "page.ts" || + entry.name === "page.jsx" || + entry.name === "page.js" + ) { + const routePath = segments.length ? `/${segments.join("/")}` : "/"; + pages.push(routePath); + } + } + + return pages; +} + +function main() { + const paths = [...new Set(walkPages(appDir))].sort((a, b) => { + if (a === "/") return -1; + if (b === "/") return 1; + return a.localeCompare(b); + }); + + const staticPages = paths.map((routePath) => { + const meta = PRIORITY[routePath] ?? { changefreq: "monthly", priority: 0.5 }; + return { path: routePath, ...meta }; + }); + + const config = { + baseUrl: `https://${websiteDomain()}`, + staticPages, + // Blog list/details use /articles, not Meshkee's default /blog. + templates: { + blog: "/articles/{id}/{titleSlug}", + }, + }; + + 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(); diff --git a/src/app/aboutus/page.tsx b/src/app/aboutus/page.tsx index 76124d4..5eb067e 100644 --- a/src/app/aboutus/page.tsx +++ b/src/app/aboutus/page.tsx @@ -1,16 +1,18 @@ -import type { Metadata } from "next"; import AboutPreview from "@/components/AboutPreview"; import PageHeader from "@/components/PageHeader"; import PromoBanner from "@/components/PromoBanner"; import SohanFeature from "@/components/SohanFeature"; import Stats from "@/components/Stats"; import WhyUs from "@/components/WhyUs"; -import { ABOUT_PAGE, SITE } from "@/lib/site"; +import { pageMetadata } from "@/lib/seo"; +import { ABOUT, ABOUT_PAGE } from "@/lib/site"; -export const metadata: Metadata = { +export const metadata = pageMetadata({ title: "درباره ما", - description: SITE.description, -}; + description: ABOUT.body, + path: "/aboutus", + image: "/images/about.png", +}); export default function AboutPage() { return ( diff --git a/src/app/api/auth/handoff/route.ts b/src/app/api/auth/handoff/route.ts new file mode 100644 index 0000000..93365f5 --- /dev/null +++ b/src/app/api/auth/handoff/route.ts @@ -0,0 +1,17 @@ +import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server"; + +export async function POST(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/handoff", { + method: "POST", + token, + }); + + if (!res.ok) { + return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status); + } + + return jsonResponse(res.data); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..53435fb --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,10 @@ +import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server"; + +export async function GET(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/me", { token }); + if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status); + return jsonResponse(res.data); +} diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts new file mode 100644 index 0000000..97d60d3 --- /dev/null +++ b/src/app/api/comments/route.ts @@ -0,0 +1,29 @@ +import { TENANT_DOMAIN } from "@/lib/meshkee"; +import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const entityType = searchParams.get("entityType") || ""; + const entityId = searchParams.get("entityId") || ""; + if (!entityType || !entityId) { + return jsonResponse({ error: "entityType and entityId are required" }, 400); + } + + const res = await meshkeeFetch( + `/tenants/${TENANT_DOMAIN}/comments?entityType=${encodeURIComponent(entityType)}&entityId=${encodeURIComponent(entityId)}`, + ); + return jsonResponse(res.data, res.status); +} + +export async function POST(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const body = await request.json(); + const res = await meshkeeFetch(`/tenants/${TENANT_DOMAIN}/comments`, { + method: "POST", + token, + body: JSON.stringify(body), + }); + return jsonResponse(res.data, res.status); +} diff --git a/src/app/articles/[id]/[slug]/page.tsx b/src/app/articles/[id]/[slug]/page.tsx new file mode 100644 index 0000000..3a4117f --- /dev/null +++ b/src/app/articles/[id]/[slug]/page.tsx @@ -0,0 +1,113 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound, permanentRedirect } from "next/navigation"; +import CommentsBox from "@/components/CommentsBox"; +import Container from "@/components/Container"; +import PageHeader from "@/components/PageHeader"; +import PostCard from "@/components/PostCard"; +import { + getComments, + getPostById, + getPosts, + postContent, + postDate, + postExcerpt, + postImage, + postTitle, +} from "@/lib/meshkee"; +import { blogPath, blogSlug } from "@/lib/urls"; +import { pageMetadata } from "@/lib/seo"; + +export const revalidate = 300; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}): Promise<Metadata> { + const { id } = await params; + const post = await getPostById(id).catch(() => null); + if (!post) { + return pageMetadata({ + title: "مقاله یافت نشد", + description: "مقاله مورد نظر در سوهان صالح نیا پیدا نشد.", + path: `/articles/${id}`, + index: false, + }); + } + const title = postTitle(post); + return pageMetadata({ + title, + description: postExcerpt(post, 160) || title, + path: blogPath(post), + image: postImage(post), + }); +} + +export default async function ArticleDetailPage({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}) { + const { id, slug: slugParam } = await params; + const slug = decodeURIComponent(slugParam); + const post = await getPostById(id).catch(() => null); + if (!post) notFound(); + + const canonicalSlug = blogSlug(post); + if (slug !== canonicalSlug) { + permanentRedirect(blogPath(post)); + } + + const title = postTitle(post); + const image = postImage(post); + const content = postContent(post); + const [comments, related] = await Promise.all([ + getComments("blog", post.id), + post.categoryId + ? getPosts({ categoryId: post.categoryId, pageSize: 4 }) + .then((data) => data.items.filter((item) => item.id !== post.id).slice(0, 3)) + .catch(() => []) + : Promise.resolve([]), + ]); + + return ( + <> + <PageHeader kicker="مقالات" title={title} english="Articles" /> + <section className="bg-page py-16"> + <Container className="max-w-3xl"> + <Link href="/articles" className="text-sm text-primary"> + بازگشت به مقالات + </Link> + <p className="mt-4 text-sm text-muted">{postDate(post)}</p> + {image && ( + <div className="relative mt-6 aspect-[16/9] overflow-hidden rounded-[20px] bg-white"> + <Image src={image} alt={title} fill className="object-cover" sizes="768px" priority /> + </div> + )} + {content && ( + <div className="rich-content mt-8" dangerouslySetInnerHTML={{ __html: content }} /> + )} + <div className="mt-12"> + <h2 className="mb-6 text-[22px] text-heading">دیدگاه‌ها</h2> + <CommentsBox entityType="blog" entityId={post.id} initialComments={comments} /> + </div> + </Container> + </section> + + {related.length > 0 && ( + <section className="bg-page pb-16"> + <Container> + <h2 className="mb-8 text-[24px] text-heading">مقالات مرتبط</h2> + <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> + {related.map((item) => ( + <PostCard key={item.id} post={item} /> + ))} + </div> + </Container> + </section> + )} + </> + ); +} diff --git a/src/app/articles/page.tsx b/src/app/articles/page.tsx index 8cd60c6..c611864 100644 --- a/src/app/articles/page.tsx +++ b/src/app/articles/page.tsx @@ -1,20 +1,51 @@ -import type { Metadata } from "next"; import Container from "@/components/Container"; import PageHeader from "@/components/PageHeader"; +import Pagination from "@/components/Pagination"; +import PostCard from "@/components/PostCard"; +import { getPosts } from "@/lib/meshkee"; +import { pageMetadata } from "@/lib/seo"; +import { SITE } from "@/lib/site"; -export const metadata: Metadata = { +export const metadata = pageMetadata({ title: "مقالات", -}; + description: `مقالات ${SITE.description}`, + path: "/articles", +}); + +const PAGE_SIZE = 9; + +export default async function ArticlesPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const { page } = await searchParams; + const requestedPage = Math.max(1, Number(page) || 1); + const { items, total } = await getPosts({ + page: requestedPage, + pageSize: PAGE_SIZE, + }).catch(() => ({ items: [], total: 0, page: 1, pageSize: PAGE_SIZE })); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const currentPage = Math.min(requestedPage, totalPages); -export default function ArticlesPage() { return ( <> <PageHeader kicker="مقالات" title="سوهان صالح نیا" english="Articles" /> - <section className="bg-page py-20"> - <Container className="text-center"> - <p className="text-lg text-muted"> - مقالات به‌زودی از طریق بک‌اند مشکی بارگذاری می‌شود. - </p> + <section className="bg-page py-16"> + <Container> + {items.length > 0 ? ( + <> + <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> + {items.map((post) => ( + <PostCard key={post.id} post={post} /> + ))} + </div> + <Pagination currentPage={currentPage} totalPages={totalPages} basePath="/articles" /> + </> + ) : ( + <p className="py-10 text-center text-muted">به‌زودی مقالات جدیدی منتشر می‌شود.</p> + )} </Container> </section> </> diff --git a/src/app/contactus/page.tsx b/src/app/contactus/page.tsx index c718684..7986dcf 100644 --- a/src/app/contactus/page.tsx +++ b/src/app/contactus/page.tsx @@ -1,13 +1,14 @@ -import type { Metadata } from "next"; import ContactForm from "@/components/ContactForm"; import Container from "@/components/Container"; import PageHeader from "@/components/PageHeader"; -import { CONTACT_PAGE, CONTACT_SIDEBAR, MAP_EMBED, SITE } from "@/lib/site"; +import { pageMetadata } from "@/lib/seo"; +import { CONTACT_PAGE, CONTACT_SIDEBAR, FOOTER, MAP_EMBED } from "@/lib/site"; -export const metadata: Metadata = { +export const metadata = pageMetadata({ title: "تماس با ما", - description: SITE.description, -}; + description: `${CONTACT_PAGE.intro}. ${FOOTER.branches.map((branch) => `${branch.title}: ${branch.phone}`).join("، ")}`, + path: "/contactus", +}); export default function ContactPage() { return ( @@ -18,7 +19,7 @@ export default function ContactPage() { <div className="flex flex-col items-start gap-10 lg:flex-row lg:items-center"> <div className="w-full lg:w-2/3"> <div className="mb-8"> - <h3 className="text-2xl text-heading">{CONTACT_PAGE.kicker}</h3> + <p className="text-2xl text-heading">{CONTACT_PAGE.kicker}</p> <p className="text-2xl text-gold">{CONTACT_PAGE.title}</p> <p className="mt-2 text-muted">{CONTACT_PAGE.intro}</p> </div> @@ -32,7 +33,7 @@ export default function ContactPage() { style={{ color: "rgb(185, 185, 185)" }} /> <div> - <h5>{item.title}</h5> + <p className="font-medium text-heading">{item.title}</p> <a href={item.href} dir="ltr" className="text-primary"> {item.value} </a> diff --git a/src/app/globals.css b/src/app/globals.css index b9ff453..2071098 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -495,3 +495,110 @@ ul { text-align: center; } } + +.account-menu { + display: inline-flex; + position: relative; +} + +.account-menu__dropdown { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 12px; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.12); + inset-inline-end: 0; + min-width: 12rem; + padding: 8px 0; + position: absolute; + top: calc(100% + 10px); + z-index: 120; +} + +.account-menu__dropdown a, +.account-menu__dropdown button { + background: transparent; + border: 0; + color: #4b4b4b; + cursor: pointer; + display: block; + font-family: inherit; + font-size: 14px; + font-weight: 500; + padding: 10px 16px; + text-align: right; + text-decoration: none; + width: 100%; +} + +.account-menu__dropdown a:hover, +.account-menu__dropdown button:hover { + background: color-mix(in srgb, var(--wb-primary-color) 8%, white); + color: var(--wb-primary-color); +} + +.account-menu__logout { + color: #b42318; +} + +.account-menu__logout:hover { + background: #fef3f2; + color: #b42318; +} + +.product-zoom { + cursor: zoom-in; +} + +.product-zoom__img { + display: block; + transition: transform 0.2s ease; + transform-origin: var(--zoom-x, 50%) var(--zoom-y, 50%); +} + +.product-zoom:hover .product-zoom__img { + transform: scale(1.7); +} + +.lightbox { + align-items: center; + background: rgba(0, 0, 0, 0.85); + display: flex; + inset: 0; + justify-content: center; + padding: 24px; + position: fixed; + z-index: 2000; +} + +.lightbox__img { + height: auto; + max-height: 90vh; + max-width: 90vw; + object-fit: contain; + width: auto; +} + +.lightbox__close { + background: transparent; + border: 0; + color: #fff; + cursor: pointer; + font-size: 36px; + line-height: 1; + position: absolute; + left: 24px; + top: 16px; +} + +.rich-content { + line-height: 2; +} + +.rich-content p { + margin-bottom: 1rem; +} + +.rich-content img { + height: auto; + max-width: 100%; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8572bf4..ef4af11 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,32 +1,47 @@ import type { Metadata } from "next"; import Header from "@/components/Header"; import Footer from "@/components/Footer"; +import Providers from "@/components/Providers"; +import { getBusinessId, getWebsiteFavicon } from "@/lib/meshkee"; +import { siteUrl } from "@/lib/seo"; import { SITE } from "@/lib/site"; import "./globals.css"; -export const metadata: Metadata = { - metadataBase: new URL(SITE.url), - title: { - default: SITE.name, - template: `%s | ${SITE.name}`, - }, - description: SITE.description, - icons: { - icon: "/images/logo.png", - }, -}; +const defaultFavicon = "/images/logo.png"; -export default function RootLayout({ +export async function generateMetadata(): Promise<Metadata> { + const branding = await getWebsiteFavicon(); + const iconUrl = branding.faviconUrl || defaultFavicon; + + return { + metadataBase: new URL(siteUrl), + title: { + default: `${SITE.name} | تولید و عرضه سوهان سنتی قم`, + template: `%s | ${SITE.name}`, + }, + description: SITE.description, + icons: { + icon: iconUrl, + shortcut: iconUrl, + }, + }; +} + +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const businessId = await getBusinessId(); + return ( <html lang="fa" dir="rtl"> <body className="flex min-h-screen flex-col antialiased"> - <Header /> - <main className="flex-1">{children}</main> - <Footer /> + <Providers businessId={businessId}> + <Header /> + <main className="flex-1">{children}</main> + <Footer /> + </Providers> </body> </html> ); diff --git a/src/app/meshkee/sitemap-config.json/route.ts b/src/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..998558b --- /dev/null +++ b/src/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/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..1959e61 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import PageHeader from "@/components/PageHeader"; +import { pageMetadata } from "@/lib/seo"; + +export const metadata = pageMetadata({ + title: "صفحه پیدا نشد", + description: "صفحه مورد نظر در سوهان صالح نیا پیدا نشد. به صفحه اصلی بازگردید.", + path: "/", + index: false, +}); + +export default function NotFound() { + return ( + <> + <PageHeader kicker="خطا" title="صفحه پیدا نشد" english="Not Found" /> + <section className="bg-page py-16 text-center"> + <p className="text-muted">این صفحه وجود ندارد یا جابه‌جا شده است.</p> + <Link href="/" className="button-style-2 mt-6 inline-flex pb-1"> + بازگشت به صفحه اصلی + </Link> + </section> + </> + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 306dbea..6b55559 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,8 +4,16 @@ import AboutPreview from "@/components/AboutPreview"; import Container from "@/components/Container"; import PromoBanner from "@/components/PromoBanner"; import SohanFeature from "@/components/SohanFeature"; +import { pageMetadata } from "@/lib/seo"; import { PRODUCT_TYPES, SITE } from "@/lib/site"; +export const metadata = pageMetadata({ + title: `${SITE.name} | تولید و عرضه سوهان سنتی قم`, + description: SITE.description, + path: "/", + image: "/images/hero.jpg", +}); + export default function HomePage() { return ( <> @@ -25,10 +33,10 @@ export default function HomePage() { <section className="bg-page py-16"> <Container> <div className="soft-shadow flex flex-col flex-wrap items-center rounded-[30px] px-4 py-8 lg:px-8"> - <div className="mb-6 flex items-center gap-1"> + <h1 className="mb-6 flex items-center gap-1"> <span className="text-[20px] text-muted">انواع محصولات</span> <span className="text-[24px] text-primary">{SITE.name}</span> - </div> + </h1> <div className="flex w-full flex-col items-center justify-between gap-8 lg:flex-row"> {PRODUCT_TYPES.map((item, index) => ( <Link diff --git a/src/app/products/[id]/[slug]/page.tsx b/src/app/products/[id]/[slug]/page.tsx new file mode 100644 index 0000000..619eeee --- /dev/null +++ b/src/app/products/[id]/[slug]/page.tsx @@ -0,0 +1,122 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, permanentRedirect } from "next/navigation"; +import Container from "@/components/Container"; +import PageHeader from "@/components/PageHeader"; +import ProductCard from "@/components/ProductCard"; +import ProductGallery from "@/components/ProductGallery"; +import ProductTabs from "@/components/ProductTabs"; +import { + getComments, + getProductById, + getProducts, + getProductTechnicalInfo, + productGallery, + productName, + productPriceLabel, + stripHtml, + technicalRows, +} from "@/lib/meshkee"; +import { productPath, productSlug } from "@/lib/urls"; +import { pageMetadata } from "@/lib/seo"; + +export const revalidate = 300; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}): Promise<Metadata> { + const { id } = await params; + const product = await getProductById(id).catch(() => null); + if (!product) { + return pageMetadata({ + title: "محصول یافت نشد", + description: "محصول مورد نظر در سوهان صالح نیا پیدا نشد.", + path: `/products/${id}`, + index: false, + }); + } + return pageMetadata({ + title: productName(product), + description: product.summary || stripHtml(product.descriptionHtml || "", 160) || productName(product), + path: productPath(product), + image: productGallery(product)[0] || product.thumbnailUrl || product.image, + }); +} + +export default async function ProductDetailPage({ + params, +}: { + params: Promise<{ id: string; slug: string }>; +}) { + const { id, slug: slugParam } = await params; + const slug = decodeURIComponent(slugParam); + const product = await getProductById(id).catch(() => null); + if (!product) notFound(); + + const canonicalSlug = productSlug(product); + if (slug !== canonicalSlug) { + permanentRedirect(productPath(product)); + } + + const [tech, comments, related] = await Promise.all([ + getProductTechnicalInfo(product.id), + getComments("product", product.id), + (product.categoryId + ? getProducts({ categoryId: product.categoryId, pageSize: 8 }) + : getProducts({ pageSize: 8 }) + ) + .then((data) => data.items.filter((item) => item.id !== product.id).slice(0, 4)) + .catch(() => []), + ]); + + const name = productName(product); + const images = productGallery(product); + + return ( + <> + <PageHeader kicker="محصولات" title={name} english="Products" /> + <section className="bg-page py-16"> + <Container> + <Link href="/products" className="text-sm text-primary"> + بازگشت به محصولات + </Link> + + <div className="mt-8 grid grid-cols-1 gap-10 lg:grid-cols-2"> + <ProductGallery images={images} alt={name} /> + <div> + <h2 className="text-[28px] text-heading">{name}</h2> + {product.summary && <p className="mt-4 leading-8 text-muted">{product.summary}</p>} + <p className="mt-6 text-xl text-primary">{productPriceLabel(product)}</p> + <Link href="/contactus" className="button-style-2 mt-8 inline-flex pb-1"> + تماس برای سفارش + <i className="WMi-left-chevron" /> + </Link> + </div> + </div> + + <ProductTabs + descriptionHtml={product.descriptionHtml || ""} + technicalRows={technicalRows(tech)} + entityId={product.id} + comments={comments} + /> + </Container> + </section> + + {related.length > 0 && ( + <section className="bg-page pb-16"> + <Container> + <h2 className="mb-8 text-[24px] text-heading">محصولات مشابه</h2> + <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"> + {related.map((item) => ( + <ProductCard key={item.id} product={item} /> + ))} + </div> + </Container> + </section> + )} + </> + ); +} diff --git a/src/app/products/page.tsx b/src/app/products/page.tsx index 020e3bc..1a51999 100644 --- a/src/app/products/page.tsx +++ b/src/app/products/page.tsx @@ -1,20 +1,52 @@ -import type { Metadata } from "next"; import Container from "@/components/Container"; import PageHeader from "@/components/PageHeader"; +import Pagination from "@/components/Pagination"; +import ProductCard from "@/components/ProductCard"; +import { getProducts } from "@/lib/meshkee"; +import { pageMetadata } from "@/lib/seo"; +import { SITE } from "@/lib/site"; -export const metadata: Metadata = { +export const metadata = pageMetadata({ title: "محصولات", -}; + description: `محصولات ${SITE.description}`, + path: "/products", + image: "/images/product-habe.png", +}); + +const PAGE_SIZE = 9; + +export default async function ProductsPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const { page } = await searchParams; + const requestedPage = Math.max(1, Number(page) || 1); + const { items, total } = await getProducts({ + page: requestedPage, + pageSize: PAGE_SIZE, + }).catch(() => ({ items: [], total: 0, page: 1, pageSize: PAGE_SIZE })); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const currentPage = Math.min(requestedPage, totalPages); -export default function ProductsPage() { return ( <> <PageHeader kicker="محصولات" title="سوهان صالح نیا" english="Products" /> - <section className="bg-page py-20"> - <Container className="text-center"> - <p className="text-lg text-muted"> - فهرست محصولات به‌زودی از طریق بک‌اند مشکی بارگذاری می‌شود. - </p> + <section className="bg-page py-16"> + <Container> + {items.length > 0 ? ( + <> + <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> + {items.map((product) => ( + <ProductCard key={product.id} product={product} /> + ))} + </div> + <Pagination currentPage={currentPage} totalPages={totalPages} basePath="/products" /> + </> + ) : ( + <p className="py-10 text-center text-muted">در حال حاضر محصولی برای نمایش وجود ندارد.</p> + )} </Container> </section> </> diff --git a/src/components/AboutPreview.tsx b/src/components/AboutPreview.tsx index dbd452b..672c38e 100644 --- a/src/components/AboutPreview.tsx +++ b/src/components/AboutPreview.tsx @@ -18,12 +18,12 @@ export default function AboutPreview() { /> </div> <div className="w-full lg:w-2/3"> - <Image - src="/images/logo.png" - alt="" - width={125} - height={40} - /> + <Image + src="/images/logo.png" + alt="سوهان صالح نیا" + width={125} + height={40} + /> <p className="my-6 leading-8 text-black">{ABOUT.body}</p> <div className="text-start"> <Link href="/contactus" className="button-style-2 pb-1"> diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx new file mode 100644 index 0000000..7eface8 --- /dev/null +++ b/src/components/AccountMenu.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useRef, useState, type MouseEvent } from "react"; +import { businessDashboardUrl, customerPortalUrl } from "@/lib/config"; +import { useSession, userDisplayName } from "@/lib/session"; + +function ChevronIcon({ open }: { open: boolean }) { + return ( + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + aria-hidden + className={`transition-transform ${open ? "rotate-180" : ""}`} + > + <path + d="M6 9l6 6 6-6" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +export default function AccountMenu() { + const { user, isAuthenticated, ready, isWebsiteAdmin, logout, getAccessToken } = useSession(); + const [open, setOpen] = useState(false); + const [handoffBusy, setHandoffBusy] = useState(false); + const menuRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!open) return; + const onClickOutside = (event: Event) => { + if (menuRef.current && event.target instanceof Node && !menuRef.current.contains(event.target)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onClickOutside); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onClickOutside); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + event.preventDefault(); + if (handoffBusy) return; + + const baseUrl = businessDashboardUrl; + const popup = window.open("about:blank", "_blank"); + setHandoffBusy(true); + try { + const token = getAccessToken(); + const res = await fetch("/api/auth/handoff", { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (!res.ok) throw new Error("handoff failed"); + const data = (await res.json()) as { ticket?: string }; + const url = new URL(baseUrl); + if (data.ticket) url.searchParams.set("handoff", data.ticket); + if (popup) { + popup.opener = null; + popup.location.replace(url.toString()); + } else { + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } + } catch { + if (popup) popup.location.replace(baseUrl); + else window.open(baseUrl, "_blank", "noopener,noreferrer"); + } finally { + setHandoffBusy(false); + setOpen(false); + } + } + + if (!ready || !isAuthenticated || !user) { + return ( + <a href={customerPortalUrl} className="button-light px-3 py-1 text-[1.2rem] leading-none" title="ورود"> + ورود + <i className="WMi-login" /> + </a> + ); + } + + const displayName = userDisplayName(user); + + return ( + <div ref={menuRef} className="account-menu account-menu--user relative"> + <button + type="button" + onClick={() => setOpen((value) => !value)} + aria-expanded={open} + aria-haspopup="menu" + className="account-menu__trigger button-light px-3 py-1 text-[15px] leading-none" + > + <span className="max-w-[9.5rem] truncate">{displayName} عزیز</span> + <ChevronIcon open={open} /> + </button> + + {open ? ( + <div role="menu" className="account-menu__dropdown"> + <a role="menuitem" href={`${customerPortalUrl}/profile`} onClick={() => setOpen(false)}> + پروفایل من + </a> + {isWebsiteAdmin ? ( + <a role="menuitem" href={businessDashboardUrl} onClick={openBusinessDashboard}> + {handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"} + </a> + ) : null} + <button + type="button" + role="menuitem" + className="account-menu__logout" + onClick={() => { + setOpen(false); + logout(); + }} + > + خروج + </button> + </div> + ) : null} + </div> + ); +} diff --git a/src/components/CommentsBox.tsx b/src/components/CommentsBox.tsx new file mode 100644 index 0000000..e7b32c1 --- /dev/null +++ b/src/components/CommentsBox.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { customerPortalUrl } from "@/lib/config"; +import type { CommentItem } from "@/lib/meshkee"; +import { useSession, userDisplayName } from "@/lib/session"; + +export default function CommentsBox({ + entityType, + entityId, + initialComments, +}: { + entityType: "product" | "blog"; + entityId: string; + initialComments: CommentItem[]; +}) { + const { isAuthenticated, ready, user, getAccessToken } = useSession(); + const [comments] = useState(initialComments); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + + async function onSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + if (!text.trim() || busy) return; + setBusy(true); + setMessage(""); + try { + const token = getAccessToken(); + const res = await fetch("/api/comments", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + entityType, + entityId, + authorName: userDisplayName(user), + authorEmail: user?.email, + text: text.trim(), + }), + }); + const data = (await res.json()) as { message?: string; error?: string }; + if (!res.ok) throw new Error(data.error || "ارسال ناموفق بود"); + setText(""); + setMessage(data.message || "دیدگاه شما ثبت شد و پس از تأیید نمایش داده می‌شود."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "ارسال ناموفق بود"); + } finally { + setBusy(false); + } + } + + return ( + <div> + {comments.length > 0 ? ( + <ul className="space-y-4"> + {comments.map((comment, index) => ( + <li key={comment.id || index} className="rounded-2xl bg-page p-4"> + <p className="font-medium text-heading">{comment.authorName || "کاربر"}</p> + <p className="mt-2 leading-7">{comment.text}</p> + </li> + ))} + </ul> + ) : ( + <p className="text-muted">هنوز دیدگاهی ثبت نشده است.</p> + )} + + {!ready ? null : isAuthenticated ? ( + <form onSubmit={onSubmit} className="form-1 mt-8"> + <div className="relative pt-2"> + <label htmlFor="comment">دیدگاه شما</label> + <textarea + id="comment" + rows={4} + value={text} + onChange={(event) => setText(event.target.value)} + required + /> + </div> + <button type="submit" className="button-style-2 mt-6 pb-1" disabled={busy}> + {busy ? "در حال ارسال…" : "ارسال دیدگاه"} + </button> + {message && <p className="mt-3 text-sm text-primary">{message}</p>} + </form> + ) : ( + <div className="mt-8 rounded-2xl border border-primary/20 bg-white p-6 text-center"> + <p className="text-heading">برای ثبت دیدگاه وارد حساب کاربری شوید.</p> + <a href={customerPortalUrl} className="button-light mt-4 inline-flex"> + ورود + <i className="WMi-login" /> + </a> + </div> + )} + </div> + ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index bf0e75e..259e252 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -21,7 +21,7 @@ export default function Footer() { <div className="grid gap-8 sm:grid-cols-2"> {FOOTER.branches.map((branch) => ( <div key={branch.title}> - <h5 className="font-black text-muted">{branch.title}</h5> + <p className="font-black text-muted">{branch.title}</p> <div className="mt-3 flex flex-wrap gap-6"> <div> <p className="mb-2 text-muted">آدرس ما</p> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4d8c01b..201298f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -4,6 +4,7 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; +import AccountMenu from "@/components/AccountMenu"; import { NAV_ITEMS, SITE } from "@/lib/site"; export default function Header() { @@ -86,13 +87,7 @@ export default function Header() { </Link> </div> <div className="flex w-[41.66%] justify-end"> - <a - href={SITE.loginUrl} - className="button-light px-3 py-1 text-[1.2rem] leading-none" - > - ورود - <i className="WMi-login" /> - </a> + <AccountMenu /> </div> </div> </div> @@ -118,13 +113,7 @@ export default function Header() { </li> ))} <li> - <a - href={SITE.loginUrl} - className="button-light mt-2 px-3 py-1 text-[1.2rem] leading-none" - > - ورود - <i className="WMi-login" /> - </a> + <AccountMenu /> </li> </ul> </nav> diff --git a/src/components/PageHeader.tsx b/src/components/PageHeader.tsx index eb1039c..c6c554f 100644 --- a/src/components/PageHeader.tsx +++ b/src/components/PageHeader.tsx @@ -28,13 +28,12 @@ export default function PageHeader({ {english} </span> </div> - <div className="relative z-10 w-fit bg-page ps-3"> - <h3 className="text-[25px] font-normal text-heading">{kicker}</h3> - <p className="text-[30px] leading-tight text-gold">{title}</p> - </div> + <h1 className="relative z-10 w-fit bg-page ps-3"> + <span className="block text-[25px] font-normal text-heading">{kicker}</span> + <span className="block text-[30px] leading-tight text-gold">{title}</span> + </h1> </div> </div> - <h1 className="sr-only">{kicker}</h1> </section> ); } diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..aba6c92 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { toFa } from "@/lib/format"; + +export default function Pagination({ + currentPage, + totalPages, + basePath, +}: { + currentPage: number; + totalPages: number; + basePath: string; +}) { + if (totalPages <= 1) return null; + + function hrefFor(page: number) { + return page === 1 ? basePath : `${basePath}?page=${page}`; + } + + return ( + <nav aria-label="صفحه‌بندی" className="mt-12 flex flex-wrap items-center justify-center gap-2"> + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + <Link + key={page} + href={hrefFor(page)} + aria-current={page === currentPage ? "page" : undefined} + className={`flex h-10 w-10 items-center justify-center rounded-full text-sm ${ + page === currentPage ? "bg-primary text-white" : "text-heading hover:bg-primary/10" + }`} + > + {toFa(page)} + </Link> + ))} + </nav> + ); +} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx new file mode 100644 index 0000000..c5bdd94 --- /dev/null +++ b/src/components/PostCard.tsx @@ -0,0 +1,38 @@ +import Image from "next/image"; +import Link from "next/link"; +import type { Post } from "@/lib/meshkee"; +import { postDate, postExcerpt, postImage, postTitle } from "@/lib/meshkee"; +import { blogPath } from "@/lib/urls"; + +export default function PostCard({ post }: { post: Post }) { + const title = postTitle(post); + const image = postImage(post); + const excerpt = postExcerpt(post, 90); + + return ( + <Link + href={blogPath(post)} + className="group flex flex-col overflow-hidden rounded-[20px] bg-white shadow-sm transition-shadow hover:shadow-lg" + > + <div className="relative aspect-[16/10] bg-page"> + {image ? ( + <Image + src={image} + alt={title} + fill + className="object-cover transition-transform duration-500 group-hover:scale-105" + sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" + /> + ) : ( + <div className="flex h-full items-center justify-center text-4xl text-primary/20">س</div> + )} + </div> + <div className="flex flex-1 flex-col p-5"> + <span className="text-xs text-muted">{postDate(post)}</span> + <h3 className="mt-3 line-clamp-2 text-[18px] leading-7 text-heading">{title}</h3> + {excerpt && <p className="mt-2 line-clamp-2 flex-1 text-sm leading-6 text-muted">{excerpt}</p>} + <span className="mt-4 text-sm text-primary">ادامه مطلب</span> + </div> + </Link> + ); +} diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx new file mode 100644 index 0000000..e864a62 --- /dev/null +++ b/src/components/ProductCard.tsx @@ -0,0 +1,37 @@ +import Image from "next/image"; +import Link from "next/link"; +import type { Product } from "@/lib/meshkee"; +import { productImage, productName, productPriceLabel, stripHtml } from "@/lib/meshkee"; +import { productPath } from "@/lib/urls"; + +export default function ProductCard({ product }: { product: Product }) { + const image = productImage(product); + const name = productName(product); + const excerpt = product.summary || stripHtml(product.descriptionHtml || "", 90); + + return ( + <Link + href={productPath(product)} + className="group overflow-hidden rounded-[20px] bg-white shadow-sm transition-shadow hover:shadow-lg" + > + <div className="relative aspect-square bg-page"> + {image ? ( + <Image + src={image} + alt={name} + fill + className="object-cover transition-transform duration-500 group-hover:scale-105" + sizes="(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw" + /> + ) : ( + <div className="flex h-full items-center justify-center text-4xl text-primary/20">س</div> + )} + </div> + <div className="p-4"> + <h3 className="min-h-12 text-[18px] leading-7 text-primary">{name}</h3> + {excerpt && <p className="mt-1 line-clamp-2 text-sm text-muted">{excerpt}</p>} + <span className="mt-3 inline-block text-sm font-bold text-gold">{productPriceLabel(product)}</span> + </div> + </Link> + ); +} diff --git a/src/components/ProductGallery.tsx b/src/components/ProductGallery.tsx new file mode 100644 index 0000000..34758bf --- /dev/null +++ b/src/components/ProductGallery.tsx @@ -0,0 +1,105 @@ +"use client"; + +import Image from "next/image"; +import { useCallback, useEffect, useState, type MouseEvent } from "react"; + +export default function ProductGallery({ + images, + alt, +}: { + images: string[]; + alt: string; +}) { + const [active, setActive] = useState(0); + const [lightbox, setLightbox] = useState(false); + const current = images[active] ?? null; + + const close = useCallback(() => setLightbox(false), []); + + function onMove(event: MouseEvent<HTMLButtonElement>) { + const box = event.currentTarget.getBoundingClientRect(); + const x = ((event.clientX - box.left) / box.width) * 100; + const y = ((event.clientY - box.top) / box.height) * 100; + event.currentTarget.style.setProperty("--zoom-x", `${x}%`); + event.currentTarget.style.setProperty("--zoom-y", `${y}%`); + } + + useEffect(() => { + if (!lightbox) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + if (event.key === "ArrowLeft") setActive((i) => Math.min(images.length - 1, i + 1)); + if (event.key === "ArrowRight") setActive((i) => Math.max(0, i - 1)); + }; + document.addEventListener("keydown", onKey); + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = ""; + }; + }, [lightbox, images.length, close]); + + if (!current) { + return ( + <div className="flex aspect-square items-center justify-center rounded-[20px] bg-white text-4xl text-primary/20"> + س + </div> + ); + } + + return ( + <div> + <button + type="button" + className="product-zoom w-full overflow-hidden rounded-[20px] bg-white" + onClick={() => setLightbox(true)} + onMouseMove={onMove} + aria-label="بزرگ‌نمایی تصویر" + > + <Image + src={current} + alt={alt} + width={800} + height={800} + className="product-zoom__img h-auto w-full object-cover" + priority + /> + </button> + + {images.length > 1 && ( + <ul className="mt-3 flex flex-wrap gap-2"> + {images.map((url, index) => ( + <li key={url}> + <button + type="button" + onClick={() => setActive(index)} + aria-label={`${alt} — تصویر ${index + 1}`} + className={`overflow-hidden rounded-xl border ${ + index === active ? "border-primary" : "border-transparent" + }`} + > + <Image src={url} alt={`${alt} — تصویر ${index + 1}`} width={72} height={72} className="h-[72px] w-[72px] object-cover" /> + </button> + </li> + ))} + </ul> + )} + + {lightbox && ( + <div className="lightbox" onClick={close} role="dialog" aria-modal="true"> + <button type="button" className="lightbox__close" onClick={close} aria-label="بستن"> + × + </button> + <Image + src={current} + alt={alt} + width={1200} + height={1200} + className="lightbox__img" + onClick={(event) => event.stopPropagation()} + /> + </div> + )} + </div> + ); +} diff --git a/src/components/ProductTabs.tsx b/src/components/ProductTabs.tsx new file mode 100644 index 0000000..2ee27d2 --- /dev/null +++ b/src/components/ProductTabs.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import CommentsBox from "@/components/CommentsBox"; +import type { CommentItem } from "@/lib/meshkee"; + +type Tab = "description" | "technical" | "comments"; + +export default function ProductTabs({ + descriptionHtml, + technicalRows, + entityId, + comments, +}: { + descriptionHtml: string; + technicalRows: { label: string; value: string }[]; + entityId: string; + comments: CommentItem[]; +}) { + const [tab, setTab] = useState<Tab>("description"); + + const tabs: { id: Tab; label: string }[] = [ + { id: "description", label: "توضیحات" }, + { id: "technical", label: "اطلاعات فنی" }, + { id: "comments", label: "دیدگاه‌ها" }, + ]; + + return ( + <div className="mt-12"> + <div className="flex flex-wrap gap-6 border-b border-neutral-200"> + {tabs.map((item) => ( + <button + key={item.id} + type="button" + onClick={() => setTab(item.id)} + className={`pb-3 text-[16px] ${ + tab === item.id ? "border-b-2 border-primary text-primary" : "text-muted" + }`} + > + {item.label} + </button> + ))} + </div> + + <div className="py-8"> + {tab === "description" && + (descriptionHtml ? ( + <div className="rich-content" dangerouslySetInnerHTML={{ __html: descriptionHtml }} /> + ) : ( + <p className="text-muted">توضیحاتی برای این محصول ثبت نشده است.</p> + ))} + + {tab === "technical" && + (technicalRows.length > 0 ? ( + <table className="w-full overflow-hidden rounded-2xl bg-white text-sm"> + <tbody> + {technicalRows.map((row) => ( + <tr key={row.label} className="border-b border-neutral-100 last:border-0"> + <th className="w-1/3 bg-page px-4 py-3 text-right font-medium text-heading"> + {row.label} + </th> + <td className="px-4 py-3">{row.value}</td> + </tr> + ))} + </tbody> + </table> + ) : ( + <p className="text-muted">اطلاعات فنی برای این محصول ثبت نشده است.</p> + ))} + + {tab === "comments" && ( + <CommentsBox entityType="product" entityId={entityId} initialComments={comments} /> + )} + </div> + </div> + ); +} diff --git a/src/components/PromoBanner.tsx b/src/components/PromoBanner.tsx index d435e3b..1efde38 100644 --- a/src/components/PromoBanner.tsx +++ b/src/components/PromoBanner.tsx @@ -9,7 +9,7 @@ export default function PromoBanner() { <Link href="/products" className="hidden p-12 lg:block"> <Image src="/images/promo-banner.png" - alt="سوهان صالح نیا" + alt="محصولات سوهان صالح نیا" width={904} height={234} className="my-3 w-full rounded-[20px]" @@ -18,7 +18,7 @@ export default function PromoBanner() { <Link href="/products" className="lg:hidden"> <Image src="/images/promo-banner.png" - alt="سوهان صالح نیا" + alt="محصولات سوهان صالح نیا" width={904} height={234} className="my-3 w-full rounded-[10px]" diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx new file mode 100644 index 0000000..abe0ebe --- /dev/null +++ b/src/components/Providers.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { SessionProvider } from "@/lib/session"; + +export default function Providers({ + businessId, + children, +}: { + businessId: string; + children: React.ReactNode; +}) { + return <SessionProvider businessId={businessId}>{children}</SessionProvider>; +} diff --git a/src/components/SohanFeature.tsx b/src/components/SohanFeature.tsx index c570779..c6921d8 100644 --- a/src/components/SohanFeature.tsx +++ b/src/components/SohanFeature.tsx @@ -21,7 +21,7 @@ export default function SohanFeature() { style={{ filter: "drop-shadow(17px 31px 20px rgb(0, 0, 0, .5))" }} /> <div> - <h3 className="text-[27px] text-heading">{SOHAN_FEATURE.title}</h3> + <h2 className="text-[27px] text-heading">{SOHAN_FEATURE.title}</h2> <p className="text-[30px] text-gold">{SOHAN_FEATURE.subtitle}</p> </div> </div> diff --git a/src/components/WhyUs.tsx b/src/components/WhyUs.tsx index 0fcb0ab..de0ceff 100644 --- a/src/components/WhyUs.tsx +++ b/src/components/WhyUs.tsx @@ -32,10 +32,10 @@ export default function WhyUs() { return ( <section className="bg-page py-16"> <Container> - <div className="mb-8 flex flex-col items-center p-1"> - <h3 className="text-[25px] text-heading">{WHY_US.kicker}</h3> + <h2 className="mb-8 flex flex-col items-center p-1"> + <span className="text-[25px] text-heading">{WHY_US.kicker}</span> <span className="inline-block text-[25px] text-gold">{WHY_US.title}</span> - </div> + </h2> <div className="flex flex-col items-center justify-around lg:flex-row"> <div className="w-full lg:w-1/4"> <WhyItem {...leftTop} bordered /> diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..a9f8638 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,14 @@ +export const websiteDomain = + process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ?? "sohansalehnia.com"; + +export const meshkeeApiBase = ( + process.env.NEXT_PUBLIC_MESHKEE_API_BASE ?? "https://api.meshkee.com/api/v1" +).replace(/\/$/, ""); + +export const customerPortalUrl = ( + process.env.NEXT_PUBLIC_CUSTOMER_PORTAL_URL ?? `https://customer.${websiteDomain}` +).replace(/\/$/, ""); + +export const businessDashboardUrl = ( + process.env.NEXT_PUBLIC_BUSINESS_ORIGIN ?? `https://business.${websiteDomain}` +).replace(/\/$/, ""); diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..2375906 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,5 @@ +const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; + +export function toFa(value: number | string): string { + return String(value).replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]); +} diff --git a/src/lib/meshkee-server.ts b/src/lib/meshkee-server.ts new file mode 100644 index 0000000..97b9bce --- /dev/null +++ b/src/lib/meshkee-server.ts @@ -0,0 +1,42 @@ +import { meshkeeApiBase } from "@/lib/config"; + +export function getBearerToken(request: Request): string | null { + const auth = request.headers.get("authorization"); + if (auth?.startsWith("Bearer ")) return auth.slice(7); + return null; +} + +export async function meshkeeFetch( + path: string, + init?: RequestInit & { token?: string | null }, +): Promise<{ ok: boolean; status: number; data: unknown }> { + const headers = new Headers(init?.headers); + if (!headers.has("Content-Type") && init?.body) { + headers.set("Content-Type", "application/json"); + } + if (init?.token) { + headers.set("Authorization", `Bearer ${init.token}`); + } + + const res = await fetch(`${meshkeeApiBase}${path}`, { + ...init, + headers, + }); + + let data: unknown = null; + try { + data = await res.json(); + } catch { + data = null; + } + + return { ok: res.ok, status: res.status, data }; +} + +export function jsonResponse(data: unknown, status = 200) { + return Response.json(data, { status }); +} + +export function unauthorizedResponse() { + return jsonResponse({ error: "Unauthorized" }, 401); +} diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts new file mode 100644 index 0000000..9d6213d --- /dev/null +++ b/src/lib/meshkee.ts @@ -0,0 +1,338 @@ +import { toFa } from "@/lib/format"; +import { websiteDomain } from "@/lib/config"; + +const API_BASE = "https://api.meshkee.com/api/v1"; +export const TENANT_DOMAIN = websiteDomain; + +export type Tenant = { + id: number; + name: string; + nameFa: string; + slug: string; + domain: string; +}; + +async function meshkeeFetch<T>(path: string, revalidateSeconds = 300): Promise<T> { + const res = await fetch(`${API_BASE}${path}`, { + next: { revalidate: revalidateSeconds }, + }); + if (!res.ok) { + throw new Error(`Meshkee API ${path} failed: ${res.status}`); + } + return res.json(); +} + +export async function getTenant(): Promise<Tenant | null> { + try { + return await meshkeeFetch<Tenant>(`/tenants/${TENANT_DOMAIN}`, 3600); + } catch { + return null; + } +} + +export async function getBusinessId(): Promise<string> { + const tenant = await getTenant(); + return tenant?.id != null ? String(tenant.id) : ""; +} + +export type WebsiteFavicon = { + faviconUrl: string | null; + logoUrl: string | null; + logoDarkUrl: string | null; + hasDedicatedFavicon: boolean; +}; + +const emptyFavicon: WebsiteFavicon = { + faviconUrl: null, + logoUrl: null, + logoDarkUrl: null, + hasDedicatedFavicon: false, +}; + +export async function getWebsiteFavicon(): Promise<WebsiteFavicon> { + try { + return await meshkeeFetch<WebsiteFavicon>( + `/tenants/${TENANT_DOMAIN}/website/favicon`, + 3600, + ); + } catch { + return emptyFavicon; + } +} + +export type ProductStore = { + variantCount: number; + minPrice: number | null; + maxPrice: number | null; + inStock: boolean; +}; + +export type Product = { + id: string; + businessId: string; + title: string; + nameFa: string; + summary: string; + descriptionHtml: string; + slug: string; + status: string; + categoryId: string | null; + categoryName: string | null; + categoryNameFa: string | null; + brandId: string | null; + brand: string | null; + tags: string[]; + thumbnailUrl: string | null; + thumbnail: string | null; + image: string | null; + images: string[]; + commentCount: number; + variantCount: number; + createdAt: string; + updatedAt: string; + store: ProductStore; +}; + +export type ProductListResponse = { + items: Product[]; + total: number; + page: number; + pageSize: number; +}; + +export function getProducts( + params: { + page?: number; + pageSize?: number; + categoryId?: string; + name?: string; + } = {}, +): Promise<ProductListResponse> { + const query = new URLSearchParams(); + if (params.page) query.set("page", String(params.page)); + if (params.pageSize) query.set("pageSize", String(params.pageSize)); + if (params.categoryId) query.set("categoryId", params.categoryId); + if (params.name) query.set("name", params.name); + const qs = query.toString(); + return meshkeeFetch<ProductListResponse>( + `/tenants/${TENANT_DOMAIN}/products${qs ? `?${qs}` : ""}`, + ); +} + +export async function getProductById(id: string): Promise<Product | null> { + const res = await fetch(`${API_BASE}/tenants/${TENANT_DOMAIN}/products/by-id/${id}`, { + next: { revalidate: 300 }, + }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`Meshkee API product id "${id}" failed: ${res.status}`); + const data = await res.json(); + return data.product as Product; +} + +export function productImage(product: Product): string | null { + return product.thumbnail || product.image || product.thumbnailUrl || product.images?.[0] || null; +} + +export function productGallery(product: Product): string[] { + const seen = new Set<string>(); + const urls: string[] = []; + for (const url of [productImage(product), ...(product.images || [])]) { + if (!url || seen.has(url)) continue; + seen.add(url); + urls.push(url); + } + return urls; +} + +export function productPriceLabel(product: Product): string { + if (product.store?.minPrice) { + const formatted = new Intl.NumberFormat("fa-IR").format(product.store.minPrice); + return `${formatted} تومان`; + } + return "تماس بگیرید"; +} + +export function productName(product: Product): string { + return product.nameFa || product.title || "محصول"; +} + +export type TechnicalFieldOption = { id: string; label: string }; +export type TechnicalField = { + id: string; + label: string; + key?: string; + type?: string; + options?: TechnicalFieldOption[]; +}; +export type TechnicalValue = { + fieldId: string; + textValue?: string | null; + optionId?: string | null; + optionIds?: string[] | null; +}; +export type TechnicalInfo = { + form: { id?: string; categoryId?: string; fields: TechnicalField[] } | null; + values: TechnicalValue[]; +}; + +export async function getProductTechnicalInfo(productId: string): Promise<TechnicalInfo> { + try { + return await meshkeeFetch<TechnicalInfo>( + `/tenants/${TENANT_DOMAIN}/products/by-id/${productId}/technical-info`, + ); + } catch { + return { form: null, values: [] }; + } +} + +export function technicalRows(info: TechnicalInfo): { label: string; value: string }[] { + const fields = info.form?.fields ?? []; + if (!fields.length) return []; + const byId = new Map(info.values.map((value) => [value.fieldId, value])); + const rows: { label: string; value: string }[] = []; + for (const field of fields) { + const value = byId.get(field.id); + if (!value) continue; + let text = value.textValue?.trim() || ""; + if (value.optionId && field.options?.length) { + text = field.options.find((option) => option.id === value.optionId)?.label ?? text; + } + if (value.optionIds?.length && field.options?.length) { + text = value.optionIds + .map((id) => field.options?.find((option) => option.id === id)?.label) + .filter(Boolean) + .join("، "); + } + if (!text) continue; + rows.push({ label: field.label, value: text }); + } + return rows; +} + +export function stripHtml(html: string, maxLength = 140): string { + const text = html + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + if (text.length <= maxLength) return text; + return text.slice(0, maxLength).trim() + "…"; +} + +export type Post = { + id: string; + businessId?: string; + title?: string; + titleFa?: string; + nameFa?: string; + summary?: string; + excerpt?: string; + descriptionHtml?: string; + contentHtml?: string; + content?: string; + slug: string; + status?: string; + categoryId?: string | null; + categoryName?: string | null; + categoryNameFa?: string | null; + tags?: string[]; + thumbnail?: string | null; + thumbnailUrl?: string | null; + image?: string | null; + images?: string[]; + commentCount?: number; + publishedAt?: string | null; + createdAt: string; + updatedAt?: string; +}; + +export type PostListResponse = { + items: Post[]; + total: number; + page: number; + pageSize: number; +}; + +export function getPosts( + params: { + page?: number; + pageSize?: number; + categoryId?: string; + title?: string; + type?: "news" | "article" | "blog"; + } = {}, +): Promise<PostListResponse> { + const query = new URLSearchParams(); + if (params.page) query.set("page", String(params.page)); + if (params.pageSize) query.set("pageSize", String(params.pageSize)); + if (params.categoryId) query.set("categoryId", params.categoryId); + if (params.title) query.set("title", params.title); + if (params.type) query.set("type", params.type); + const qs = query.toString(); + return meshkeeFetch<PostListResponse>( + `/tenants/${TENANT_DOMAIN}/blogs${qs ? `?${qs}` : ""}`, + ); +} + +export async function getPostById(id: string): Promise<Post | null> { + const res = await fetch(`${API_BASE}/tenants/${TENANT_DOMAIN}/blogs/by-id/${id}`, { + next: { revalidate: 300 }, + }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`Meshkee API blog id "${id}" failed: ${res.status}`); + const data = await res.json(); + return (data.blog ?? data.post ?? data) as Post; +} + +export function postTitle(post: Post): string { + return post.nameFa || post.titleFa || post.title || "بدون عنوان"; +} + +export function postContent(post: Post): string { + return post.descriptionHtml || post.contentHtml || post.content || ""; +} + +export function postImage(post: Post): string | null { + return post.thumbnail || post.image || post.thumbnailUrl || post.images?.[0] || null; +} + +export function postExcerpt(post: Post, maxLength = 140): string { + if (post.summary) return post.summary; + if (post.excerpt) return post.excerpt; + return stripHtml(postContent(post), maxLength); +} + +export function postDate(post: Post): string { + const iso = post.publishedAt || post.createdAt; + return new Intl.DateTimeFormat("fa-IR-u-ca-persian", { + year: "numeric", + month: "long", + day: "numeric", + }).format(new Date(iso)); +} + +export function postReadingTime(post: Post): string { + const text = stripHtml(postContent(post), 100000); + const words = text.split(/\s+/).filter(Boolean).length; + const minutes = Math.max(1, Math.round(words / 150)); + return `${toFa(minutes)} دقیقه مطالعه`; +} + +export type CommentItem = { + id?: string; + authorName?: string; + text?: string; + createdAt?: string; +}; + +export async function getComments(entityType: "product" | "blog", entityId: string): Promise<CommentItem[]> { + try { + const data = await meshkeeFetch<{ items: CommentItem[] }>( + `/tenants/${TENANT_DOMAIN}/comments?entityType=${entityType}&entityId=${encodeURIComponent(entityId)}`, + 30, + ); + return data.items ?? []; + } catch { + return []; + } +} diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..65d7b1f --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import { websiteDomain } from "@/lib/config"; +import { SITE } from "@/lib/site"; + +export const siteUrl = ( + process.env.NEXT_PUBLIC_SITE_URL ?? `https://${websiteDomain}` +).replace(/\/$/, ""); + +export function clipMetaDescription(text: string, max = 158) { + const clean = text.replace(/\s+/g, " ").trim(); + if (clean.length <= max) return clean; + const cut = clean.slice(0, max - 1); + const lastSpace = cut.lastIndexOf(" "); + return `${(lastSpace > 80 ? cut.slice(0, lastSpace) : cut).trim()}…`; +} + +export function canonicalPath(path: string): string { + if (!path || path === "/") return siteUrl; + return `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`; +} + +export function pageTitle(title: string, path = "/") { + if (path === "/" || title.includes(SITE.name)) return title; + return `${title} | ${SITE.name}`; +} + +export function pageMetadata({ + title, + description, + path = "/", + image, + index = true, +}: { + title: string; + description: string; + path?: string; + image?: string | null; + index?: boolean; +}): Metadata { + const url = canonicalPath(path); + const fullTitle = pageTitle(title, path); + const metaDescription = clipMetaDescription(description); + const ogImage = image || `${siteUrl}/images/logo.png`; + const isDetail = path.split("/").filter(Boolean).length > 1; + + return { + title: { absolute: fullTitle }, + description: metaDescription, + robots: { index, follow: index }, + alternates: { canonical: url }, + openGraph: { + type: isDetail ? "article" : "website", + locale: "fa_IR", + url, + siteName: SITE.name, + title: fullTitle, + description: metaDescription, + images: [{ url: ogImage, alt: fullTitle }], + }, + twitter: { + card: "summary_large_image", + title: fullTitle, + description: metaDescription, + images: [ogImage], + }, + }; +} diff --git a/src/lib/session.tsx b/src/lib/session.tsx new file mode 100644 index 0000000..bc953a3 --- /dev/null +++ b/src/lib/session.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +import { + clearSharedAuthCookies, + hydrateLocalTokensFromSharedCookies, + SHARED_ACCESS_TOKEN_KEY, + SHARED_REFRESH_TOKEN_KEY, + writeSharedAuthCookies, +} from "@/lib/shared-auth-cookie"; + +export type AuthBusinessMembership = { + id: string; + name: string; + slug: string; + isOwner: boolean; + teamRole: string | null; +}; + +export type AuthUser = { + id: string; + firstName?: string; + lastName?: string; + cellNumber?: string; + email?: string; + roles?: string[]; + isSuperAdmin?: boolean; + businesses?: AuthBusinessMembership[]; +}; + +type SessionContextValue = { + user: AuthUser | null; + isAuthenticated: boolean; + ready: boolean; + businessId: string; + isWebsiteAdmin: boolean; + logout: () => void; + getAccessToken: () => string | null; +}; + +function normalizeUser(raw: Partial<AuthUser> & { id?: string | number }): AuthUser | null { + if (raw.id == null) return null; + return { + id: String(raw.id), + firstName: raw.firstName ?? undefined, + lastName: raw.lastName ?? undefined, + cellNumber: raw.cellNumber ?? undefined, + email: raw.email ?? undefined, + roles: Array.isArray(raw.roles) ? raw.roles : undefined, + isSuperAdmin: Boolean(raw.isSuperAdmin), + businesses: Array.isArray(raw.businesses) + ? raw.businesses.map((business) => ({ + id: String(business.id), + name: business.name, + slug: business.slug, + isOwner: Boolean(business.isOwner), + teamRole: business.teamRole ?? null, + })) + : undefined, + }; +} + +export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string): boolean { + if (!user || !businessId) return false; + if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true; + return Boolean( + user.businesses?.some( + (business) => String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)), + ), + ); +} + +const SessionContext = createContext<SessionContextValue | null>(null); + +function readToken(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeToken(key: string, value: string | null) { + try { + if (value) localStorage.setItem(key, value); + else localStorage.removeItem(key); + } catch { + // ignore + } +} + +export function SessionProvider({ + children, + businessId, +}: { + children: ReactNode; + businessId: string; +}) { + const [user, setUser] = useState<AuthUser | null>(null); + const [ready, setReady] = useState(false); + + const getAccessToken = useCallback(() => { + hydrateLocalTokensFromSharedCookies(SHARED_ACCESS_TOKEN_KEY, SHARED_REFRESH_TOKEN_KEY); + return ( + readToken(SHARED_ACCESS_TOKEN_KEY) || + readToken("accessToken") || + readToken("meshkeeToken") || + readToken("token") + ); + }, []); + + const clearTokens = useCallback(() => { + writeToken(SHARED_ACCESS_TOKEN_KEY, null); + writeToken(SHARED_REFRESH_TOKEN_KEY, null); + writeToken("accessToken", null); + writeToken("meshkeeToken", null); + writeToken("token", null); + clearSharedAuthCookies(); + setUser(null); + }, []); + + useEffect(() => { + const token = getAccessToken(); + if (!token) { + setReady(true); + return; + } + + const refresh = readToken(SHARED_REFRESH_TOKEN_KEY); + writeSharedAuthCookies(token, refresh ?? undefined); + + fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } }) + .then(async (res) => { + if (res.status === 401) { + clearTokens(); + return; + } + if (!res.ok) return; + const data = (await res.json()) as { user?: AuthUser } & AuthUser; + const next = normalizeUser(data.user ?? data); + if (next) setUser(next); + }) + .catch(() => undefined) + .finally(() => setReady(true)); + }, [clearTokens, getAccessToken]); + + const logout = useCallback(() => { + clearTokens(); + }, [clearTokens]); + + const isWebsiteAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]); + + const value = useMemo( + () => ({ + user, + isAuthenticated: Boolean(user), + ready, + businessId, + isWebsiteAdmin, + logout, + getAccessToken, + }), + [user, ready, businessId, isWebsiteAdmin, logout, getAccessToken], + ); + + return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>; +} + +export function useSession() { + const ctx = useContext(SessionContext); + if (!ctx) throw new Error("useSession must be used within SessionProvider"); + return ctx; +} + +export function userDisplayName(user: AuthUser | null | undefined) { + if (!user) return "کاربر"; + const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim(); + return full || user.cellNumber || user.email || "کاربر"; +} diff --git a/src/lib/shared-auth-cookie.ts b/src/lib/shared-auth-cookie.ts new file mode 100644 index 0000000..7fff06b --- /dev/null +++ b/src/lib/shared-auth-cookie.ts @@ -0,0 +1,121 @@ +export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token"; +export const SHARED_REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token"; + +const CHUNK_SIZE = 2800; +const MAX_CHUNKS = 24; + +function cookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + const host = window.location.hostname.toLowerCase(); + if (host === "localhost" || host === "127.0.0.1") return undefined; + const parts = host.split(".").filter(Boolean); + if (parts.length < 2) return undefined; + return `.${parts.slice(-2).join(".")}`; +} + +function readRawCookie(name: string): string | null { + if (typeof document === "undefined") return null; + const prefix = `${name}=`; + const match = document.cookie.split("; ").find((row) => row.startsWith(prefix)); + if (!match) return null; + return match.slice(prefix.length); +} + +function writeRawCookie(name: string, value: string | null) { + if (typeof document === "undefined") return; + const domain = cookieDomain(); + if (value == null) { + let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`; + if (domain) cookie += `; domain=${domain}`; + document.cookie = cookie; + return; + } + const maxAge = 60 * 60 * 24 * 30; + let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`; + if (domain) cookie += `; domain=${domain}`; + document.cookie = cookie; +} + +function clearChunkedCookie(name: string) { + writeRawCookie(name, null); + const n = Number(readRawCookie(`${name}_n`) || "0"); + writeRawCookie(`${name}_n`, null); + for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) { + writeRawCookie(`${name}_${i}`, null); + } +} + +function writeCookie(name: string, value: string | null) { + clearChunkedCookie(name); + if (value == null || value === "") return; + + const encoded = encodeURIComponent(value); + if (encoded.length <= 3500) { + writeRawCookie(name, encoded); + return; + } + + const chunks: string[] = []; + for (let i = 0; i < encoded.length; i += CHUNK_SIZE) { + chunks.push(encoded.slice(i, i + CHUNK_SIZE)); + } + writeRawCookie(`${name}_n`, String(chunks.length)); + chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk)); +} + +function readCookie(name: string): string | null { + const n = Number(readRawCookie(`${name}_n`) || "0"); + if (n > 0) { + let encoded = ""; + for (let i = 0; i < n; i++) { + const part = readRawCookie(`${name}_${i}`); + if (part == null) return null; + encoded += part; + } + try { + return decodeURIComponent(encoded); + } catch { + return null; + } + } + + const raw = readRawCookie(name); + if (raw == null) return null; + try { + return decodeURIComponent(raw); + } catch { + return null; + } +} + +export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) { + writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken); + if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken); +} + +export function clearSharedAuthCookies() { + writeCookie(SHARED_ACCESS_TOKEN_KEY, null); + writeCookie(SHARED_REFRESH_TOKEN_KEY, null); +} + +export function readSharedAccessToken(): string | null { + return readCookie(SHARED_ACCESS_TOKEN_KEY); +} + +export function readSharedRefreshToken(): string | null { + return readCookie(SHARED_REFRESH_TOKEN_KEY); +} + +export function hydrateLocalTokensFromSharedCookies( + accessKey = SHARED_ACCESS_TOKEN_KEY, + refreshKey = SHARED_REFRESH_TOKEN_KEY, +): boolean { + if (typeof window === "undefined") return false; + if (localStorage.getItem(accessKey)) return false; + const access = readSharedAccessToken(); + if (!access) return false; + localStorage.setItem(accessKey, access); + const refresh = readSharedRefreshToken(); + if (refresh) localStorage.setItem(refreshKey, refresh); + return true; +} diff --git a/src/lib/site.ts b/src/lib/site.ts index 44bd68f..655dffe 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -2,8 +2,7 @@ export const SITE = { name: "سوهان صالح نیا", nameEn: "Sohan Salehnia", url: "https://www.sohansalehnia.com", - loginUrl: - "https://app.sohansalehnia.com/auth/login?redirect=http://www.sohansalehnia.com", + loginUrl: "https://customer.sohansalehnia.com", description: "سوهان صالح نیا، تولید و عرضه انواع سوهان سنتی قم با طعم تازه و اصیل.", primary: "#706C01", diff --git a/src/lib/sitemap-config.generated.json b/src/lib/sitemap-config.generated.json new file mode 100644 index 0000000..124a6a1 --- /dev/null +++ b/src/lib/sitemap-config.generated.json @@ -0,0 +1,33 @@ +{ + "baseUrl": "https://sohansalehnia.com", + "staticPages": [ + { + "path": "/", + "changefreq": "daily", + "priority": 1 + }, + { + "path": "/aboutus", + "changefreq": "monthly", + "priority": 0.6 + }, + { + "path": "/articles", + "changefreq": "weekly", + "priority": 0.8 + }, + { + "path": "/contactus", + "changefreq": "monthly", + "priority": 0.5 + }, + { + "path": "/products", + "changefreq": "weekly", + "priority": 0.9 + } + ], + "templates": { + "blog": "/articles/{id}/{titleSlug}" + } +} diff --git a/src/lib/slugify.ts b/src/lib/slugify.ts new file mode 100644 index 0000000..bcf10d7 --- /dev/null +++ b/src/lib/slugify.ts @@ -0,0 +1,15 @@ +/** Public URL slug: keep Farsi letters, Latin, digits; spaces → -; strip other punctuation. */ +export function slugifyFa(text: string, fallback = ""): string { + const slug = text + .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; +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts new file mode 100644 index 0000000..4b7b0de --- /dev/null +++ b/src/lib/urls.ts @@ -0,0 +1,19 @@ +import type { Post, Product } from "@/lib/meshkee"; +import { postTitle, productName } from "@/lib/meshkee"; +import { slugifyFa } from "@/lib/slugify"; + +export function productSlug(product: Pick<Product, "nameFa" | "title">): string { + return slugifyFa(productName(product as Product), "product"); +} + +export function productPath(product: Pick<Product, "id" | "nameFa" | "title">): string { + return `/products/${product.id}/${encodeURIComponent(productSlug(product))}`; +} + +export function blogSlug(post: Post): string { + return slugifyFa(postTitle(post), "article"); +} + +export function blogPath(post: Post): string { + return `/articles/${post.id}/${encodeURIComponent(blogSlug(post))}`; +}