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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
4ca234b20f
commit
11fd1cf454
+71
-36
@@ -2,50 +2,85 @@
|
|||||||
|
|
||||||
## Project
|
## 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`.
|
- Next.js 16 App Router, React 19, TypeScript, Tailwind 4, `src/app`.
|
||||||
- Language: `fa`, direction: `rtl`.
|
- 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
|
## Auth / account
|
||||||
- `/aboutus` about
|
|
||||||
- `/contactus` contact (form is client-side only; no backend submit yet)
|
- Guest login/user icon → `https://customer.sohansalehnia.com`
|
||||||
- `/products` and `/articles` are placeholders. Products, workshops, and blog will be read from Meshkee later.
|
- 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 `<title>`, 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
|
## Brand
|
||||||
|
|
||||||
| Token | Value | Use |
|
| Token | Value |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| Primary | `#706C01` | links, buttons, product titles, branch details |
|
| Primary | `#706C01` |
|
||||||
| Gold | `#868215` | section accents, counters, English labels |
|
| Gold | `#868215` |
|
||||||
| Page | `#F9F9F9` | page background, header, footer |
|
| Page | `#F9F9F9` |
|
||||||
| Heading | `#4B4B4B` | titles |
|
| Heading | `#4B4B4B` |
|
||||||
| Muted | `#5E5E5E` | labels, footer copy |
|
| Muted | `#5E5E5E` |
|
||||||
| Icon gray | `#939393` | social icons |
|
|
||||||
|
|
||||||
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
|
- 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`).
|
||||||
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.
|
|
||||||
- Keep `CONTEXT.md` updated when routes, brand tokens, or data sources change.
|
- Keep `CONTEXT.md` updated when routes, brand tokens, or data sources change.
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
formats: ["image/avif", "image/webp"],
|
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" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -3,10 +3,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prebuild": "node scripts/generate-sitemap-config.mjs",
|
||||||
"dev": "next dev --port 3001",
|
"dev": "next dev --port 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start --port 3001",
|
"start": "next start --port 3001",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import AboutPreview from "@/components/AboutPreview";
|
import AboutPreview from "@/components/AboutPreview";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import PromoBanner from "@/components/PromoBanner";
|
import PromoBanner from "@/components/PromoBanner";
|
||||||
import SohanFeature from "@/components/SohanFeature";
|
import SohanFeature from "@/components/SohanFeature";
|
||||||
import Stats from "@/components/Stats";
|
import Stats from "@/components/Stats";
|
||||||
import WhyUs from "@/components/WhyUs";
|
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: "درباره ما",
|
title: "درباره ما",
|
||||||
description: SITE.description,
|
description: ABOUT.body,
|
||||||
};
|
path: "/aboutus",
|
||||||
|
image: "/images/about.png",
|
||||||
|
});
|
||||||
|
|
||||||
export default function AboutPage() {
|
export default function AboutPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,51 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import PageHeader from "@/components/PageHeader";
|
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: "مقالات",
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader kicker="مقالات" title="سوهان صالح نیا" english="Articles" />
|
<PageHeader kicker="مقالات" title="سوهان صالح نیا" english="Articles" />
|
||||||
<section className="bg-page py-20">
|
<section className="bg-page py-16">
|
||||||
<Container className="text-center">
|
<Container>
|
||||||
<p className="text-lg text-muted">
|
{items.length > 0 ? (
|
||||||
مقالات بهزودی از طریق بکاند مشکی بارگذاری میشود.
|
<>
|
||||||
</p>
|
<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>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import ContactForm from "@/components/ContactForm";
|
import ContactForm from "@/components/ContactForm";
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import PageHeader from "@/components/PageHeader";
|
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: "تماس با ما",
|
title: "تماس با ما",
|
||||||
description: SITE.description,
|
description: `${CONTACT_PAGE.intro}. ${FOOTER.branches.map((branch) => `${branch.title}: ${branch.phone}`).join("، ")}`,
|
||||||
};
|
path: "/contactus",
|
||||||
|
});
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
return (
|
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="flex flex-col items-start gap-10 lg:flex-row lg:items-center">
|
||||||
<div className="w-full lg:w-2/3">
|
<div className="w-full lg:w-2/3">
|
||||||
<div className="mb-8">
|
<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="text-2xl text-gold">{CONTACT_PAGE.title}</p>
|
||||||
<p className="mt-2 text-muted">{CONTACT_PAGE.intro}</p>
|
<p className="mt-2 text-muted">{CONTACT_PAGE.intro}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +33,7 @@ export default function ContactPage() {
|
|||||||
style={{ color: "rgb(185, 185, 185)" }}
|
style={{ color: "rgb(185, 185, 185)" }}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h5>{item.title}</h5>
|
<p className="font-medium text-heading">{item.title}</p>
|
||||||
<a href={item.href} dir="ltr" className="text-primary">
|
<a href={item.href} dir="ltr" className="text-primary">
|
||||||
{item.value}
|
{item.value}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -495,3 +495,110 @@ ul {
|
|||||||
text-align: center;
|
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%;
|
||||||
|
}
|
||||||
|
|||||||
+30
-15
@@ -1,32 +1,47 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Footer from "@/components/Footer";
|
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 { SITE } from "@/lib/site";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
const defaultFavicon = "/images/logo.png";
|
||||||
metadataBase: new URL(SITE.url),
|
|
||||||
title: {
|
|
||||||
default: SITE.name,
|
|
||||||
template: `%s | ${SITE.name}`,
|
|
||||||
},
|
|
||||||
description: SITE.description,
|
|
||||||
icons: {
|
|
||||||
icon: "/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,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const businessId = await getBusinessId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl">
|
<html lang="fa" dir="rtl">
|
||||||
<body className="flex min-h-screen flex-col antialiased">
|
<body className="flex min-h-screen flex-col antialiased">
|
||||||
<Header />
|
<Providers businessId={businessId}>
|
||||||
<main className="flex-1">{children}</main>
|
<Header />
|
||||||
<Footer />
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-2
@@ -4,8 +4,16 @@ import AboutPreview from "@/components/AboutPreview";
|
|||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import PromoBanner from "@/components/PromoBanner";
|
import PromoBanner from "@/components/PromoBanner";
|
||||||
import SohanFeature from "@/components/SohanFeature";
|
import SohanFeature from "@/components/SohanFeature";
|
||||||
|
import { pageMetadata } from "@/lib/seo";
|
||||||
import { PRODUCT_TYPES, SITE } from "@/lib/site";
|
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() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -25,10 +33,10 @@ export default function HomePage() {
|
|||||||
<section className="bg-page py-16">
|
<section className="bg-page py-16">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="soft-shadow flex flex-col flex-wrap items-center rounded-[30px] px-4 py-8 lg:px-8">
|
<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-[20px] text-muted">انواع محصولات</span>
|
||||||
<span className="text-[24px] text-primary">{SITE.name}</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">
|
<div className="flex w-full flex-col items-center justify-between gap-8 lg:flex-row">
|
||||||
{PRODUCT_TYPES.map((item, index) => (
|
{PRODUCT_TYPES.map((item, index) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,52 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import PageHeader from "@/components/PageHeader";
|
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: "محصولات",
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader kicker="محصولات" title="سوهان صالح نیا" english="Products" />
|
<PageHeader kicker="محصولات" title="سوهان صالح نیا" english="Products" />
|
||||||
<section className="bg-page py-20">
|
<section className="bg-page py-16">
|
||||||
<Container className="text-center">
|
<Container>
|
||||||
<p className="text-lg text-muted">
|
{items.length > 0 ? (
|
||||||
فهرست محصولات بهزودی از طریق بکاند مشکی بارگذاری میشود.
|
<>
|
||||||
</p>
|
<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>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ export default function AboutPreview() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full lg:w-2/3">
|
<div className="w-full lg:w-2/3">
|
||||||
<Image
|
<Image
|
||||||
src="/images/logo.png"
|
src="/images/logo.png"
|
||||||
alt=""
|
alt="سوهان صالح نیا"
|
||||||
width={125}
|
width={125}
|
||||||
height={40}
|
height={40}
|
||||||
/>
|
/>
|
||||||
<p className="my-6 leading-8 text-black">{ABOUT.body}</p>
|
<p className="my-6 leading-8 text-black">{ABOUT.body}</p>
|
||||||
<div className="text-start">
|
<div className="text-start">
|
||||||
<Link href="/contactus" className="button-style-2 pb-1">
|
<Link href="/contactus" className="button-style-2 pb-1">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export default function Footer() {
|
|||||||
<div className="grid gap-8 sm:grid-cols-2">
|
<div className="grid gap-8 sm:grid-cols-2">
|
||||||
{FOOTER.branches.map((branch) => (
|
{FOOTER.branches.map((branch) => (
|
||||||
<div key={branch.title}>
|
<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 className="mt-3 flex flex-wrap gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-muted">آدرس ما</p>
|
<p className="mb-2 text-muted">آدرس ما</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import AccountMenu from "@/components/AccountMenu";
|
||||||
import { NAV_ITEMS, SITE } from "@/lib/site";
|
import { NAV_ITEMS, SITE } from "@/lib/site";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
@@ -86,13 +87,7 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-[41.66%] justify-end">
|
<div className="flex w-[41.66%] justify-end">
|
||||||
<a
|
<AccountMenu />
|
||||||
href={SITE.loginUrl}
|
|
||||||
className="button-light px-3 py-1 text-[1.2rem] leading-none"
|
|
||||||
>
|
|
||||||
ورود
|
|
||||||
<i className="WMi-login" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,13 +113,7 @@ export default function Header() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<AccountMenu />
|
||||||
href={SITE.loginUrl}
|
|
||||||
className="button-light mt-2 px-3 py-1 text-[1.2rem] leading-none"
|
|
||||||
>
|
|
||||||
ورود
|
|
||||||
<i className="WMi-login" />
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -28,13 +28,12 @@ export default function PageHeader({
|
|||||||
{english}
|
{english}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 w-fit bg-page ps-3">
|
<h1 className="relative z-10 w-fit bg-page ps-3">
|
||||||
<h3 className="text-[25px] font-normal text-heading">{kicker}</h3>
|
<span className="block text-[25px] font-normal text-heading">{kicker}</span>
|
||||||
<p className="text-[30px] leading-tight text-gold">{title}</p>
|
<span className="block text-[30px] leading-tight text-gold">{title}</span>
|
||||||
</div>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="sr-only">{kicker}</h1>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export default function PromoBanner() {
|
|||||||
<Link href="/products" className="hidden p-12 lg:block">
|
<Link href="/products" className="hidden p-12 lg:block">
|
||||||
<Image
|
<Image
|
||||||
src="/images/promo-banner.png"
|
src="/images/promo-banner.png"
|
||||||
alt="سوهان صالح نیا"
|
alt="محصولات سوهان صالح نیا"
|
||||||
width={904}
|
width={904}
|
||||||
height={234}
|
height={234}
|
||||||
className="my-3 w-full rounded-[20px]"
|
className="my-3 w-full rounded-[20px]"
|
||||||
@@ -18,7 +18,7 @@ export default function PromoBanner() {
|
|||||||
<Link href="/products" className="lg:hidden">
|
<Link href="/products" className="lg:hidden">
|
||||||
<Image
|
<Image
|
||||||
src="/images/promo-banner.png"
|
src="/images/promo-banner.png"
|
||||||
alt="سوهان صالح نیا"
|
alt="محصولات سوهان صالح نیا"
|
||||||
width={904}
|
width={904}
|
||||||
height={234}
|
height={234}
|
||||||
className="my-3 w-full rounded-[10px]"
|
className="my-3 w-full rounded-[10px]"
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export default function SohanFeature() {
|
|||||||
style={{ filter: "drop-shadow(17px 31px 20px rgb(0, 0, 0, .5))" }}
|
style={{ filter: "drop-shadow(17px 31px 20px rgb(0, 0, 0, .5))" }}
|
||||||
/>
|
/>
|
||||||
<div>
|
<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>
|
<p className="text-[30px] text-gold">{SOHAN_FEATURE.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export default function WhyUs() {
|
|||||||
return (
|
return (
|
||||||
<section className="bg-page py-16">
|
<section className="bg-page py-16">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="mb-8 flex flex-col items-center p-1">
|
<h2 className="mb-8 flex flex-col items-center p-1">
|
||||||
<h3 className="text-[25px] text-heading">{WHY_US.kicker}</h3>
|
<span className="text-[25px] text-heading">{WHY_US.kicker}</span>
|
||||||
<span className="inline-block text-[25px] text-gold">{WHY_US.title}</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="flex flex-col items-center justify-around lg:flex-row">
|
||||||
<div className="w-full lg:w-1/4">
|
<div className="w-full lg:w-1/4">
|
||||||
<WhyItem {...leftTop} bordered />
|
<WhyItem {...leftTop} bordered />
|
||||||
|
|||||||
@@ -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(/\/$/, "");
|
||||||
@@ -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)]);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 || "کاربر";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+1
-2
@@ -2,8 +2,7 @@ export const SITE = {
|
|||||||
name: "سوهان صالح نیا",
|
name: "سوهان صالح نیا",
|
||||||
nameEn: "Sohan Salehnia",
|
nameEn: "Sohan Salehnia",
|
||||||
url: "https://www.sohansalehnia.com",
|
url: "https://www.sohansalehnia.com",
|
||||||
loginUrl:
|
loginUrl: "https://customer.sohansalehnia.com",
|
||||||
"https://app.sohansalehnia.com/auth/login?redirect=http://www.sohansalehnia.com",
|
|
||||||
description:
|
description:
|
||||||
"سوهان صالح نیا، تولید و عرضه انواع سوهان سنتی قم با طعم تازه و اصیل.",
|
"سوهان صالح نیا، تولید و عرضه انواع سوهان سنتی قم با طعم تازه و اصیل.",
|
||||||
primary: "#706C01",
|
primary: "#706C01",
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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))}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user