Add the Kaman Cable Next.js storefront with Meshkee catalog, blog, auth, and static-image slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-31 17:01:01 +03:30
co-authored by Cursor
commit 51fb9f6a26
84 changed files with 12999 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts
+160
View File
@@ -0,0 +1,160 @@
# Kaman Cable — AI / Project Context
## Source of truth
- Live reference: https://www.kamancable.ir/
- Backend / Meshkee Website API: https://api.meshkee.com/docs/website
- Domain: `kamancable.ir` (`NEXT_PUBLIC_WEBSITE_DOMAIN`)
- Tenant business id: `24` (`GET /tenants/kamancable.ir`)
- Customer portal: `https://customer.kamancable.ir`
- Business dashboard: `https://business.kamancable.ir`
- Catalog and blog content come from Meshkee (`enabledModules`: products, blog). Business-info / logos in the API may be empty — brand copy and some homepage photos still live in `src/data/site.ts` and `public/images/`.
## Stack
- Next.js 15 (App Router) + React 19 + TypeScript
- Tailwind CSS v4 (utility import) + custom CSS in `src/app/globals.css`
- Language: `fa`, direction: `rtl`
- Fonts:
- `iran-yekan` from Meshkee CDN (Persian UI / body)
- `Rajdhani` via `next/font/google` (English UI, class `.en`)
- `Oswald` via `next/font/google` (`--font-en-display` for section titles / phone)
## Color palette
- Dark navy: `#1C1E40`
- White: `#FFFFFF`
- Gold accent: `#E1A34C`
- Highlight yellow: `#FFDE30`
- Muted text: `#C8CCE0`
## Pages
| Route | Purpose |
|-------|---------|
| `/` | Homepage (hero, app CTA, about teaser, contact bar, category cards, blog carousel, clients) |
| `/products` | Product catalog from `GET /tenants/{domain}/products` |
| `/products/{id}/{nameFaSlug}` | Product detail (`GET .../products/by-id/{id}`) |
| `/products/category/{id}/{nameFaSlug}` | Category (`GET .../categories/by-id/{id}`, then products with `categoryId`) |
| `/blog` | Blog index from `GET /tenants/{domain}/blogs` |
| `/blog/{id}/{titleSlug}` | Blog detail (`GET .../blogs/by-id/{id}`) |
| `/about` | درباره ما |
| `/contact` | تماس با ما |
Header (RTL): logo + menu on the right; search, language (FA/EN), and account on the left.
- Signed out: icon links to `https://customer.kamancable.ir`
- Signed in: `{firstName} عزیز` dropdown — پروفایل من, خروج, and مدیریت وبسایت if the user is a website admin
- Session: shared cookies `meshkee_customer_access_token` / refresh, hydrated into localStorage; `/api/auth/me` proxies `GET /auth/me`
Homepage product cards are Meshkee `categories` slot teasers (duplicatable; empty API keeps `src/data/site.ts`). The full catalog is `/products`.
Homepage blog carousel prefers Meshkee blogs, with `src/data/site.ts` as fallback.
## Meshkee API integration
Base: `{NEXT_PUBLIC_MESHKEE_API_BASE}/tenants/{domain}/...`
Helpers: `src/lib/meshkee.ts` → `meshkeeGet`, `meshkeeGetOrNull`, `tenantPath`, `getTenant`, `getBusinessId`
### Products
- List: `GET /tenants/{domain}/products?page&pageSize&categoryId`
- Detail: `GET /tenants/{domain}/products/by-id/{id}` → `{ product, relatedProducts }`
- Category: `GET /tenants/{domain}/categories?entityType=product` and `GET .../categories/by-id/{id}`
- Specs: `GET /tenants/{domain}/products/by-id/{id}/technical-info` — join `form.fields[].id` ↔ `values[].fieldId`. Do not invent field labels from the detail payload.
- Helpers: `src/lib/products.ts`, `src/lib/categories.ts`, `src/lib/technical.ts`
- Detail UI: lightbox gallery, hover zoom, tabs (توضیحات / مشخصات فنی / نظرات), similar products row
- Public slugs are built from `nameFa` / title (Farsi kept, spaces → `-`). Never use the legacy DB `slug` in the URL.
### Blog
- List: `GET /tenants/{domain}/blogs?page&pageSize`
- Detail: `GET /tenants/{domain}/blogs/by-id/{id}` → `{ blog }`
- Helpers: `src/lib/blog.ts`
- Empty API → local posts in `src/data/site.ts`
### Comments
- List: `GET /tenants/{domain}/comments?entityType=product|blog&entityId=`
- Submit: website proxy `POST /api/comments` → Meshkee comments
- UI: authenticated users get a form; guests see a login box to the customer portal (`src/components/CommentBox.tsx`)
### Contact
- Public POST: `/tenants/kamancable.ir/contact-submissions`
- Website proxy: `POST /api/contact`
- Body: `{ title, name, text, email?, cellNumber? }`
- Homepage callback strip also posts through this route
### Auth proxies
- `GET /api/auth/me` → `GET /auth/me`
- `POST /api/auth/handoff` → `POST /auth/handoff` (admin dashboard)
## Static image slots
API: `GET /tenants/{domain}/website/static-images?pageKey=home|about|contact|products|blog`
Docs: https://api.meshkee.com/docs/website (not http://meshkee.com/docs/website)
Catalog: `GET /meshkee/static-image-slots` → `STATIC_IMAGE_SLOTS` in `src/lib/static-images.ts`
Helpers: `fetchStaticImageSlots`, `pickSlot` (aliases for renamed keys), `overlaySlotImages`, `slotRatioStyle`
Homepage placeholders (match Meshkee dashboard keys exactly):
| key | kind | itemCount | aspectRatio | recommendedWidth | section |
| --- | --- | --- | --- | --- | --- |
| `slider` | list (duplicatable) | null | `1920:830` | 1440 | Homepage hero (72vh cover crop) |
| `about-us-image` | single | 1 | `440:609` | 480 | Homepage about photo |
| `categories` | list (duplicatable) | null | `3:4` | 480 | Homepage product cards (`.product-card .thumb`) |
| `partners` | list (duplicatable) | null | `3:2` | 280 | Homepage partner logos |
| `home-blog` | list | 6 | `16:10` | 720 | Homepage blog carousel |
| `about-hero` | single | 1 | `1920:640` | 1440 | About hero |
| `about-gallery` | list | 6 | `4:3` | 720 | About gallery |
| `contact-hero` | single | 1 | `1920:480` | 1440 | Contact hero |
| `products-hero` | single | 1 | `1920:480` | 1440 | Products page hero |
| `blog-hero` | single | 1 | `1920:480` | 1440 | Blog page hero |
Legacy aliases (still resolved by `pickSlot` if the API has not been re-synced): `home-about-image` → `about-us-image`, `home-products` → `categories`, `home-clients` → `partners`.
Rules:
- Use `slot.images` (all if `kind=list`, first if `kind=single`)
- `url` → image src; `linkUrl` → wrap in `SlotLink`
- Empty / fail → keep local fallbacks from `src/data/site.ts` / `public/images/`
- Apply `slot.aspectRatio` as CSS `--slot-ratio` (`slotRatioStyle`); do not invent upload APIs
- **If a slot’s on-page size or aspect ratio changes in CSS, update `STATIC_IMAGE_SLOTS` in the same change** (Meshkee’s static-image reader uses this catalog)
## Content & assets
- Site copy, phones, address, category blurbs: `src/data/site.ts`
- Logo: `public/images/logo/logo.png`
- Hero banners: `public/images/slider/bg1.jpg` … `bg3.jpg`
- Favicon: `/favicon.png`
- Contact: `021-242624` · دفتر تهران، خیابان کریم‌خان زند · کارخانه شهر صنعتی شماره یک زنجان
- App: https://kamancable.com/applanding/
## Important paths
- Layout / shell: `src/app/layout.tsx`, `src/components/Header.tsx`, `src/components/AccountMenu.tsx`, `src/components/Footer.tsx`
- Home: `src/app/page.tsx`, `src/components/HeroSlider.tsx`, `src/components/ContactBar.tsx`
- Products: `src/app/products/page.tsx`, `src/app/products/[id]/[slug]/page.tsx`
- Blog: `src/app/blog/page.tsx`, `src/app/blog/[id]/[slug]/page.tsx`
- Config: `src/lib/config.ts`
## Env
```bash
NEXT_PUBLIC_WEBSITE_DOMAIN=kamancable.ir
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
```
## Commands
```bash
npm install
npm run dev
npm run build
```
## Sitemap + robots
- **Do not** add `public/sitemap.xml` or `public/robots.txt` (nginx proxies both to the Meshkee API)
- Live sitemap is an index: `/sitemap.xml` → `/sitemap-main.xml` + `/sitemap-products.xml`
- Publish `GET /meshkee/sitemap-config.json` (generated at build from app routes)
- Includes public static routes automatically; excludes `api` / `meshkee` / `auth` / `checkout` / `cart` / `admin` / `account` / `login`
- Generated at build: `scripts/generate-sitemap-config.mjs` → `src/lib/sitemap-config.generated.json`
- Run manually: `npm run generate:sitemap-config`
- After deploy: business dashboard → Website → Settings → Sync sitemap config (needed for static pages)
### Canonical CMS URLs (id is canonical; slug is decorative and redirects if stale)
- Product: `/products/{id}/{nameFaSlug}` → `GET /tenants/{domain}/products/by-id/{id}`
- Category: `/products/category/{id}/{nameFaSlug}` → `GET /tenants/{domain}/categories/by-id/{id}` then list products with `categoryId`
- Blog: `/blog/{id}/{titleSlug}` → `GET /tenants/{domain}/blogs/by-id/{id}`
- Portfolio is not enabled for this tenant (`enabledModules`: products, blog, finance)
### On-page SEO rules
- Unique `<title>` + meta description on every crawlable page (`src/lib/seo.ts` → `pageMetadata`); paginated list pages include the page number
- Prefer CMS `nameFa` / `title` / `abstract` / `summary` / `descriptionHtml`; never leave Next.js defaults
- Exactly one `<h1>` per page; section headings use `<h2>` / `<h3>`
- Content images have meaningful `alt` (CMS title when available)
- Slugify: Farsi letters kept, spaces → `-`, other punctuation stripped (`src/lib/slug.ts`)
+14
View File
@@ -0,0 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
export default eslintConfig;
+26
View File
@@ -0,0 +1,26 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
},
{
protocol: "https",
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
},
{
protocol: "https",
hostname: "c804387.parspack.net",
},
{
protocol: "https",
hostname: "cdn.meshkee.com",
},
],
},
};
export default nextConfig;
+6263
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "kaman-cable",
"version": "0.1.0",
"private": true,
"scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
},
"dependencies": {
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.15.21",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"eslint": "^9.28.0",
"eslint-config-next": "^15.3.3",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+98
View File
@@ -0,0 +1,98 @@
/**
* 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 },
"/about": { changefreq: "monthly", priority: 0.6 },
"/contact": { changefreq: "monthly", priority: 0.5 },
"/products": { changefreq: "weekly", priority: 0.8 },
"/blog": { changefreq: "weekly", priority: 0.7 },
};
function websiteDomain() {
return (
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ||
"kamancable.ir"
);
}
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,
};
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();
+110
View File
@@ -0,0 +1,110 @@
import SlotImage from "@/components/SlotImage";
import {
aboutGallery,
aboutIntro,
laboratoryText,
machineryText,
qualityPolicy,
site,
stats,
} from "@/data/site";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
export const metadata = pageMetadata({
title: "درباره ما",
description:
"آشنایی با شرکت صنایع کابل کمان؛ تولیدکننده سیم و کابل فشار ضعیف، مخابراتی و ابزار دقیق از سال ۱۳۷۰ در زنجان.",
path: "/about",
image: "/images/samples/cables-coils.jpg",
});
export default async function AboutPage() {
const slots = await fetchStaticImageSlots("about");
const hero = overlaySlotImages(
pickSlot(slots, "about-hero"),
[{ id: "about-hero", image: "/images/samples/cables-coils.jpg", title: "درباره ما" }],
"about-hero",
);
const gallery = overlaySlotImages(
pickSlot(slots, "about-gallery"),
aboutGallery.map((item) => ({ id: item.id, image: item.image, title: item.title })),
"about-gallery",
);
return (
<>
<section className="page-hero">
<SlotImage src={hero.items[0]?.image || "/images/samples/cables-coils.jpg"} alt={hero.items[0]?.title || "درباره صنایع کابل کمان"} fill />
<div className="container">
<h1>درباره ما</h1>
<p className="en">About Us</p>
</div>
</section>
<section className="section">
<div className="container about-blocks">
<div className="stats">
{stats.map((stat) => (
<article key={stat.label} className="stat">
<strong>{stat.value}</strong>
<div>{stat.label}</div>
<p className="en">{stat.labelEn}</p>
</article>
))}
</div>
<article className="about-block">
<h2>شرکت کابل کمان</h2>
<p className="muted">{aboutIntro}</p>
</article>
<article className="about-block">
<h2>اهداف</h2>
<p className="en">Quality Policy</p>
<p className="muted">صداقت بهترین روش است.</p>
<p className="muted">
شرکت صنایع کابل کمان به‌عنوان یکی از تولیدکنندگان سیم و کابل، رضایت مشتریان را از طریق ارتقاء کیفیت و
رعایت کلیه قوانین و الزامات حاکم بر محصول خود، به‌عنوان برترین هدف برگزیده است.
</p>
<ol className="muted">
{qualityPolicy.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
</article>
<article className="about-block">
<h2>آزمایشگاه صنایع کابل کمان</h2>
<p className="en">Laboratory</p>
<p className="muted">{laboratoryText}</p>
</article>
<article className="about-block">
<h2>تجهیزات و ماشین‌آلات</h2>
<p className="en">Equipment & Machinery</p>
<p className="muted">{machineryText}</p>
</article>
<article className="about-block">
<h2>ضمانت‌نامه شرکت</h2>
<p className="en">Guarantee</p>
<p className="muted">{machineryText}</p>
</article>
<article className="about-block">
<h2>گالری</h2>
<p className="en">Gallery</p>
<div className="gallery">
{gallery.items.map((item) => (
<div key={item.id} className="gallery-item">
<SlotImage src={item.image} alt={item.title || site.shortName} />
</div>
))}
</div>
</article>
</div>
</section>
</>
);
}
+17
View File
@@ -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);
}
+10
View File
@@ -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);
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function POST(request: Request) {
const body = (await request.json()) as {
entityType?: string;
entityId?: string;
authorName?: string;
authorEmail?: string;
text?: string;
};
if (!body.entityType?.trim() || !body.entityId?.trim() || !body.authorName?.trim() || !body.text?.trim()) {
return NextResponse.json({ message: "نام و متن نظر الزامی است." }, { status: 400 });
}
const response = await fetch(`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entityType: body.entityType.trim(),
entityId: body.entityId.trim(),
authorName: body.authorName.trim(),
authorEmail: body.authorEmail?.trim() || undefined,
text: body.text.trim(),
}),
cache: "no-store",
});
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function POST(request: Request) {
const body = (await request.json()) as {
title?: string;
name?: string;
email?: string;
cellNumber?: string;
text?: string;
};
if (!body.title?.trim() || !body.name?.trim() || !body.text?.trim()) {
return NextResponse.json({ message: "نام، موضوع و متن پیام الزامی است." }, { status: 400 });
}
const response = await fetch(
`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/contact-submissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: body.title.trim(),
name: body.name.trim(),
email: body.email?.trim() || undefined,
cellNumber: body.cellNumber?.trim() || undefined,
text: body.text.trim(),
}),
cache: "no-store",
},
);
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import CommentBox from "@/components/CommentBox";
import SlotImage from "@/components/SlotImage";
import {
blogDate,
blogHref,
blogImage,
blogTitleSlug,
fetchBlogById,
getLocalBlogPost,
} from "@/lib/blog";
import { fetchComments } from "@/lib/comments";
import { sanitizeHtml } from "@/lib/format";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
type Params = { id: string; slug: string };
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { id } = await params;
const post = (await fetchBlogById(id)) ?? getLocalBlogPost(id);
if (!post) {
return {
title: "مقاله یافت نشد",
description: "این مطلب در وبلاگ صنایع کابل کمان موجود نیست.",
robots: { index: false },
};
}
return pageMetadata({
title: post.title,
description: post.abstract?.trim() || `${post.title} | وبلاگ صنایع سیم و کابل کمان`,
path: blogHref(post),
image: blogImage(post),
});
}
export default async function BlogDetailPage({ params }: { params: Promise<Params> }) {
const { id, slug } = await params;
const apiPost = await fetchBlogById(id);
const post = apiPost ?? getLocalBlogPost(id);
if (!post) notFound();
const canonical = blogTitleSlug(post.title);
if (decodeURIComponent(slug) !== canonical) {
redirect(blogHref(post));
}
const comments = apiPost ? await fetchComments("blog", post.id) : [];
const html = sanitizeHtml(post.mainTextHtml);
return (
<>
<section className="page-hero">
<SlotImage src={blogImage(post)} alt={post.title} fill />
<div className="container">
<h1>{post.title}</h1>
{blogDate(post) ? <p className="en">{blogDate(post)}</p> : <p className="en">Blog</p>}
</div>
</section>
<section className="section">
<div className="container detail-article">
{post.abstract ? <p className="detail-lead">{post.abstract}</p> : null}
{html ? <div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} /> : null}
<section className="detail-tabs">
<h2>نظرات</h2>
{apiPost ? (
<CommentBox entityType="blog" entityId={post.id} comments={comments} />
) : (
<p className="comment-empty">ثبت نظر برای این مطلب در دسترس نیست.</p>
)}
</section>
<Link href="/blog" className="about-contact-link">
بازگشت به وبلاگ
</Link>
</div>
</section>
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import type { Metadata } from "next";
import Link from "next/link";
import SlotImage from "@/components/SlotImage";
import SlotLink from "@/components/SlotLink";
import { blogDate, blogHref, blogImage, fetchBlogs, getLocalBlogPosts } from "@/lib/blog";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
export const revalidate = 60;
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}): Promise<Metadata> {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
return pageMetadata({
title: page > 1 ? `وبلاگ — صفحه ${page}` : "وبلاگ",
description: "آخرین اخبار و مقالات فنی صنایع سیم و کابل کمان.",
path: page > 1 ? `/blog?page=${page}` : "/blog",
image: "/images/samples/production-line.avif",
});
}
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const [result, slots] = await Promise.all([fetchBlogs({ page, pageSize: 12 }), fetchStaticImageSlots("blog")]);
const hero = overlaySlotImages(
pickSlot(slots, "blog-hero"),
[{ id: "blog-hero", image: "/images/samples/production-line.avif", title: "وبلاگ" }],
"blog-hero",
);
const usingApi = result.items.length > 0;
const posts = usingApi ? result.items : page === 1 ? getLocalBlogPosts() : [];
const pageCount = usingApi
? Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)))
: 1;
return (
<>
<section className="page-hero">
<SlotImage src={hero.items[0]?.image || "/images/samples/production-line.avif"} alt={hero.items[0]?.title || "وبلاگ کابل کمان"} fill />
<div className="container">
<h1>وبلاگ</h1>
<p className="en">Blog</p>
</div>
</section>
<section className="section">
<div className="container">
{posts.length === 0 ? (
<p className="comment-empty">مقاله‌ای برای نمایش وجود ندارد.</p>
) : (
<div className="blog-grid">
{posts.map((post) => (
<article key={post.id} className="blog-card">
<div className="blog-card-media">
<SlotLink href={blogHref(post)} className="blog-card-hit">
<SlotImage src={blogImage(post)} alt={post.title} fill />
</SlotLink>
</div>
<div className="blog-card-body">
{blogDate(post) ? <p className="blog-date">{blogDate(post)}</p> : null}
<h2>
<SlotLink href={blogHref(post)}>{post.title}</SlotLink>
</h2>
{post.abstract ? <p className="blog-excerpt">{post.abstract}</p> : null}
<SlotLink href={blogHref(post)} className="blog-more">
ادامه مطلب
</SlotLink>
</div>
</article>
))}
</div>
)}
{pageCount > 1 ? (
<nav className="pagination" aria-label="صفحه‌بندی وبلاگ">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
if (pageCount > 12 && Math.abs(number - page) > 3 && number !== 1 && number !== pageCount) {
return number === 2 || number === pageCount - 1 ? <span key={number}>…</span> : null;
}
return (
<Link
key={number}
href={number === 1 ? "/blog" : `/blog?page=${number}`}
className={number === page ? "active" : undefined}
>
{number}
</Link>
);
})}
</nav>
) : null}
</div>
</section>
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
import ContactForm from "@/components/ContactForm";
import SlotImage from "@/components/SlotImage";
import { site } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
export const metadata = pageMetadata({
title: "تماس با ما",
description: `ارتباط با صنایع کابل کمان. دفتر تهران: ${site.address}. تلفن ${site.phoneFa}.`,
path: "/contact",
image: "/images/slider/bg3.jpg",
});
export default async function ContactPage() {
const slots = await fetchStaticImageSlots("contact");
const hero = overlaySlotImages(
pickSlot(slots, "contact-hero"),
[{ id: "contact-hero", image: "/images/slider/bg3.jpg", title: "تماس با ما" }],
"contact-hero",
);
return (
<>
<section className="page-hero">
<SlotImage src={hero.items[0]?.image || "/images/slider/bg3.jpg"} alt={hero.items[0]?.title || "تماس با صنایع کابل کمان"} fill />
<div className="container">
<h1>با ما در ارتباط باشید</h1>
<p className="en">Contact Us</p>
</div>
</section>
<section className="section">
<div className="container contact-grid">
<div className="info-card">
<h2>اطلاعات تماس</h2>
<div className="info-row">
<span className="kicker en">Address</span>
<p>{site.address}</p>
</div>
<div className="info-row">
<span className="kicker en">Factory</span>
<p>{site.factory}</p>
</div>
<div className="info-row">
<span className="kicker en">Phone</span>
<p>
<a href={`tel:${site.phone.replace(/-/g, "")}`}>{site.phoneFa}</a>
</p>
</div>
</div>
<ContactForm />
</div>
<div className="container map-wrap">
<iframe
title="موقعیت دفتر صنایع کابل کمان"
src="https://www.openstreetmap.org/export/embed.html?bbox=51.412%2C35.705%2C51.425%2C35.713&layer=mapnik&marker=35.709%2C51.418"
loading="lazy"
/>
</div>
</section>
</>
);
}
+2285
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { Oswald, Rajdhani } from "next/font/google";
import Footer from "@/components/Footer";
import Header from "@/components/Header";
import Providers from "@/components/Providers";
import { site } from "@/data/site";
import { websiteDomain } from "@/lib/config";
import { getBusinessId } from "@/lib/meshkee";
import "./globals.css";
const rajdhani = Rajdhani({
subsets: ["latin"],
weight: ["500", "600", "700"],
variable: "--font-en",
display: "swap",
});
const oswald = Oswald({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-en-display",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(`https://${websiteDomain}`),
title: {
default: `${site.shortName} | ${site.name}`,
template: `%s | ${site.shortName}`,
},
description: site.description,
icons: {
icon: "/favicon.png",
},
openGraph: {
locale: "fa_IR",
siteName: site.shortName,
type: "website",
},
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const businessId = await getBusinessId();
return (
<html lang="fa" dir="rtl" className={`${rajdhani.variable} ${oswald.variable}`}>
<body>
<Providers businessId={businessId || "24"}>
<div className="wrapper">
<Header />
<main>{children}</main>
<Footer />
</div>
</Providers>
</body>
</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,16 @@
import { STATIC_IMAGE_SLOTS } from "@/lib/static-images";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
}
+213
View File
@@ -0,0 +1,213 @@
import type { CSSProperties } from "react";
import AboutTabs from "@/components/AboutTabs";
import BlogCarousel from "@/components/BlogCarousel";
import ContactBar from "@/components/ContactBar";
import HeroSlider from "@/components/HeroSlider";
import Reveal from "@/components/Reveal";
import SectionTitle from "@/components/SectionTitle";
import SlotImage from "@/components/SlotImage";
import SlotLink from "@/components/SlotLink";
import { blogHref, blogImage, fetchBlogs } from "@/lib/blog";
import { blogPosts, clients, products, site, slides } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot, slotRatioStyle } from "@/lib/static-images";
export const metadata = pageMetadata({
title: `${site.shortName} | ${site.name}`,
description: site.description,
path: "/",
image: "/images/slider/bg1.jpg",
absolute: true,
});
export default async function HomePage() {
const slots = await fetchStaticImageSlots("home");
const slider = overlaySlotImages(
pickSlot(slots, "slider"),
slides.map((slide) => ({
id: slide.id,
image: slide.image,
title: slide.title,
kicker: slide.kicker,
})),
"slider",
);
const aboutImage = overlaySlotImages(
pickSlot(slots, "about-us-image"),
[{ id: "about", image: "/images/about/factory.jpg", title: "کارخانه صنایع کابل کمان" }],
"about-us-image",
);
const productItems = overlaySlotImages(
pickSlot(slots, "categories"),
products.map((product) => ({
id: product.id,
image: product.image,
title: product.title,
kicker: product.titleEn,
text: product.text,
})),
"categories",
);
const clientItems = overlaySlotImages(
pickSlot(slots, "partners"),
clients.map((client) => ({
id: client.id,
image: client.image,
title: client.title,
})),
"partners",
);
const apiBlogs = await fetchBlogs({ pageSize: 6 });
const blogFallbacks = apiBlogs.items.length
? apiBlogs.items.map((post) => ({
id: post.id,
image: blogImage(post),
title: post.title,
text: post.abstract || undefined,
href: blogHref(post),
}))
: blogPosts.map((post) => ({
id: post.id,
image: post.image,
title: post.title,
kicker: post.date,
text: post.text,
href: blogHref(post),
}));
const blogItems = overlaySlotImages(pickSlot(slots, "home-blog"), blogFallbacks, "home-blog");
return (
<>
<h1 className="sr-only">{site.name}</h1>
<HeroSlider slides={slider.items} aspectRatio={slider.aspectRatio} />
<section className="app-strip">
<Reveal>
<div className="container app-strip-inner">
<h2>اپلیکیشن اختصاصی</h2>
<a className="btn btn-gold" href={site.appUrl} target="_blank" rel="noopener noreferrer">
دانلود اپلیکیشن
</a>
</div>
</Reveal>
</section>
<section className="section">
<div className="container about-grid">
<Reveal variant="right">
<div className="about-photo" style={slotRatioStyle(aboutImage.aspectRatio)}>
<SlotLink href={aboutImage.items[0]?.href}>
<SlotImage
src={aboutImage.items[0]?.image || "/images/about/factory.jpg"}
alt={aboutImage.items[0]?.title || "کارخانه صنایع کابل کمان"}
fill
/>
</SlotLink>
</div>
</Reveal>
<Reveal variant="left" delay={140}>
<div>
<SectionTitle
index="01"
en="About"
title="صنایع کابل کمان"
text={site.tagline}
/>
<AboutTabs />
<p className="about-cta">
<a className="about-contact-link" href="#contact">
تماس با ما
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M7.2 3.8h2.6l1.2 3-1.6 1.1a12.5 12.5 0 0 0 5.7 5.7l1.1-1.6 3 1.2v2.6c0 .7-.6 1.4-1.3 1.5-2 .3-6.3-.2-10.1-4S5.4 6.9 5.7 4.9c.1-.7.8-1.1 1.5-1.1Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
</a>
</p>
</div>
</Reveal>
</div>
</section>
<Reveal>
<ContactBar />
</Reveal>
<section className="section" id="products">
<div className="container">
<Reveal>
<SectionTitle
index="02"
en="Products"
title="محصولات کابل و سیم"
text="تنوع کامل سیم و کابل فشار ضعیف، مخابراتی، شبکه و ابزار دقیق، تولیدشده مطابق با استانداردهای ملی و بین‌المللی."
/>
</Reveal>
<Reveal stagger className="product-grid" style={slotRatioStyle(productItems.aspectRatio)}>
{productItems.items.map((item, index) => (
<article
key={item.id}
className="product-card"
id={`product-${item.id}`}
style={{ "--d": index } as CSSProperties}
>
<div className="thumb">
<SlotLink href={item.href}>
<SlotImage src={item.image} alt={item.title || "محصول کابل کمان"} fill />
</SlotLink>
</div>
<div className="body">
<h3>{item.title}</h3>
{item.kicker ? <p className="en">{item.kicker}</p> : null}
{item.text ? <p className="product-excerpt">{item.text}</p> : null}
</div>
</article>
))}
</Reveal>
</div>
</section>
<section className="section" id="blog" style={{ paddingTop: 0 }}>
<div className="container">
<Reveal>
<SectionTitle
index="03"
en="Blog"
title="آخرین اخبار و مقالات"
text="تازه‌ترین دانستنی‌های فنی و اخبار صنایع سیم و کابل کمان را در این بخش دنبال کنید."
/>
</Reveal>
<Reveal delay={80} style={slotRatioStyle(blogItems.aspectRatio)}>
<BlogCarousel posts={blogItems.items} />
</Reveal>
</div>
</section>
<section className="section" style={{ paddingTop: 0 }}>
<div className="container">
<Reveal>
<SectionTitle
index="04"
en="Partners"
title="همکاران و مشتریان"
text="برندها و مجموعه‌هایی که کیفیت تولید کابل کمان را برای پروژه‌های خود انتخاب کرده‌اند."
/>
</Reveal>
<Reveal stagger className="clients" style={slotRatioStyle(clientItems.aspectRatio)}>
{clientItems.items.map((item, index) => (
<div key={item.id} className="client" style={{ "--d": index } as CSSProperties}>
<SlotLink href={item.href}>
<SlotImage src={item.image} alt={item.title || "همکار کابل کمان"} />
</SlotLink>
</div>
))}
</Reveal>
</div>
</section>
</>
);
}
+149
View File
@@ -0,0 +1,149 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import CatalogCard from "@/components/CatalogCard";
import ProductGallery from "@/components/ProductGallery";
import ProductTabs from "@/components/ProductTabs";
import { categoryHref, categoryTitle } from "@/lib/categories";
import { fetchComments } from "@/lib/comments";
import { plainText, sanitizeHtml } from "@/lib/format";
import {
fetchProductById,
fetchProductTechnicalInfo,
localProductText,
productGallery,
productHref,
productTitle,
} from "@/lib/products";
import { pageMetadata } from "@/lib/seo";
import { slugifyForUrl } from "@/lib/slug";
import { rowsFromTechnicalInfo } from "@/lib/technical";
export const revalidate = 60;
type Params = { id: string; slug: string };
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { id } = await params;
const data = await fetchProductById(id);
if (!data?.product) {
return {
title: "محصول یافت نشد",
description: "این محصول در کاتالوگ صنایع کابل کمان موجود نیست.",
robots: { index: false },
};
}
const title = productTitle(data.product);
const description =
plainText(data.product.summary || data.product.descriptionHtml).slice(0, 160) ||
`${title} | محصولات صنایع سیم و کابل کمان`;
return pageMetadata({
title,
description,
path: productHref(data.product),
image: productGallery(data.product)[0],
});
}
export default async function ProductDetailPage({ params }: { params: Promise<Params> }) {
const { id, slug } = await params;
const data = await fetchProductById(id);
if (!data?.product) notFound();
const product = data.product;
const title = productTitle(product);
const canonicalSlug = slugifyForUrl(title);
if (decodeURIComponent(slug) !== canonicalSlug) {
redirect(productHref(product));
}
const [technicalInfo, comments] = await Promise.all([
fetchProductTechnicalInfo(product.id),
fetchComments("product", product.id),
]);
const descriptionHtml = sanitizeHtml(product.descriptionHtml);
const descriptionText = product.summary?.trim() || localProductText(product);
const related = (data.relatedProducts ?? []).filter((item) => item.id !== product.id).slice(0, 4);
return (
<section className="detail-page">
<div className="container">
<nav className="crumbs" aria-label="مسیر صفحه">
<Link href="/">صفحه اصلی</Link>
<span>/</span>
<Link href="/products">محصولات</Link>
{product.categoryId && (product.categoryNameFa || product.categoryName) ? (
<>
<span>/</span>
<Link
href={categoryHref({
id: product.categoryId,
nameFa: product.categoryNameFa,
name: product.categoryName,
})}
>
{categoryTitle({ nameFa: product.categoryNameFa, name: product.categoryName })}
</Link>
</>
) : null}
<span>/</span>
<b>{title}</b>
</nav>
<div className="product-detail-grid">
<ProductGallery images={productGallery(product)} alt={title} />
<div className="product-detail-copy">
<p className="en">Product Details</p>
{product.categoryId && (product.categoryNameFa || product.categoryName) ? (
<p className="product-category">
<Link
href={categoryHref({
id: product.categoryId,
nameFa: product.categoryNameFa,
name: product.categoryName,
})}
>
{categoryTitle({ nameFa: product.categoryNameFa, name: product.categoryName })}
</Link>
</p>
) : product.categoryNameFa || product.categoryName ? (
<p className="product-category">{product.categoryNameFa || product.categoryName}</p>
) : null}
<h1>{title}</h1>
{product.title && product.nameFa ? <p className="en">{product.title}</p> : null}
{descriptionText && !descriptionHtml ? <p>{descriptionText}</p> : null}
<a className="about-contact-link" href="/#contact">
تماس با ما
</a>
</div>
</div>
<ProductTabs
descriptionHtml={descriptionHtml || undefined}
descriptionText={descriptionHtml ? undefined : descriptionText}
technicalRows={rowsFromTechnicalInfo(technicalInfo)}
comments={comments}
entityId={product.id}
/>
{related.length > 0 ? (
<section className="related-section">
<h2>محصولات مشابه</h2>
<p className="en">Similar Products</p>
<div className="catalog-grid">
{related.map((item) => (
<CatalogCard key={item.id} product={item} />
))}
</div>
</section>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,147 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import CatalogCard from "@/components/CatalogCard";
import SlotImage from "@/components/SlotImage";
import {
categoryHref,
categoryTitle,
fetchCategories,
fetchCategoryById,
} from "@/lib/categories";
import { fetchProducts } from "@/lib/products";
import { pageMetadata } from "@/lib/seo";
import { slugifyForUrl } from "@/lib/slug";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
export const revalidate = 60;
type Params = { id: string; slug: string };
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<Params>;
searchParams: Promise<{ page?: string }>;
}): Promise<Metadata> {
const { id } = await params;
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const category = await fetchCategoryById(id);
if (!category) {
return { title: "دسته‌بندی یافت نشد", description: "این دسته‌بندی در کاتالوگ کابل کمان موجود نیست.", robots: { index: false } };
}
const title = categoryTitle(category);
const description =
category.description?.trim() ||
`محصولات دسته ${title} در صنایع سیم و کابل کمان.`;
return pageMetadata({
title: page > 1 ? `${title} — صفحه ${page}` : title,
description,
path: categoryHref(category),
image: "/images/samples/cables-coils.jpg",
});
}
export default async function ProductCategoryPage({
params,
searchParams,
}: {
params: Promise<Params>;
searchParams: Promise<{ page?: string }>;
}) {
const { id, slug } = await params;
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const category = await fetchCategoryById(id);
if (!category) notFound();
const canonicalSlug = slugifyForUrl(categoryTitle(category));
if (decodeURIComponent(slug) !== canonicalSlug) {
redirect(categoryHref(category));
}
const [result, allCategories, slots] = await Promise.all([
fetchProducts({ page, pageSize: 12, categoryId: category.id }),
fetchCategories("product"),
fetchStaticImageSlots("products"),
]);
const hero = overlaySlotImages(
pickSlot(slots, "products-hero"),
[{ id: "products-hero", image: "/images/samples/cables-coils.jpg", title: categoryTitle(category) }],
"products-hero",
);
const children = allCategories.filter((item) => item.parentId === category.id);
const title = categoryTitle(category);
const pageCount = Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)));
const listHref = (number: number) =>
number === 1 ? categoryHref(category) : `${categoryHref(category)}?page=${number}`;
return (
<>
<section className="page-hero">
<SlotImage
src={hero.items[0]?.image || "/images/samples/cables-coils.jpg"}
alt={hero.items[0]?.title || title}
fill
/>
<div className="container">
<h1>{title}</h1>
{category.name && category.nameFa ? <p className="en">{category.name}</p> : <p className="en">Products</p>}
</div>
</section>
<section className="section">
<div className="container">
<nav className="crumbs" aria-label="مسیر صفحه">
<Link href="/">صفحه اصلی</Link>
<span>/</span>
<Link href="/products">محصولات</Link>
<span>/</span>
<b>{title}</b>
</nav>
{category.description ? <p className="detail-lead">{category.description}</p> : null}
{children.length > 0 ? (
<div className="category-pills">
{children.map((item) => (
<Link key={item.id} href={categoryHref(item)}>
{categoryTitle(item)}
</Link>
))}
</div>
) : null}
{result.items.length === 0 ? (
<p className="comment-empty">محصولی در این دسته ثبت نشده است.</p>
) : (
<div className="catalog-grid">
{result.items.map((product) => (
<CatalogCard key={product.id} product={product} />
))}
</div>
)}
{pageCount > 1 ? (
<nav className="pagination" aria-label="صفحه‌بندی دسته">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
if (pageCount > 12 && Math.abs(number - page) > 3 && number !== 1 && number !== pageCount) {
return number === 2 || number === pageCount - 1 ? <span key={number}>…</span> : null;
}
return (
<Link key={number} href={listHref(number)} className={number === page ? "active" : undefined}>
{number}
</Link>
);
})}
</nav>
) : null}
</div>
</section>
</>
);
}
+109
View File
@@ -0,0 +1,109 @@
import type { Metadata } from "next";
import Link from "next/link";
import CatalogCard from "@/components/CatalogCard";
import SlotImage from "@/components/SlotImage";
import { categoryHref, categoryTitle, fetchCategories } from "@/lib/categories";
import { fetchProducts } from "@/lib/products";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
export const revalidate = 60;
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}): Promise<Metadata> {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
return pageMetadata({
title: page > 1 ? `محصولات — صفحه ${page}` : "محصولات",
description: "کاتالوگ سیم و کابل فشار ضعیف، مخابراتی، شبکه و ابزار دقیق صنایع کابل کمان.",
path: page > 1 ? `/products?page=${page}` : "/products",
image: "/images/samples/cables-coils.jpg",
});
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const [result, categories, slots] = await Promise.all([
fetchProducts({ page, pageSize: 12 }),
fetchCategories("product"),
fetchStaticImageSlots("products"),
]);
const hero = overlaySlotImages(
pickSlot(slots, "products-hero"),
[{ id: "products-hero", image: "/images/samples/cables-coils.jpg", title: "محصولات" }],
"products-hero",
);
const pageCount = Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)));
const topCategories = categories.filter((item) => !item.parentId);
return (
<>
<section className="page-hero">
<SlotImage
src={hero.items[0]?.image || "/images/samples/cables-coils.jpg"}
alt={hero.items[0]?.title || "محصولات کابل کمان"}
fill
/>
<div className="container">
<h1>محصولات</h1>
<p className="en">Products</p>
</div>
</section>
<section className="section">
<div className="container">
{topCategories.length > 0 ? (
<div className="category-pills">
{topCategories.map((item) => (
<Link key={item.id} href={categoryHref(item)}>
{categoryTitle(item)}
</Link>
))}
</div>
) : null}
{result.items.length === 0 ? (
<p className="comment-empty">محصولی برای نمایش وجود ندارد.</p>
) : (
<div className="catalog-grid">
{result.items.map((product) => (
<CatalogCard key={product.id} product={product} />
))}
</div>
)}
{pageCount > 1 ? (
<nav className="pagination" aria-label="صفحه‌بندی محصولات">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
if (pageCount > 12 && Math.abs(number - page) > 3 && number !== 1 && number !== pageCount) {
return number === 2 || number === pageCount - 1 ? (
<span key={number}>…</span>
) : null;
}
return (
<Link
key={number}
href={number === 1 ? "/products" : `/products?page=${number}`}
className={number === page ? "active" : undefined}
>
{number}
</Link>
);
})}
</nav>
) : null}
</div>
</section>
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import { goalsText, qualityPolicy, resumeText } from "@/data/site";
export default function AboutTabs() {
const [tab, setTab] = useState<"resume" | "goals">("resume");
return (
<div>
<div className="tabs" role="tablist">
<button type="button" className={tab === "resume" ? "active" : undefined} onClick={() => setTab("resume")}>
رزومه
</button>
<button type="button" className={tab === "goals" ? "active" : undefined} onClick={() => setTab("goals")}>
اهداف
</button>
</div>
{tab === "resume" ? (
<p className="muted">{resumeText}</p>
) : (
<div>
{goalsText.map((paragraph) => (
<p key={paragraph.slice(0, 24)} className="muted">
{paragraph}
</p>
))}
<p className="muted">خط مشی کیفیت عبارت است از:</p>
<ol className="muted">
{qualityPolicy.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
</div>
)}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { businessDashboardUrl, customerPortalUrl } from "@/lib/config";
import { useSession, userDisplayName } from "@/lib/session";
function UserIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.75" />
<path
d="M5.5 19.25c1.4-3 3.8-4.5 6.5-4.5s5.1 1.5 6.5 4.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
aria-hidden
className={open ? "account-menu__caret open" : "account-menu__caret"}
>
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export default function AccountMenu() {
const { user, isAuthenticated, 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 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(businessDashboardUrl);
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(businessDashboardUrl);
else window.open(businessDashboardUrl, "_blank", "noopener,noreferrer");
} finally {
setHandoffBusy(false);
setOpen(false);
}
}
if (!isAuthenticated || !user) {
return (
<a href={customerPortalUrl} className="header-tool header-login" aria-label="ورود">
<UserIcon />
<span>ورود</span>
</a>
);
}
return (
<div ref={menuRef} className="account-menu">
<button
type="button"
className="header-tool account-menu__trigger"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="menu"
>
<UserIcon />
<span>{userDisplayName(user)} عزیز</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"
onClick={() => {
setOpen(false);
logout();
}}
>
خروج
</button>
</div>
) : null}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { type CSSProperties, useEffect, useState } from "react";
import SlotImage from "@/components/SlotImage";
import SlotLink from "@/components/SlotLink";
import type { SlotMediaItem } from "@/lib/static-images";
function perViewForWidth(width: number) {
if (width <= 760) return 1;
if (width <= 980) return 2;
return 3;
}
export default function BlogCarousel({ posts }: { posts: SlotMediaItem[] }) {
const [index, setIndex] = useState(0);
const [perView, setPerView] = useState(3);
const [paused, setPaused] = useState(false);
const maxIndex = Math.max(0, posts.length - perView);
useEffect(() => {
function sync() {
setPerView(perViewForWidth(window.innerWidth));
}
sync();
window.addEventListener("resize", sync);
return () => window.removeEventListener("resize", sync);
}, []);
useEffect(() => {
setIndex((value) => Math.min(value, maxIndex));
}, [maxIndex]);
useEffect(() => {
if (posts.length <= perView || paused) return undefined;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return undefined;
const timer = window.setInterval(() => {
setIndex((value) => (value >= maxIndex ? 0 : value + 1));
}, 5200);
return () => window.clearInterval(timer);
}, [maxIndex, paused, perView, posts.length]);
if (posts.length === 0) return null;
function go(next: number) {
setIndex(Math.min(maxIndex, Math.max(0, next)));
}
return (
<div
className="blog-carousel"
style={{ "--blog-index": index, "--blog-per": perView } as CSSProperties}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="blog-toolbar">
<div className="blog-dots" role="tablist" aria-label="مقالات وبلاگ">
{Array.from({ length: maxIndex + 1 }, (_, page) => (
<button
key={page}
type="button"
role="tab"
aria-selected={page === index}
aria-label={`اسلاید ${page + 1}`}
className={page === index ? "blog-dot active" : "blog-dot"}
onClick={() => go(page)}
/>
))}
</div>
<div className="blog-arrows">
<button
type="button"
className="blog-arrow"
aria-label="قبلی"
onClick={() => setIndex(index <= 0 ? maxIndex : index - 1)}
>
‹
</button>
<button
type="button"
className="blog-arrow"
aria-label="بعدی"
onClick={() => setIndex(index >= maxIndex ? 0 : index + 1)}
>
›
</button>
</div>
</div>
<div className="blog-viewport">
<div className="blog-track">
{posts.map((post) => (
<article key={post.id} className="blog-card">
<div className="blog-card-media">
<SlotLink href={post.href} className="blog-card-hit">
<SlotImage src={post.image} alt={post.title || "مقاله کابل کمان"} fill />
</SlotLink>
</div>
<div className="blog-card-body">
{post.kicker ? <p className="blog-date">{post.kicker}</p> : null}
<h3>
<SlotLink href={post.href}>{post.title}</SlotLink>
</h3>
{post.text ? <p className="blog-excerpt">{post.text}</p> : null}
{post.href ? (
<SlotLink href={post.href} className="blog-more">
ادامه مطلب
</SlotLink>
) : null}
</div>
</article>
))}
</div>
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { FormEvent, useState } from "react";
export default function CallbackForm() {
const [status, setStatus] = useState<"idle" | "sending" | "ok" | "error">("idle");
const [message, setMessage] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const name = String(data.get("name") || "").trim();
const cellNumber = String(data.get("cellNumber") || "").trim();
setStatus("sending");
setMessage("");
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
title: "درخواست تماس",
cellNumber,
text: `درخواست تماس از صفحه اصلی. شماره: ${cellNumber}`,
}),
});
if (!response.ok) {
setStatus("error");
setMessage("ثبت شماره انجام نشد. لطفاً دوباره تلاش کنید.");
return;
}
setStatus("ok");
setMessage("شماره شما ثبت شد. به‌زودی تماس می‌گیریم.");
form.reset();
} catch {
setStatus("error");
setMessage("ارتباط با سرور برقرار نشد.");
}
}
return (
<form className="callback-form" onSubmit={onSubmit}>
<label className="sr-only" htmlFor="callback-name">
نام
</label>
<input id="callback-name" name="name" type="text" required placeholder="نام" autoComplete="name" />
<label className="sr-only" htmlFor="callback-phone">
شماره تماس
</label>
<input
id="callback-phone"
name="cellNumber"
type="tel"
required
inputMode="tel"
placeholder="شماره تماس"
autoComplete="tel"
/>
<button type="submit" className="btn btn-gold" disabled={status === "sending"}>
{status === "sending" ? "در حال ارسال…" : "ثبت شماره"}
</button>
{message ? <p className={status === "ok" ? "ok" : "err"}>{message}</p> : null}
</form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import SlotImage from "@/components/SlotImage";
import { type CatalogProduct, productHref, productImage, productTitle } from "@/lib/products";
export default function CatalogCard({ product }: { product: CatalogProduct }) {
const title = productTitle(product);
return (
<article className="catalog-card">
<Link href={productHref(product)} className="catalog-card-media">
<SlotImage src={productImage(product)} alt={title} fill />
</Link>
<div className="catalog-card-body">
<h3>
<Link href={productHref(product)}>{title}</Link>
</h3>
{product.title && product.nameFa ? <p className="en">{product.title}</p> : null}
</div>
</article>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { FormEvent, useState } from "react";
import { customerPortalUrl } from "@/lib/config";
import { formatFaDate } from "@/lib/format";
import type { CommentItem } from "@/lib/comments";
import { useSession, userDisplayName } from "@/lib/session";
export default function CommentBox({
entityType,
entityId,
comments,
}: {
entityType: "product" | "blog";
entityId: string;
comments: CommentItem[];
}) {
const { user, isAuthenticated, ready } = useSession();
const [status, setStatus] = useState<"idle" | "sending" | "ok" | "error">("idle");
const [message, setMessage] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!user) return;
const form = event.currentTarget;
const text = String(new FormData(form).get("text") || "").trim();
if (!text) return;
setStatus("sending");
setMessage("");
try {
const response = await fetch("/api/comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entityType,
entityId,
authorName: userDisplayName(user),
authorEmail: user.email,
text,
}),
});
if (!response.ok) {
setStatus("error");
setMessage("ارسال نظر انجام نشد. لطفاً دوباره تلاش کنید.");
return;
}
setStatus("ok");
setMessage("نظر شما ثبت شد و پس از تأیید نمایش داده می‌شود.");
form.reset();
} catch {
setStatus("error");
setMessage("ارتباط با سرور برقرار نشد.");
}
}
return (
<div className="comment-box">
{comments.length === 0 ? (
<p className="comment-empty">هنوز نظری ثبت نشده است.</p>
) : (
<div className="comment-list">
{comments.map((comment, index) => (
<article key={comment.id ?? index} className="comment-item">
<strong>{comment.authorName || "کاربر"}</strong>
{comment.createdAt ? <time>{formatFaDate(comment.createdAt)}</time> : null}
<p>{comment.text}</p>
</article>
))}
</div>
)}
{ready && !isAuthenticated ? (
<div className="comment-login">
<p>برای ثبت نظر وارد حساب کاربری شوید.</p>
<a className="btn btn-gold" href={customerPortalUrl}>
ورود
</a>
</div>
) : null}
{ready && isAuthenticated ? (
<form className="comment-form" onSubmit={onSubmit}>
<label className="sr-only" htmlFor={`comment-${entityType}-${entityId}`}>
نظر شما
</label>
<textarea
id={`comment-${entityType}-${entityId}`}
name="text"
rows={4}
required
placeholder="نظر خود را بنویسید…"
/>
<button type="submit" className="btn btn-gold" disabled={status === "sending"}>
{status === "sending" ? "در حال ارسال…" : "ثبت نظر"}
</button>
{message ? <p className={status === "ok" ? "ok" : "err"}>{message}</p> : null}
</form>
) : null}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import type { ReactNode } from "react";
import CallbackForm from "@/components/CallbackForm";
import { site, socials } from "@/data/site";
function InstagramIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" strokeWidth="1.7" />
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.7" />
<circle cx="17.4" cy="6.6" r="1" fill="currentColor" />
</svg>
);
}
function TelegramIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M20.5 4.5 3.8 11.2c-1.1.4-1.1 1.1-.2 1.4l4.3 1.3 1.7 5.2c.2.7.1.9.8.9.4 0 .6-.2.8-.4l2.4-2.3 4.5 3.3c.8.5 1.4.2 1.6-.8L21.7 5.7c.3-1.1-.4-1.6-1.2-1.2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
);
}
function LinkedinIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<rect x="3.5" y="3.5" width="17" height="17" rx="2.5" stroke="currentColor" strokeWidth="1.7" />
<path d="M8 10.5V16.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
<circle cx="8" cy="8" r="0.9" fill="currentColor" />
<path
d="M12 16.5v-3.6c0-1.2.8-2 1.9-2s1.9.8 1.9 2v3.6"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
);
}
function EmailIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
<rect x="3.5" y="5.5" width="17" height="13" rx="2" stroke="currentColor" strokeWidth="1.7" />
<path d="M4.5 7.5 12 13l7.5-5.5" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
</svg>
);
}
function PhoneIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M7.2 3.8h2.6l1.2 3-1.6 1.1a12.5 12.5 0 0 0 5.7 5.7l1.1-1.6 3 1.2v2.6c0 .7-.6 1.4-1.3 1.5-2 .3-6.3-.2-10.1-4S5.4 6.9 5.7 4.9c.1-.7.8-1.1 1.5-1.1Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
const icons: Record<string, () => ReactNode> = {
instagram: InstagramIcon,
telegram: TelegramIcon,
linkedin: LinkedinIcon,
email: EmailIcon,
};
export default function ContactBar() {
return (
<section className="contact-bar" id="contact" aria-label="تماس با ما">
<div className="container contact-bar-frame">
<div className="contact-bar-inner">
<div className="contact-bar-phone">
<span className="contact-bar-kicker">تماس با ما</span>
<a href={`tel:${site.phone.replace(/-/g, "")}`} className="contact-bar-number">
<span dir="ltr">{site.phone}</span>
<PhoneIcon />
</a>
</div>
<div className="contact-socials">
{socials.map((item) => {
const Icon = icons[item.id];
return (
<a
key={item.id}
href={item.href}
className="contact-social"
target={item.href.startsWith("mailto:") ? undefined : "_blank"}
rel="noopener noreferrer"
aria-label={item.label}
title={item.label}
>
{Icon ? <Icon /> : item.labelEn}
</a>
);
})}
</div>
</div>
<div className="contact-bar-subscribe">
<p className="contact-bar-subscribe-text">شماره تماس خود را بگذارید، ما با شما تماس میگیریم.</p>
<CallbackForm />
</div>
</div>
</section>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { FormEvent, useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "ok" | "error">("idle");
const [message, setMessage] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
setStatus("sending");
setMessage("");
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: String(data.get("name") || ""),
title: String(data.get("title") || ""),
email: String(data.get("email") || ""),
cellNumber: String(data.get("cellNumber") || ""),
text: String(data.get("text") || ""),
}),
});
if (!response.ok) {
setStatus("error");
setMessage("ارسال پیام انجام نشد. لطفاً دوباره تلاش کنید.");
return;
}
setStatus("ok");
setMessage("پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.");
form.reset();
} catch {
setStatus("error");
setMessage("ارتباط با سرور برقرار نشد.");
}
}
return (
<form className="contact-form" onSubmit={onSubmit}>
<h2>پیام خود را ارسال کنید</h2>
<p className="en" style={{ margin: 0, color: "var(--gold)" }}>
Send a message
</p>
<label>
نام
<input name="name" type="text" required />
</label>
<label>
موضوع
<input name="title" type="text" required />
</label>
<label>
ایمیل
<input name="email" type="email" />
</label>
<label>
شماره تماس
<input name="cellNumber" type="tel" />
</label>
<label>
متن پیام
<textarea name="text" required />
</label>
<button type="submit" className="btn btn-gold" disabled={status === "sending"}>
{status === "sending" ? "در حال ارسال…" : "ارسال پیام"}
</button>
{message ? <p className={status === "ok" ? "ok" : "err"}>{message}</p> : null}
</form>
);
}
+76
View File
@@ -0,0 +1,76 @@
import Image from "next/image";
import Link from "next/link";
import { navLinks, productsNav, site } from "@/data/site";
export default function Footer() {
return (
<footer className="site-footer">
<div className="container footer-grid">
<div>
<h3>{site.shortName}</h3>
<p>{site.description}</p>
</div>
<div>
<h3>دسترسی سریع</h3>
<ul>
<li>
<Link href="/">{navLinks[0].label}</Link>
</li>
<li>
<Link href={productsNav.href}>{productsNav.label}</Link>
</li>
{navLinks.slice(1).map((link) => (
<li key={link.href}>
<Link href={link.href}>{link.label}</Link>
</li>
))}
</ul>
</div>
<div>
<h3>تماس با ما</h3>
<ul>
<li>{site.address}</li>
<li>
<a href={`tel:${site.phone.replace(/-/g, "")}`}>{site.phoneFa}</a>
</li>
<li>{site.factory}</li>
</ul>
</div>
</div>
<div className="footer-bottom">
<div className="container">
<div className="footer-bottom-bar">
<div className="footer-bottom-site">{site.name}</div>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit"
>
<span className="meshkee-credit__frame" aria-hidden>
<span className="meshkee-credit__line meshkee-credit__line--l" />
<span className="meshkee-credit__line meshkee-credit__line--t" />
<span className="meshkee-credit__line meshkee-credit__line--r" />
<span className="meshkee-credit__line meshkee-credit__line--b" />
</span>
<Image
src="/images/footer/MeshkeeLogo-White.png"
alt="Meshkee"
className="meshkee-credit__logo"
width={42}
height={42}
/>
<span className="meshkee-credit__text">
<span className="meshkee-credit__label">Designed And Developed By</span>
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
</span>
</a>
</div>
</div>
</div>
</footer>
);
}
+259
View File
@@ -0,0 +1,259 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import AccountMenu from "@/components/AccountMenu";
import { blogPosts, navLinks, products, productsNav, site } from "@/data/site";
type Locale = "fa" | "en";
function SearchIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="11" cy="11" r="6.25" stroke="currentColor" strokeWidth="1.75" />
<path d="M16 16.5 20 20.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
);
}
function ChevronIcon() {
return (
<svg className="nav-caret" width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export default function Header() {
const pathname = usePathname();
const router = useRouter();
const [open, setOpen] = useState(false);
const [locale, setLocale] = useState<Locale>("fa");
const [query, setQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [productsOpen, setProductsOpen] = useState(false);
const [hash, setHash] = useState("");
const searchRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const saved = window.localStorage.getItem("kaman-locale");
if (saved === "en" || saved === "fa") setLocale(saved);
}, []);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = locale === "fa" ? "rtl" : "ltr";
window.localStorage.setItem("kaman-locale", locale);
}, [locale]);
useEffect(() => {
function onPointer(event: MouseEvent) {
if (!searchRef.current?.contains(event.target as Node)) setSearchOpen(false);
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setSearchOpen(false);
}
document.addEventListener("mousedown", onPointer);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, []);
useEffect(() => {
if (searchOpen) inputRef.current?.focus();
}, [searchOpen]);
useEffect(() => {
function syncHash() {
setHash(window.location.hash);
}
syncHash();
window.addEventListener("hashchange", syncHash);
return () => window.removeEventListener("hashchange", syncHash);
}, [pathname]);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const pages = [
...navLinks.map((link) => ({
href: link.href,
title: locale === "en" ? link.labelEn : link.label,
})),
{
href: "/products",
title: locale === "en" ? productsNav.labelEn : productsNav.label,
},
{
href: "/blog",
title: locale === "en" ? "Blog" : "وبلاگ",
},
];
const catalog = products.map((product) => ({
href: `/products`,
title: locale === "en" ? product.titleEn : product.title,
}));
const articles = blogPosts.map((post) => ({
href: `/blog`,
title: locale === "en" ? post.titleEn : post.title,
}));
return [...pages, ...catalog, ...articles].filter((item) => item.title.toLowerCase().includes(q)).slice(0, 8);
}, [locale, query]);
function onSearch(event: FormEvent) {
event.preventDefault();
const first = results[0];
if (!first) return;
if (/^https?:\/\//i.test(first.href)) {
window.open(first.href, "_blank", "noopener,noreferrer");
} else {
router.push(first.href);
}
setSearchOpen(false);
setQuery("");
}
return (
<header className="site-header">
<div className="container header-inner">
<div className="header-start">
<Link href="/" className="brand" onClick={() => setOpen(false)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={site.logo} alt={site.shortName} width={32} height={32} />
<span className="brand-text">
<strong>{locale === "en" ? site.nameEnShort : site.shortName}</strong>
<span className="en">{site.nameEnShort}</span>
</span>
</Link>
<button
type="button"
className="menu-toggle"
aria-expanded={open}
aria-label={locale === "en" ? "Menu" : "منو"}
onClick={() => setOpen((value) => !value)}
>
{locale === "en" ? "Menu" : "منو"}
</button>
<nav className={open ? "nav open" : "nav"}>
{navLinks.slice(0, 1).map((link) => (
<Link
key={link.href}
href={link.href}
className={pathname === "/" && !hash.startsWith("#product") && hash !== "#products" ? "active" : undefined}
onClick={() => setOpen(false)}
>
{locale === "en" ? link.labelEn : link.label}
</Link>
))}
<div
className={productsOpen ? "nav-dropdown open" : "nav-dropdown"}
onMouseEnter={() => setProductsOpen(true)}
onMouseLeave={() => setProductsOpen(false)}
>
<Link
href={productsNav.href}
className={pathname.startsWith("/products") || hash === "#products" || hash.startsWith("#product-") ? "active" : undefined}
onClick={() => setOpen(false)}
>
{locale === "en" ? productsNav.labelEn : productsNav.label}
<ChevronIcon />
</Link>
<ul className="nav-sub">
{products.map((product) => (
<li key={product.id}>
<Link href={`/#product-${product.id}`} onClick={() => setOpen(false)}>
{locale === "en" ? product.titleEn : product.title}
</Link>
</li>
))}
</ul>
</div>
{navLinks.slice(1).map((link) => (
<Link
key={link.href}
href={link.href}
className={pathname === link.href ? "active" : undefined}
onClick={() => setOpen(false)}
>
{locale === "en" ? link.labelEn : link.label}
</Link>
))}
</nav>
</div>
<div className="header-tools">
<div className={searchOpen ? "header-search open" : "header-search"} ref={searchRef}>
<form onSubmit={onSearch}>
<label className="sr-only" htmlFor="site-search">
{locale === "en" ? "Search" : "جستجو"}
</label>
<input
id="site-search"
ref={inputRef}
value={query}
tabIndex={searchOpen ? 0 : -1}
onChange={(event) => {
setQuery(event.target.value);
setSearchOpen(true);
}}
onFocus={() => setSearchOpen(true)}
placeholder={locale === "en" ? "Search" : "جستجو"}
/>
<button
type={searchOpen && query.trim() ? "submit" : "button"}
className="header-tool"
aria-expanded={searchOpen}
aria-label={locale === "en" ? "Search" : "جستجو"}
onClick={() => {
if (!searchOpen) setSearchOpen(true);
}}
>
<SearchIcon />
</button>
</form>
{searchOpen && results.length > 0 ? (
<ul className="header-search-results">
{results.map((item) => (
<li key={`${item.href}-${item.title}`}>
<Link
href={item.href}
onClick={() => {
setSearchOpen(false);
setQuery("");
}}
>
{item.title}
</Link>
</li>
))}
</ul>
) : null}
</div>
<label className="header-lang">
<span className="sr-only">{locale === "en" ? "Language" : "زبان"}</span>
<select
value={locale}
onChange={(event) => setLocale(event.target.value as Locale)}
aria-label={locale === "en" ? "Language" : "انتخاب زبان"}
>
<option value="fa">FA</option>
<option value="en">EN</option>
</select>
</label>
<AccountMenu />
</div>
</div>
</header>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import SlotImage from "@/components/SlotImage";
import SlotLink from "@/components/SlotLink";
import { heroHighlights } from "@/data/site";
import { slotRatioStyle, type SlotMediaItem } from "@/lib/static-images";
function RevealLine({
text,
tone = "white",
}: {
text: string;
tone?: "gold" | "white";
}) {
return (
<span className={`hero-reveal hero-reveal--${tone}`}>
<span className="hero-reveal__text">{text}</span>
<span className="hero-reveal__mask" aria-hidden />
</span>
);
}
export default function HeroSlider({
slides,
aspectRatio,
}: {
slides: SlotMediaItem[];
aspectRatio?: string;
}) {
const [index, setIndex] = useState(0);
const current = slides[index] ?? slides[0];
const tabs = heroHighlights.slice(0, Math.max(slides.length, heroHighlights.length));
useEffect(() => {
if (slides.length < 2) return undefined;
const timer = window.setInterval(() => {
setIndex((value) => (value + 1) % slides.length);
}, 6000);
return () => window.clearInterval(timer);
}, [index, slides.length]);
if (!current) return null;
return (
<section className="hero" style={slotRatioStyle(aspectRatio)}>
{slides.map((slide, slideIndex) => (
<div key={slide.id} className={slideIndex === index ? "hero-slide active" : "hero-slide"}>
<SlotLink href={slide.href}>
<SlotImage src={slide.image} alt={slide.title || "اسلایدر صنایع کابل کمان"} fill priority={slideIndex === 0} />
</SlotLink>
</div>
))}
<div className="hero-overlay" />
<div className="container hero-copy">
{current.kicker ? (
<p className="en">
<RevealLine key={`kicker-${current.id}-${index}`} text={current.kicker} tone="gold" />
</p>
) : null}
<h2 className="hero-title">
<RevealLine key={`title-${current.id}-${index}`} text={current.title || ""} tone="white" />
</h2>
</div>
<div className="container">
<div className="highlights" role="tablist" aria-label="اسلایدر">
{tabs.map((item, tabIndex) => {
const active = tabIndex === index;
return (
<button
key={item.n}
type="button"
role="tab"
aria-selected={active}
className={active ? "highlight active" : "highlight"}
onClick={() => setIndex(tabIndex % slides.length)}
>
<div className="n">{item.n}</div>
<h3>{item.title}</h3>
<p className="en">{item.titleEn}</p>
</button>
);
})}
</div>
</div>
</section>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useCallback, useEffect, useState, type MouseEvent } from "react";
type Props = {
images: string[];
alt: string;
};
export default function ProductGallery({ images, alt }: Props) {
const [active, setActive] = useState(0);
const [lightbox, setLightbox] = useState(false);
const [origin, setOrigin] = useState("50% 50%");
const list = images.length ? images : [];
const go = useCallback(
(delta: number) => {
if (list.length === 0) return;
setActive((value) => (value + delta + list.length) % list.length);
},
[list.length],
);
useEffect(() => {
if (!lightbox) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setLightbox(false);
if (event.key === "ArrowLeft") go(1);
if (event.key === "ArrowRight") go(-1);
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [go, lightbox]);
function updateOrigin(event: MouseEvent<HTMLButtonElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width) * 100;
const y = ((event.clientY - bounds.top) / bounds.height) * 100;
setOrigin(`${x}% ${y}%`);
}
if (list.length === 0) {
return <div className="product-gallery-empty">بدون تصویر</div>;
}
return (
<div className="product-gallery">
<button
type="button"
className="product-gallery-main"
onClick={() => setLightbox(true)}
onMouseMove={updateOrigin}
onMouseLeave={() => setOrigin("50% 50%")}
aria-label="بزرگ‌نمایی تصویر"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={list[active]} alt={alt} style={{ transformOrigin: origin }} />
</button>
{list.length > 1 ? (
<div className="product-gallery-thumbs">
{list.map((url, index) => (
<button
key={`${url}-${index}`}
type="button"
className={index === active ? "active" : undefined}
onClick={() => setActive(index)}
aria-label={`تصویر ${index + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={`${alt} — تصویر ${index + 1}`} />
</button>
))}
</div>
) : null}
{lightbox ? (
<div
className="product-lightbox"
role="dialog"
aria-modal="true"
aria-label="گالری تصاویر"
onClick={() => setLightbox(false)}
>
<button type="button" className="product-lightbox-close" aria-label="بستن">
×
</button>
{list.length > 1 ? (
<>
<button
type="button"
className="product-lightbox-nav next"
aria-label="قبلی"
onClick={(event) => {
event.stopPropagation();
go(-1);
}}
>
›
</button>
<button
type="button"
className="product-lightbox-nav prev"
aria-label="بعدی"
onClick={(event) => {
event.stopPropagation();
go(1);
}}
>
‹
</button>
</>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={list[active]} alt={alt} onClick={(event) => event.stopPropagation()} />
{list.length > 1 ? (
<p>
{active + 1} / {list.length}
</p>
) : null}
</div>
) : null}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import CommentBox from "@/components/CommentBox";
import type { CommentItem } from "@/lib/comments";
import type { TechnicalRow } from "@/lib/technical";
type TabId = "description" | "technical" | "comments";
export default function ProductTabs({
descriptionHtml,
descriptionText,
technicalRows,
comments,
entityId,
}: {
descriptionHtml?: string;
descriptionText?: string;
technicalRows: TechnicalRow[];
comments: CommentItem[];
entityId: string;
}) {
const [tab, setTab] = useState<TabId>("description");
return (
<section className="detail-tabs">
<div className="detail-tabs-nav" role="tablist" aria-label="جزئیات محصول">
<button type="button" className={tab === "description" ? "active" : undefined} onClick={() => setTab("description")}>
توضیحات
</button>
<button type="button" className={tab === "technical" ? "active" : undefined} onClick={() => setTab("technical")}>
مشخصات فنی
</button>
<button type="button" className={tab === "comments" ? "active" : undefined} onClick={() => setTab("comments")}>
نظرات
</button>
</div>
<div className="detail-tabs-body">
{tab === "description" ? (
descriptionHtml ? (
<div className="rich-text" dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
) : descriptionText ? (
<p>{descriptionText}</p>
) : (
<p className="comment-empty">توضیحاتی ثبت نشده است.</p>
)
) : null}
{tab === "technical" ? (
technicalRows.length > 0 ? (
<table className="tech-table">
<tbody>
{technicalRows.map((row) => (
<tr key={row.id}>
<th>{row.label}</th>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="comment-empty">مشخصات فنی برای این محصول ثبت نشده است.</p>
)
) : null}
{tab === "comments" ? (
<CommentBox entityType="product" entityId={entityId} comments={comments} />
) : null}
</div>
</section>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { SessionProvider } from "@/lib/session";
import type { ReactNode } from "react";
export default function Providers({
businessId,
children,
}: {
businessId: string;
children: ReactNode;
}) {
return <SessionProvider businessId={businessId}>{children}</SessionProvider>;
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
type Props = {
children: ReactNode;
className?: string;
delay?: number;
stagger?: boolean;
variant?: "up" | "left" | "right";
style?: CSSProperties;
};
export default function Reveal({
children,
className = "",
delay = 0,
stagger = false,
variant = "up",
style,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return undefined;
const reveal = () => setShown(true);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
reveal();
return undefined;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
reveal();
observer.disconnect();
}
},
{ threshold: 0.16, rootMargin: "0px 0px -10% 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const classes = [
"reveal",
`reveal--${variant}`,
stagger ? "stagger" : "",
shown ? "is-in" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
ref={ref}
className={classes}
style={{ "--reveal-delay": `${delay}ms`, ...style } as CSSProperties}
>
{children}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
type Props = {
index?: string;
en: string;
title: string;
text?: string;
};
export default function SectionTitle({ index, en, title, text }: Props) {
return (
<header className="section-head">
<span className="section-head-bg" aria-hidden>
{en}
</span>
<div className="section-head-meta">
{index ? <span className="section-head-index">{index}</span> : null}
<span className="section-head-rule" aria-hidden />
<p className="section-head-en">{en}</p>
</div>
<h2>{title}</h2>
{text ? <p className="section-head-text">{text}</p> : null}
</header>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { CSSProperties } from "react";
type Props = {
src: string;
alt: string;
width?: number;
height?: number;
fill?: boolean;
className?: string;
sizes?: string;
priority?: boolean;
style?: CSSProperties;
fallbackSrc?: string;
};
export default function SlotImage({
src,
alt,
width,
height,
fill,
className,
priority,
style,
fallbackSrc,
}: Props) {
const resolved = src || fallbackSrc || "";
if (!resolved) return null;
if (fill) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolved}
alt={alt}
className={className ? `${className} slot-fill` : "slot-fill"}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", ...style }}
/>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolved}
alt={alt}
width={width}
height={height}
className={className}
style={style}
fetchPriority={priority ? "high" : undefined}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Link from "next/link";
import type { ReactNode } from "react";
function isExternal(href: string) {
return /^https?:\/\//i.test(href) || href.startsWith("//");
}
export default function SlotLink({
href,
className,
children,
}: {
href?: string;
className?: string;
children: ReactNode;
}) {
if (!href) return <>{children}</>;
if (isExternal(href)) {
return (
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
+264
View File
@@ -0,0 +1,264 @@
export const site = {
name: "صنایع سیم و کابل کمان",
shortName: "کابل کمان",
nameEn: "Kaman Cable & Wire Industries",
nameEnShort: "Kaman Cable",
tagline: "تولید سیم و کابل فشار ضعیف، مخابراتی، کنترل و ابزار دقیق",
description:
"شرکت صنایع کابل کمان از سال ۱۳۷۰ در شهر صنعتی زنجان به تولید انواع سیم و کابل فشار ضعیف برق و مخابراتی، کابل‌های کنترل و ابزار دقیق مطابق با استانداردهای ملی و بین‌المللی می‌پردازد.",
founded: "۱۳۷۰",
capacity: "۱۵۰۰۰ تن",
factoryArea: "۴۰۰۰۰ متر مربع",
factory: "شهر صنعتی شماره یک، استان زنجان",
address:
"تهران، خیابان کریم‌خان زند، خیابان سنایی، بالاتر از میدان سنایی، پلاک ۸۲، طبقه سوم، واحد ۶",
phone: "021-242624",
phoneFa: "۰۲۱-۲۴۲۶۲۴",
email: "info@kamancable.com",
appUrl: "https://kamancable.com/applanding/",
logo: "/images/logo/logo.png",
};
export const socials = [
{
id: "instagram",
label: "اینستاگرام",
labelEn: "Instagram",
href: "https://www.instagram.com/kamancable/",
},
{
id: "telegram",
label: "تلگرام",
labelEn: "Telegram",
href: "https://t.me/kamancable",
},
{
id: "linkedin",
label: "لینکدین",
labelEn: "LinkedIn",
href: "https://www.linkedin.com/company/kaman-cable",
},
{
id: "email",
label: "ایمیل",
labelEn: "Email",
href: "mailto:info@kamancable.com",
},
];
export const navLinks = [
{ href: "/", label: "صفحه اصلی", labelEn: "Home" },
{ href: "/blog", label: "وبلاگ", labelEn: "Blog" },
{ href: "/about", label: "درباره ما", labelEn: "About" },
{ href: "/contact", label: "ارتباط با ما", labelEn: "Contact" },
];
export const productsNav = {
href: "/products",
label: "محصولات",
labelEn: "Products",
};
export const slides = [
{
id: "slide-1",
image: "/images/slider/bg1.jpg",
kicker: "KAMAN CABLE & WIRE INDUSTRIES",
title: "kaman cable & wire",
titleFa: "صنایع سیم و کابل کمان",
tone: "light" as const,
},
{
id: "slide-2",
image: "/images/slider/bg2.jpg",
kicker: "ENDORSED BY THE MINISTRY OF ENERGY AND OIL COMPANY",
title: "دارای تاییدیه از وزارت نیرو و شرکت نفت",
titleFa: "دارای تاییدیه از وزارت نیرو و شرکت نفت",
tone: "dark" as const,
},
{
id: "slide-3",
image: "/images/slider/bg3.jpg",
kicker: "HIGH QUALITY AND UP-TO-DATE PRODUCTS",
title: "پیشرو در ارائه محصولات با کیفیت و بروز",
titleFa: "پیشرو در ارائه محصولات با کیفیت و بروز",
tone: "dark" as const,
},
];
export const heroHighlights = [
{ n: "۱", title: "تنوع محصولات", titleEn: "Variety of products" },
{ n: "۲", title: "خط تولید قدرتمند", titleEn: "Powerful Production Line" },
{ n: "۳", title: "تولید کننده نمونه", titleEn: "Top Producer" },
];
export const resumeText =
"شرکت صنایع کابل کمان در سال ۱۳۷۰ در زمینی به وسعت ۴۰۰۰۰ متر مربع در شهر صنعتی شماره یک واقع در استان زنجان شروع به فعالیت نمود. این واحد تولیدی پس از گذراندن مراحل نصب ماشین آلات و تامین زیر ساخت‌های مناسب و با بهره‌مندی از متخصصین و کارشناسان با تجربه در این رشته، در ابتدای سال ۱۳۷۳ موفق به اخذ پروانه بهره‌برداری با ظرفیت ۱۵۰۰۰ تن و دریافت گواهینامه استاندارد ملی (ISIRI) گردید. از آنجایی که کیفیت و رضایتمندی مشتری سرلوحه کلیه فعالیت‌ها است، لذا این واحد تولیدی با تکیه بر اصل مشتری‌مداری و با تلاش متخصصین خود توانست در سال‌های نخست گواهینامه ISO 9001:2015 و CE را اخذ نموده و در راستای نیاز روزافزون بازارهای داخلی و کشورهای همسایه به صورت گسترده محصولات خود را با کیفیت مرغوب و سازگار با اهداف مشتری عرضه نماید.";
export const goalsText = [
"صداقت بهترین روش است.",
"شرکت صنایع کابل کمان به‌عنوان یکی از تولیدکنندگان سیم و کابل، رضایت مشتریان را از طریق ارتقاء کیفیت و رعایت کلیه قوانین و الزامات حاکم بر محصول خود، به‌عنوان برترین هدف برگزیده است. در این راستا مدیریت ارشد، اعتقاد و تعهد خود را به اجرا، حفظ و بهبود مستمر اثربخشی سیستم مدیریت کیفیت بر اساس استاندارد ISO 9001:2015 اعلام می‌دارد.",
];
export const qualityPolicy = [
"افزایش تنوع و حجم محصولات تولیدی",
"افزایش سطح رضایتمندی مشتریان",
"توسعه استانداردهای ملی و بین‌المللی در سازمان",
];
export const aboutIntro =
"شرکت کابل کمان یک شرکت موفق در زمینه تولید کابل و برق است که سابقه زیادی در این زمینه دارد. شرکت کابل کمان در حال حاضر از بهترین ماشین‌آلات شرکت‌های سازنده معتبر اروپایی استفاده می‌کند و به تولید انواع سیم و کابل فشار ضعیف برق و مخابراتی، کابل‌های کنترل و ابزار دقیق مشغول است. محصولات شرکت کابل کمان مطابق با استانداردهای ملی و بین‌المللی است و محصولاتی با بهترین کیفیت را ارائه می‌دهد و به صورت گسترده نیاز بازارهای داخلی و کشورهای همسایه را با بالاترین کیفیت و سازگار با اهداف مشتری عرضه می‌کند.";
export const laboratoryText =
"طبق تاریخچه آزمایشگاه شرکت صنایع کابل کمان در سال ۱۳۹۱ موفق به دریافت گواهینامه آزمایشگاه همکار از موسسه استاندارد استان زنجان شد و به‌عنوان همکار آن موسسه محترم اقدام به فعالیت نمود و هم‌اکنون دارای گواهینامه تایید صلاحیت آزمایشگاه از طرف مرکز ملی تایید صلاحیت ایران (ISIRI-ISO/IEC 17025) می‌باشد. علاوه بر گواهینامه‌های فوق، تاییدیه از شرکت توانیر، شرکت‌های توزیع نیروی برق استان‌های مختلف و شرکت‌های نفت را نیز دارا می‌باشد. آزمایشگاه شرکت با داشتن تجهیزات پیشرفته و متخصصین با تجربه و توانمند جهت انجام انواع تست، آمادگی خدمت‌رسانی مطابق با نیاز واحدهای مختلف صنعت برق و مشتریان گرامی را دارد.";
export const machineryText =
"این شرکت هم‌اکنون مجهز به انواع ماشین‌آلات از شرکت‌های سازنده معتبر اروپایی، در زمینه تولید انواع سیم و کابل فشار ضعیف برق و مخابراتی، کابل‌های کنترل و ابزار دقیق مطابق با انواع استانداردهای ملی (ISIRI 607, 3569-1, 1926, …) و بین‌المللی (IEC, BS, VDE, …) قادر به ارائه محصولات با کیفیت است.";
export const products = [
{
id: "power",
title: "کابل قدرت",
titleEn: "Power Cable",
text: "سیم‌های برق وظیفه انتقال جریان الکتریسیته را دارند و این انتقال توسط بخش‌های داخلی تشکیل‌دهنده انجام می‌گیرد.",
image: "/images/samples/cables-coils.jpg",
icon: "/images/products/icon-1.png",
},
{
id: "flat",
title: "کابل‌های تخت",
titleEn: "Flat Cables",
text: "این نوع کابل‌ها جهت استفاده در بالابرها و نیز در اتصالات متحرک مناسب می‌باشند.",
image: "/images/samples/wire-spools.avif",
icon: "/images/products/icon-2.png",
},
{
id: "phone",
title: "کابل‌های مخابراتی",
titleEn: "Phone Cables",
text: "برای اینکه رطوبت حالت اتصال و اکسیدگی بین سیم‌ها ایجاد نشود درون کابل‌های مخابراتی را از نوعی ژله پلاستیکی چسبناک پر می‌کنند.",
image: "/images/samples/cable-closeup.jpg",
icon: "/images/products/icon-3.png",
},
{
id: "network",
title: "کابل‌های شبکه",
titleEn: "Network Cable",
text: "برای برقراری ارتباط در هر شبکه نیازمند کابل شبکه هستیم. کابل LAN ارتباط بین دو یا چند کامپیوتر، سرور، روتر و … را فراهم می‌کند.",
image: "/images/samples/copper-coils.avif",
icon: "/images/products/icon-4.png",
},
{
id: "instrument",
title: "ابزار دقیق و کنترل",
titleEn: "Instrumentation & Control",
text: "ابزار دقیق در حقیقت زیرساخت یک سیستم کنترل و اتوماسیون را تشکیل می‌دهند و شامل انواع سنسور، انواع کنترلر و … می‌باشند.",
image: "/images/samples/factory-floor.avif",
icon: "/images/products/icon-5.png",
},
{
id: "coaxial",
title: "کواکسیال",
titleEn: "Coaxial",
text: "کابل کواکسیال نوع خیلی رایج از کابل انتقال داده‌های محافظت‌شده می‌باشد که از دو رسانا ساخته شده است.",
image: "/images/samples/production-line.avif",
icon: "/images/products/icon-6.png",
},
{
id: "fire",
title: "اعلام حریق",
titleEn: "Fire Alarm",
text: "سیستم‌های اعلام حریق وظیفه اطلاع‌رسانی در هنگام آتش‌سوزی را بر عهده دارند و می‌توان آن‌ها را به دو گروه تقسیم کرد.",
image: "/images/slider/bg2.jpg",
icon: "/images/products/icon-7.png",
},
{
id: "smart",
title: "خانه هوشمند",
titleEn: "Smart House",
text: "تفاوت خانه هوشمند با خانه معمولی این است که همه وسایل خانه هوشمند به یکدیگر متصل هستند و با دستگاه مرکزی کنترل می‌شوند.",
image: "/images/slider/bg3.jpg",
icon: "/images/products/icon-4.png",
},
];
export const blogPosts = [
{
id: "hv-clearance",
title: "حریم سیم برق فشار قوی",
titleEn: "High-voltage clearance",
date: "۱۲ مرداد ۱۴۰۴",
text: "حریم سیم برق فشار قوی فاصله‌ای ایمن برای جلوگیری از برق‌گرفتگی و آسیب به ساختمان‌ها است و با افزایش ولتاژ، این فاصله بیشتر می‌شود.",
image: "/images/samples/cables-coils.jpg",
href: "https://kamancable.com/high-voltage-power-wire-privacy/",
},
{
id: "lv-mv",
title: "کابل فشار ضعیف و متوسط",
titleEn: "Low and medium voltage cable",
date: "۲۸ تیر ۱۴۰۴",
text: "کابل فشار ضعیف تا ۱ کیلوولت برای ساختمان و کابل فشار متوسط تا ۳۶ کیلوولت برای صنعت به کار می‌رود؛ انتخاب درست به ولتاژ، مقطع و محل نصب بستگی دارد.",
image: "/images/samples/production-line.avif",
href: "https://kamancable.com/low-and-medium-voltage-cable/",
},
{
id: "cable-parts",
title: "اجزای تشکیل‌دهنده کابل",
titleEn: "Cable components",
date: "۱۵ تیر ۱۴۰۴",
text: "هادی، عایق، شیلد و روکش بیرونی ساختار کابل را می‌سازند و هر لایه نقش مشخصی در انتقال جریان و حفاظت مکانیکی دارد.",
image: "/images/samples/cable-closeup.jpg",
href: "https://kamancable.com/cable-components/",
},
{
id: "two-three-core",
title: "تفاوت کابل دو رشته با سه رشته",
titleEn: "Two-core vs three-core cables",
date: "۲ خرداد ۱۴۰۴",
text: "کابل دو رشته فاز و نول را منتقل می‌کند و کابل سه رشته با افزودن ارت، ایمنی مدار را در ساختمان و صنعت بالاتر می‌برد.",
image: "/images/samples/wire-spools.avif",
href: "https://kamancable.com/the-difference-between-two-core-and-three-core-cables/",
},
{
id: "wire-vs-cable",
title: "سیم و کابل چیست و چه تفاوتی دارند؟",
titleEn: "Wire vs cable",
date: "۱۸ اردیبهشت ۱۴۰۴",
text: "سیم معمولاً یک هادی عایق‌شده است؛ کابل چند رشته را در غلاف مشترک قرار می‌دهد تا مقاومت مکانیکی و تحمل ولتاژ بیشتری داشته باشد.",
image: "/images/samples/copper-coils.avif",
href: "https://kamancable.com/what-are-wires-and-cables/",
},
{
id: "cross-section",
title: "سطح مقطع کابل چیست؟",
titleEn: "Cable cross-section",
date: "۴ اردیبهشت ۱۴۰۴",
text: "سطح مقطع، جریان مجاز و افت ولتاژ را تعیین می‌کند؛ انتخاب مقطع مناسب به بار، طول مسیر، نوع هادی و نحوه نصب وابسته است.",
image: "/images/samples/factory-floor.avif",
href: "https://kamancable.com/what-is-the-cross-section-of-the-cable/",
},
];
export const clients = [
{ id: "c1", image: "/images/clients/client-1.png", title: "همکار ۱" },
{ id: "c2", image: "/images/clients/client-2.png", title: "همکار ۲" },
{ id: "c3", image: "/images/clients/client-3.png", title: "همکار ۳" },
{ id: "c4", image: "/images/clients/client-4.png", title: "همکار ۴" },
{ id: "c5", image: "/images/clients/client-5.png", title: "همکار ۵" },
];
export const aboutGallery = [
{ id: "g1", image: "/images/about/factory.jpg", title: "کارخانه کابل کمان" },
{ id: "g2", image: "/images/samples/cables-coils.jpg", title: "کلاف‌های کابل" },
{ id: "g3", image: "/images/samples/cable-closeup.jpg", title: "جزئیات کابل" },
{ id: "g4", image: "/images/samples/copper-coils.avif", title: "کلاف مس" },
{ id: "g5", image: "/images/samples/factory-floor.avif", title: "خط تولید" },
{ id: "g6", image: "/images/samples/production-line.avif", title: "ماشین‌آلات تولید" },
];
export const stats = [
{ value: "۱۳۷۰", label: "سال تاسیس", labelEn: "Founded" },
{ value: "۴۰٬۰۰۰", label: "متر مربع کارخانه", labelEn: "Factory Area" },
{ value: "۱۵٬۰۰۰", label: "تن ظرفیت سالانه", labelEn: "Annual Capacity" },
{ value: "ISO", label: "۹۰۰۱:۲۰۱۵ و ۱۷۰۲۵", labelEn: "Certified" },
];
+83
View File
@@ -0,0 +1,83 @@
import { blogPosts } from "@/data/site";
import { formatFaDate } from "@/lib/format";
import { meshkeeGet, meshkeeGetOrNull, tenantPath } from "@/lib/meshkee";
import { detailPath, slugifyForUrl } from "@/lib/slug";
export type BlogPost = {
id: string;
title: string;
slug?: string | null;
abstract?: string | null;
mainTextHtml?: string | null;
titleImageUrl?: string | null;
publishedAt?: string | null;
createdAt?: string | null;
commentCount?: number;
};
export type PagedBlogs = {
items: BlogPost[];
total: number;
page: number;
pageSize: number;
};
export async function fetchBlogs(params: { page?: number; pageSize?: number } = {}) {
const query = new URLSearchParams();
query.set("page", String(params.page ?? 1));
query.set("pageSize", String(params.pageSize ?? 12));
const data = await meshkeeGet<PagedBlogs>(tenantPath(`/blogs?${query.toString()}`), {
items: [],
total: 0,
page: 1,
pageSize: params.pageSize ?? 12,
});
return {
...data,
items: (data.items ?? []).map((item) => ({
...item,
id: String(item.id),
})),
};
}
export async function fetchBlogById(id: string) {
const data = await meshkeeGetOrNull<{ blog?: BlogPost }>(
tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`),
);
const blog = data?.blog;
return blog ? { ...blog, id: String(blog.id) } : null;
}
export function blogHref(post: Pick<BlogPost, "id" | "title">) {
return detailPath("/blog", post.id, post.title);
}
export function blogTitleSlug(title: string) {
return slugifyForUrl(title);
}
export function blogImage(post: BlogPost, fallback = "/images/samples/cables-coils.jpg") {
return post.titleImageUrl?.trim() || fallback;
}
export function blogDate(post: BlogPost) {
return formatFaDate(post.publishedAt || post.createdAt);
}
export function getLocalBlogPosts(): BlogPost[] {
return blogPosts.map((post) => ({
id: post.id,
title: post.title,
slug: post.id,
abstract: post.text,
titleImageUrl: post.image,
publishedAt: null,
createdAt: null,
}));
}
export function getLocalBlogPost(idOrSlug: string) {
const posts = getLocalBlogPosts();
return posts.find((post) => post.id === idOrSlug || post.slug === idOrSlug) ?? null;
}
+36
View File
@@ -0,0 +1,36 @@
import { meshkeeGet, meshkeeGetOrNull, tenantPath } from "@/lib/meshkee";
import { detailPath } from "@/lib/slug";
export type ProductCategory = {
id: string;
name?: string | null;
nameFa?: string | null;
slug?: string | null;
description?: string | null;
parentId?: string | null;
entityType?: string | null;
};
export function categoryTitle(category: Pick<ProductCategory, "nameFa" | "name">) {
return category.nameFa?.trim() || category.name?.trim() || "دسته‌بندی";
}
export function categoryHref(category: Pick<ProductCategory, "id" | "nameFa" | "name">) {
return detailPath("/products/category", category.id, categoryTitle(category));
}
export async function fetchCategories(entityType: "product" | "blog" = "product") {
const data = await meshkeeGet<{ items?: ProductCategory[] }>(
tenantPath(`/categories?entityType=${entityType}`),
{ items: [] },
);
return (data.items ?? []).map((item) => ({ ...item, id: String(item.id) }));
}
export async function fetchCategoryById(id: string, entityType: "product" | "blog" = "product") {
const data = await meshkeeGetOrNull<{ category?: ProductCategory }>(
tenantPath(`/categories/by-id/${encodeURIComponent(id)}?entityType=${entityType}`),
);
const category = data?.category;
return category ? { ...category, id: String(category.id) } : null;
}
+19
View File
@@ -0,0 +1,19 @@
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
export type CommentItem = {
id?: string;
authorName?: string | null;
text?: string | null;
createdAt?: string | null;
};
export async function fetchComments(
entityType: "product" | "blog",
entityId: string,
) {
const data = await meshkeeGet<{ items?: CommentItem[] }>(
tenantPath(`/comments?entityType=${entityType}&entityId=${encodeURIComponent(entityId)}`),
{ items: [] },
);
return data.items ?? [];
}
+20
View File
@@ -0,0 +1,20 @@
export const websiteDomain =
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ??
process.env.WEBSITE_DOMAIN ??
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ??
"kamancable.ir";
export const meshkeeApiBase = (
process.env.NEXT_PUBLIC_MESHKEE_API_BASE ??
process.env.MESHKEE_API_BASE ??
process.env.NEXT_PUBLIC_MESHKEE_API_URL ??
"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(/\/$/, "");
+29
View File
@@ -0,0 +1,29 @@
export function formatFaDate(value?: string | null) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("fa-IR", {
day: "numeric",
month: "long",
year: "numeric",
}).format(date);
}
export function sanitizeHtml(html?: string | null) {
if (!html) return "";
return html
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
.replace(/\son\w+="[^"]*"/gi, "")
.replace(/\son\w+='[^']*'/gi, "")
.replace(/javascript:/gi, "");
}
export function plainText(html?: string | null) {
if (!html) return "";
return html
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
+42
View File
@@ -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);
}
+45
View File
@@ -0,0 +1,45 @@
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function meshkeeGet<T>(path: string, fallback: T, revalidate = 60): Promise<T> {
try {
const response = await fetch(`${meshkeeApiBase}${path}`, {
next: { revalidate },
});
if (!response.ok) return fallback;
return (await response.json()) as T;
} catch {
return fallback;
}
}
export async function meshkeeGetOrNull<T>(path: string, revalidate = 60): Promise<T | null> {
try {
const response = await fetch(`${meshkeeApiBase}${path}`, {
next: { revalidate },
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
}
}
export function tenantPath(suffix: string) {
return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
}
type Tenant = {
id: number | string;
nameFa?: string;
name?: string;
domain?: string;
};
export async function getTenant() {
return meshkeeGet<Tenant | null>(`/tenants/${encodeURIComponent(websiteDomain)}`, null);
}
export async function getBusinessId() {
const tenant = await getTenant();
return tenant?.id != null ? String(tenant.id) : "";
}
+126
View File
@@ -0,0 +1,126 @@
import { products as localProducts } from "@/data/site";
import { meshkeeGet, meshkeeGetOrNull, tenantPath } from "@/lib/meshkee";
import { detailPath } from "@/lib/slug";
export type ProductImage = {
url?: string | null;
mediaId?: string | null;
};
export type CatalogProduct = {
id: string;
title?: string | null;
nameFa?: string | null;
summary?: string | null;
descriptionHtml?: string | null;
slug?: string | null;
categoryId?: string | null;
categoryName?: string | null;
categoryNameFa?: string | null;
thumbnailUrl?: string | null;
image?: string | null;
thumbnail?: string | null;
images?: ProductImage[] | string[] | null;
commentCount?: number;
};
export type ProductDetailPayload = {
product?: CatalogProduct;
relatedProducts?: CatalogProduct[];
};
export type PagedProducts = {
items: CatalogProduct[];
total: number;
page: number;
pageSize: number;
};
export type TechnicalFormField = {
id?: string | number;
label?: string | null;
key?: string | null;
type?: string | null;
sortOrder?: number | null;
options?: { id?: string | number; label?: string | null; value?: string | null }[];
};
export type TechnicalInfoValue = {
fieldId?: string | number | null;
textValue?: string | null;
optionId?: string | null;
optionIds?: string[] | null;
};
export type ProductTechnicalInfo = {
form?: { fields?: TechnicalFormField[] } | null;
values?: TechnicalInfoValue[] | null;
};
export async function fetchProducts(
params: { page?: number; pageSize?: number; name?: string; categoryId?: string } = {},
) {
const query = new URLSearchParams();
query.set("page", String(params.page ?? 1));
query.set("pageSize", String(params.pageSize ?? 12));
if (params.name) query.set("name", params.name);
if (params.categoryId) query.set("categoryId", params.categoryId);
return meshkeeGet<PagedProducts>(tenantPath(`/products?${query.toString()}`), {
items: [],
total: 0,
page: 1,
pageSize: params.pageSize ?? 12,
});
}
export async function fetchProductById(id: string) {
return meshkeeGetOrNull<ProductDetailPayload>(
tenantPath(`/products/by-id/${encodeURIComponent(id)}`),
);
}
export async function fetchProductTechnicalInfo(id: string) {
return meshkeeGetOrNull<ProductTechnicalInfo>(
tenantPath(`/products/by-id/${encodeURIComponent(id)}/technical-info`),
);
}
export function productTitle(product: Pick<CatalogProduct, "nameFa" | "title">) {
return product.nameFa?.trim() || product.title?.trim() || "محصول";
}
export function productHref(product: Pick<CatalogProduct, "id" | "nameFa" | "title">) {
return detailPath("/products", product.id, productTitle(product));
}
export function productImage(product: CatalogProduct, fallback = "/images/samples/cables-coils.jpg") {
const gallery = productGallery(product);
return gallery[0] || fallback;
}
export function productGallery(product: CatalogProduct) {
const extras = (product.images ?? [])
.map((item) => (typeof item === "string" ? item : item?.url))
.filter((url): url is string => Boolean(url?.trim()));
return Array.from(
new Set(
[product.thumbnailUrl, product.image, product.thumbnail, ...extras].filter(
(url): url is string => Boolean(url?.trim()),
),
),
);
}
export function localProductText(product: CatalogProduct) {
const title = productTitle(product).toLowerCase();
const english = (product.title || "").toLowerCase();
return (
localProducts.find(
(item) =>
item.title === product.nameFa ||
item.titleEn.toLowerCase() === english ||
title.includes(item.title) ||
english.includes(item.titleEn.toLowerCase()),
)?.text ?? ""
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { site } from "@/data/site";
import { websiteDomain } from "@/lib/config";
export const siteUrl = `https://${websiteDomain}`;
export function pageMetadata({
title,
description,
path = "/",
image,
absolute = false,
}: {
title: string;
description: string;
path?: string;
image?: string | null;
absolute?: boolean;
}): Metadata {
const url = `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
const ogImage = image || `${siteUrl}/images/slider/bg1.jpg`;
return {
title: absolute ? { absolute: title } : title,
description,
alternates: { canonical: url },
openGraph: {
type: path === "/" ? "website" : "article",
locale: "fa_IR",
url,
siteName: site.shortName,
title,
description,
images: [{ url: ogImage, alt: title }],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [ogImage],
},
};
}
+188
View File
@@ -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 || "کاربر";
}
+123
View File
@@ -0,0 +1,123 @@
/** Shared auth cookies so storefront + customer.{domain} share one login. */
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;
}
+30
View File
@@ -0,0 +1,30 @@
{
"baseUrl": "https://kamancable.ir",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/about",
"changefreq": "monthly",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.7
},
{
"path": "/contact",
"changefreq": "monthly",
"priority": 0.5
},
{
"path": "/products",
"changefreq": "weekly",
"priority": 0.8
}
]
}
+16
View File
@@ -0,0 +1,16 @@
/** URL segment from a Persian / Latin title. */
export function slugifyForUrl(value: string, fallback = "item"): string {
const slug = value
.trim()
.normalize("NFC")
.replace(/\s+/g, "-")
.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug || fallback;
}
export function detailPath(base: string, id: string | number, title: string) {
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slugifyForUrl(title))}`;
}
+264
View File
@@ -0,0 +1,264 @@
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export type SlotKind = "single" | "list";
export type SlotImage = {
id: string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number | null;
};
export type StaticImageSlotCatalogItem = {
key: string;
label: string;
kind: SlotKind;
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
export type StaticImageSlot = StaticImageSlotCatalogItem & {
images: SlotImage[];
};
export type SlotMediaItem = {
id: string;
image: string;
href?: string;
title?: string;
kicker?: string;
text?: string;
fallbackSrc?: string;
};
/**
* Slot catalog published at GET /meshkee/static-image-slots.
* If a section’s CSS size or aspect ratio changes, update this catalog
* in the same change — Meshkee reads it as the source of truth.
*
* Homepage keys match the Meshkee dashboard placeholders:
* slider, about-us-image, categories, partners (all duplicatable except about-us-image).
*/
export const STATIC_IMAGE_SLOTS: StaticImageSlotCatalogItem[] = [
{
key: "slider",
label: "Homepage slider",
kind: "list",
pageKey: "home",
aspectRatio: "1920:830",
itemCount: null,
recommendedWidth: 1440,
},
{
key: "about-us-image",
label: "Homepage about photo",
kind: "single",
pageKey: "home",
aspectRatio: "440:609",
itemCount: 1,
recommendedWidth: 480,
},
{
key: "categories",
label: "Homepage product categories",
kind: "list",
pageKey: "home",
aspectRatio: "3:4",
itemCount: null,
recommendedWidth: 480,
},
{
key: "home-blog",
label: "Homepage blog carousel",
kind: "list",
pageKey: "home",
aspectRatio: "16:10",
itemCount: 6,
recommendedWidth: 720,
},
{
key: "partners",
label: "Homepage partners",
kind: "list",
pageKey: "home",
aspectRatio: "3:2",
itemCount: null,
recommendedWidth: 280,
},
{
key: "about-hero",
label: "About page hero",
kind: "single",
pageKey: "about",
aspectRatio: "1920:640",
itemCount: 1,
recommendedWidth: 1440,
},
{
key: "about-gallery",
label: "About page gallery",
kind: "list",
pageKey: "about",
aspectRatio: "4:3",
itemCount: 6,
recommendedWidth: 720,
},
{
key: "contact-hero",
label: "Contact page hero",
kind: "single",
pageKey: "contact",
aspectRatio: "1920:480",
itemCount: 1,
recommendedWidth: 1440,
},
{
key: "products-hero",
label: "Products page hero",
kind: "single",
pageKey: "products",
aspectRatio: "1920:480",
itemCount: 1,
recommendedWidth: 1440,
},
{
key: "blog-hero",
label: "Blog page hero",
kind: "single",
pageKey: "blog",
aspectRatio: "1920:480",
itemCount: 1,
recommendedWidth: 1440,
},
];
/** Older dashboard keys still resolve if Meshkee has not been re-synced. */
const SLOT_ALIASES: Record<string, string[]> = {
"about-us-image": ["home-about-image"],
categories: ["home-products"],
partners: ["home-clients"],
};
export function catalogSlot(key: string) {
return STATIC_IMAGE_SLOTS.find((slot) => slot.key === key);
}
export function parseAspectRatio(ratio?: string | null) {
if (!ratio) return undefined;
const match = /^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/.exec(ratio.trim());
if (!match) return undefined;
return `${match[1]} / ${match[2]}`;
}
/** CSS custom property consumed by `.about-photo`, `.product-card .thumb`, `.client`. */
export function slotRatioStyle(ratio?: string | null): { "--slot-ratio": string } | undefined {
const parsed = parseAspectRatio(ratio);
return parsed ? { "--slot-ratio": parsed } : undefined;
}
export function pickSlot(slots: StaticImageSlot[], key: string) {
const match = slots.find((slot) => slot.key === key);
if (match) return match;
const aliases = SLOT_ALIASES[key];
if (!aliases) return undefined;
return slots.find((slot) => aliases.includes(slot.key));
}
function normalizeImages(images: SlotImage[] | undefined) {
return [...(images ?? [])]
.filter((image) => Boolean(image?.url))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
export function imagesFromSlot(slot?: StaticImageSlot | null): SlotImage[] {
if (!slot) return [];
const images = normalizeImages(slot.images);
if (slot.kind === "single") return images.slice(0, 1);
return images;
}
function toMedia(image: SlotImage, fallback?: SlotMediaItem): SlotMediaItem {
return {
id: String(image.id),
image: image.url,
href: image.linkUrl?.trim() || fallback?.href,
title: image.titleFa?.trim() || fallback?.title,
kicker: image.titleEn?.trim() || fallback?.kicker,
text: image.subtext?.trim() || fallback?.text,
fallbackSrc: fallback?.image || fallback?.fallbackSrc,
};
}
export function overlaySlotImages(
slot: StaticImageSlot | undefined,
fallbacks: SlotMediaItem[],
key: string,
): { items: SlotMediaItem[]; aspectRatio?: string } {
const catalog = catalogSlot(key);
const aspectRatio = slot?.aspectRatio || catalog?.aspectRatio;
const kind = slot?.kind ?? catalog?.kind ?? "list";
const itemCount = slot?.itemCount ?? catalog?.itemCount ?? null;
const apiImages = imagesFromSlot(slot ? { ...slot, kind, images: slot.images ?? [] } : undefined);
if (kind === "single") {
const image = apiImages[0];
const fallback = fallbacks[0];
return {
aspectRatio,
items: image ? [toMedia(image, fallback)] : fallbacks.slice(0, 1),
};
}
if (apiImages.length === 0) {
return {
aspectRatio,
items: itemCount ? fallbacks.slice(0, itemCount) : fallbacks,
};
}
if (itemCount) {
return {
aspectRatio,
items: Array.from({ length: itemCount }, (_, index) => {
const image = apiImages[index];
const fallback = fallbacks[index] ?? fallbacks[fallbacks.length - 1];
return image ? toMedia(image, fallback) : fallback;
}).filter(Boolean),
};
}
return {
aspectRatio,
items: apiImages.map((image, index) => toMedia(image, fallbacks[index])),
};
}
function readItems(payload: unknown): StaticImageSlot[] {
if (Array.isArray(payload)) return payload as StaticImageSlot[];
if (payload && typeof payload === "object") {
const record = payload as { items?: StaticImageSlot[]; slots?: StaticImageSlot[] };
return record.items ?? record.slots ?? [];
}
return [];
}
export async function fetchStaticImageSlots(pageKey = "home"): Promise<StaticImageSlot[]> {
const url = `${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/website/static-images?pageKey=${encodeURIComponent(pageKey)}`;
try {
const response = await fetch(url, {
next: { revalidate: 60 },
});
if (!response.ok) return [];
return readItems(await response.json());
} catch {
return [];
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { ProductTechnicalInfo, TechnicalFormField, TechnicalInfoValue } from "@/lib/products";
export type TechnicalRow = {
id: string;
label: string;
value: string;
};
function pick(...values: Array<string | number | null | undefined>) {
for (const value of values) {
if (value == null) continue;
const text = String(value).trim();
if (text) return text;
}
return "";
}
function optionLabel(field: TechnicalFormField | undefined, optionId: string) {
const option = field?.options?.find((item) => String(item.id) === optionId);
return pick(option?.label, option?.value, optionId);
}
function valueText(value: TechnicalInfoValue, field?: TechnicalFormField) {
const optionIds =
value.optionIds?.filter(Boolean).map(String) ?? (value.optionId ? [String(value.optionId)] : []);
if (optionIds.length > 0) {
const labels = optionIds.map((id) => optionLabel(field, id)).filter(Boolean);
if (labels.length) return labels.join("، ");
}
return pick(value.textValue);
}
export function rowsFromTechnicalInfo(info?: ProductTechnicalInfo | null): TechnicalRow[] {
const fields = info?.form?.fields ?? [];
const values = info?.values ?? [];
if (!fields.length || !values.length) return [];
const fieldById = new Map(fields.map((field) => [String(field.id), field]));
return values
.map((value, index) => {
const id = String(value.fieldId ?? `item-${index}`);
const field = fieldById.get(id);
const text = valueText(value, field);
if (!text) return null;
return {
id,
label: pick(field?.label, field?.key) || `فیلد ${index + 1}`,
value: text,
};
})
.filter((row): row is TechnicalRow => Boolean(row));
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}