Initial Next.js clone of Pars Elevator (plift.ir).
RTL Meshkee storefront with home/about/contact/products/blog, API auth/contact/blog wiring, and SEO sitemap-config. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
.env*.local
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
|
tmp-assets
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# CONTEXT — Plift (آسانسور پارس)
|
||||||
|
|
||||||
|
Clone of [plift.ir](https://www.plift.ir) rebuilt in **Next.js 15 (App Router)** for Meshkee.
|
||||||
|
|
||||||
|
## Brand
|
||||||
|
|
||||||
|
| Key | Value |
|
||||||
|
|---|---|
|
||||||
|
| Name (FA) | آسانسور پارس |
|
||||||
|
| Name (EN) | Pars Elevator |
|
||||||
|
| Apex domain | `plift.ir` |
|
||||||
|
| Lang / Dir | `fa` / `rtl` |
|
||||||
|
| Font | IranYekan (`public/fonts/*.ttf`) |
|
||||||
|
| Primary | `#5932FF` |
|
||||||
|
| Secondary | `#4B4B4B` |
|
||||||
|
| Tertiary | `#9A0000` |
|
||||||
|
| Accent (ochre) | `#C3964E` |
|
||||||
|
| Dark | `#232323` / `#0E0E0E` |
|
||||||
|
| Light | `#F6F6F6` |
|
||||||
|
|
||||||
|
## Meshkee API
|
||||||
|
|
||||||
|
- Docs: https://api.meshkee.com/docs/website
|
||||||
|
- OpenAPI: https://api.meshkee.com/docs/website/openapi.json
|
||||||
|
- Base: `https://api.meshkee.com/api/v1`
|
||||||
|
- Tenant: `GET /tenants/plift.ir` → `businessId` `39`
|
||||||
|
- Config: `src/lib/config.ts` (`DOMAIN`, `API_BASE`, `CUSTOMER_URL`, `BUSINESS_URL`)
|
||||||
|
- Client helpers: `src/lib/api.ts`, `src/lib/meshkee.ts`
|
||||||
|
|
||||||
|
### Auth / account
|
||||||
|
|
||||||
|
- Login / user icon → `https://customer.plift.ir` (with `?redirect=` back to site)
|
||||||
|
- Tokens in `localStorage`: `meshkee.accessToken`, `meshkee.refreshToken`
|
||||||
|
- Also captures `accessToken` / `refreshToken` from URL query/hash after portal redirect
|
||||||
|
- Logged-in header shows `{name} عزیز` + dropdown: پروفایل من · خروج · (admin) مدیریت وبسایت → `https://business.plift.ir`
|
||||||
|
- `GET /auth/me` + refresh via `POST /auth/refresh`
|
||||||
|
|
||||||
|
### Contact
|
||||||
|
|
||||||
|
- `POST /tenants/plift.ir/contact-submissions` `{ title, name, email?, cellNumber?, text }`
|
||||||
|
- UI: toast + success text + form reset (`ContactForm`)
|
||||||
|
|
||||||
|
### Blog
|
||||||
|
|
||||||
|
- List: `GET /tenants/plift.ir/blogs`
|
||||||
|
- Detail: `GET /tenants/plift.ir/blogs/by-id/{id}`
|
||||||
|
- Routes: `/blog`, `/blog/{id}/{titleSlug}`
|
||||||
|
- Homepage `#blog` shows latest 3 posts
|
||||||
|
- Module enabled on tenant; content may be empty until published in dashboard
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
|
||||||
|
- Do **not** ship `public/sitemap.xml` or `public/robots.txt` (nginx → Meshkee API).
|
||||||
|
- Live sitemap index: `/sitemap.xml` → `/sitemap-main.xml` + module sitemaps.
|
||||||
|
- `GET /meshkee/sitemap-config.json` from build-time scan of `src/app` (`scripts/generate-sitemap-config.cjs`, runs on `prebuild`).
|
||||||
|
- Output: `src/generated/sitemap-config.json` (static routes only; no auth/cart/admin).
|
||||||
|
- After deploy: dashboard → Website → Settings → Sync sitemap config.
|
||||||
|
- Canonical detail URLs (title slugify; **never** legacy DB slug):
|
||||||
|
- Blog: `/blog/{id}/{titleSlug}`
|
||||||
|
- Product: `/products/{id}/{nameFaSlug}`
|
||||||
|
- Category: `/products/category/{id}/{nameFaSlug}`
|
||||||
|
- On-page: unique title + meta description per page; exactly one H1; CMS body H1 demoted to H2; content images have meaningful `alt`.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Implemented pages:
|
||||||
|
|
||||||
|
- `/` — homepage
|
||||||
|
- `/about-us` — about
|
||||||
|
- `/contact-us` — contact (+ Google Map + API form)
|
||||||
|
- `/products` — products marketing catalog
|
||||||
|
- `/products/[id]/[slug]` — Meshkee product detail (API)
|
||||||
|
- `/products/category/[id]/[slug]` — product category (API)
|
||||||
|
- `/blog` — blog list (API)
|
||||||
|
- `/blog/[id]/[slug]` — blog detail (API)
|
||||||
|
|
||||||
|
**Still stubbed / not full Meshkee modules:** cabin selector, quality control pages (nav still points to products/about). Portfolio routes omitted (module not enabled).
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
- Live site: `https://www.plift.ir`
|
||||||
|
- Assets from Meshkee template CDN (`plift_kltGhf0Zol`)
|
||||||
|
- Logo: `public/images/logo.png` / `logo-footer.png`
|
||||||
|
- Meshkee footer credit like safeteb.com (`MeshkeeCredit`)
|
||||||
|
|
||||||
|
## Contact data
|
||||||
|
|
||||||
|
- Address: قم ، بلوارصدوقی ، نبش کوچه ۵۷ ، پلاک ۳
|
||||||
|
- Support: ۰۲۵-۳۲۹۴۴۴۴۵
|
||||||
|
- Mobile: آقای هدایتی ۰۹۱۰۲۰۴۱۶۱۲ / آقای بلاغی ۰۹۱۲۷۴۹۸۳۵۷
|
||||||
|
- Telegram: https://t.me/plift
|
||||||
|
- Eitaa: https://eitaa.com/parslift
|
||||||
|
- Customer portal: https://customer.plift.ir
|
||||||
|
- Business dashboard: https://business.plift.ir
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
app/ # pages + meshkee/sitemap-config.json route
|
||||||
|
components/ # Header, Footer, Auth, Toast, BlogCard, forms, …
|
||||||
|
data/site.ts # Copy / nav / products / counters
|
||||||
|
generated/ # sitemap-config.json (build-generated)
|
||||||
|
lib/ # api, meshkee, auth helpers, format, config
|
||||||
|
scripts/
|
||||||
|
generate-sitemap-config.cjs
|
||||||
|
public/
|
||||||
|
fonts/ images/ # no sitemap.xml / robots.txt
|
||||||
|
CONTEXT.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run generate:sitemap-config
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
npm run build # runs prebuild → sitemap-config then next build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions for next AI / prompts
|
||||||
|
|
||||||
|
1. Keep `lang="fa"` and `dir="rtl"`.
|
||||||
|
2. Prefer `src/data/site.ts` for static copy; live blog/contact/products via Meshkee API.
|
||||||
|
3. Reuse CSS variables from `globals.css`.
|
||||||
|
4. Keep Meshkee footer credit.
|
||||||
|
5. Do not invent CMS/admin write APIs — website API only.
|
||||||
|
6. Update this `CONTEXT.md` on every Meshkee prompt when scope/integrations change.
|
||||||
|
7. Detail URLs: `/blog|products|products/category/{id}/{titleSlug}` — slugify from FA title, not DB slug.
|
||||||
|
8. Never add local `sitemap.xml` / `robots.txt` / Next `app/sitemap.ts` that override Meshkee proxy.
|
||||||
|
|
||||||
|
## Homepage section map
|
||||||
|
|
||||||
|
1. Hero slider
|
||||||
|
2. Intro (`info_43`)
|
||||||
|
3. Features carousel
|
||||||
|
4. Engineering (`info_65`)
|
||||||
|
5. Products fancy
|
||||||
|
6. Counters — title «پروژه های ما» (43 آسانسور / 12 پله برقی / 32 بالابر / 12 حمل بار)
|
||||||
|
7. CTA band
|
||||||
|
8. Blog preview (API)
|
||||||
|
|
||||||
|
## Prompt history
|
||||||
|
|
||||||
|
- **basic-clone** (`de4b8fb6eec585ea637854ac`): Initial Next.js RTL clone.
|
||||||
|
- **homepage / products passes**: Match live sections; `/products` page.
|
||||||
|
- **backend** (`0ad11f82dc4cbebf9e7c9c73`): Customer portal auth UI; blog list/detail via API; contact form → contact-submissions + toast; CONTEXT updated.
|
||||||
|
- **seo** (`18c6ecc67d4a93eb2d7ae277`): Build-time sitemap-config; product/category SEO routes; on-page title/description/H1/alts; no local sitemap/robots.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# آسانسور پارس (Plift)
|
||||||
|
|
||||||
|
Next.js RTL clone of [plift.ir](https://www.plift.ir).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
- `/` — صفحه اصلی
|
||||||
|
- `/about-us` — درباره ما
|
||||||
|
- `/contact-us` — تماس با ما
|
||||||
|
|
||||||
|
See `CONTEXT.md` for brand tokens, assets, and AI/system notes.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: "https", hostname: "**.parminstorage.ir" },
|
||||||
|
{ protocol: "https", hostname: "**.parspack.net" },
|
||||||
|
{ protocol: "https", hostname: "meshkee-storage.sas.amin.parminstorage.ir" },
|
||||||
|
{ protocol: "https", hostname: "meshkee-templates.sas.amin.parminstorage.ir" },
|
||||||
|
{ protocol: "https", hostname: "api.meshkee.com" },
|
||||||
|
{ protocol: "https", hostname: "cdn.willaspace.com" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "plift",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"prebuild": "node scripts/generate-sitemap-config.cjs",
|
||||||
|
"generate:sitemap-config": "node scripts/generate-sitemap-config.cjs",
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^15.5.2",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.17.2",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
|
"tailwindcss": "^4.1.12",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-config-next": "^15.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Scans src/app for public static routes and writes
|
||||||
|
* src/generated/sitemap-config.json for Meshkee sync.
|
||||||
|
*/
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const appDir = path.join(__dirname, "../src/app");
|
||||||
|
const outDir = path.join(__dirname, "../src/generated");
|
||||||
|
const outFile = path.join(outDir, "sitemap-config.json");
|
||||||
|
|
||||||
|
const EXCLUDE_PREFIXES = [
|
||||||
|
"/meshkee",
|
||||||
|
"/api",
|
||||||
|
"/auth",
|
||||||
|
"/login",
|
||||||
|
"/checkout",
|
||||||
|
"/cart",
|
||||||
|
"/account",
|
||||||
|
"/admin",
|
||||||
|
"/customer",
|
||||||
|
];
|
||||||
|
|
||||||
|
function walk(dir, base = "") {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
const routes = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name.startsWith("[") && entry.name.endsWith("]")) continue;
|
||||||
|
const segment = entry.name.startsWith("(") ? "" : `/${entry.name}`;
|
||||||
|
routes.push(...walk(full, `${base}${segment}`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.name === "page.tsx" || entry.name === "page.ts" || entry.name === "page.jsx") {
|
||||||
|
const routePath = base || "/";
|
||||||
|
if (EXCLUDE_PREFIXES.some((p) => routePath === p || routePath.startsWith(`${p}/`))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
routes.push(routePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityFor(route) {
|
||||||
|
if (route === "/") return 1.0;
|
||||||
|
if (route === "/products") return 0.8;
|
||||||
|
if (route === "/blog") return 0.7;
|
||||||
|
if (route === "/about-us") return 0.6;
|
||||||
|
if (route === "/contact-us") return 0.5;
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changefreqFor(route) {
|
||||||
|
if (route === "/" || route === "/blog") return "daily";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = [...new Set(walk(appDir))].sort((a, b) => {
|
||||||
|
if (a === "/") return -1;
|
||||||
|
if (b === "/") return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const staticPages = routes.map((routePath) => {
|
||||||
|
const page = {
|
||||||
|
path: routePath,
|
||||||
|
priority: priorityFor(routePath),
|
||||||
|
};
|
||||||
|
const cf = changefreqFor(routePath);
|
||||||
|
if (cf) page.changefreq = cf;
|
||||||
|
return page;
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
baseUrl: "https://www.plift.ir",
|
||||||
|
staticPages,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||||
|
console.log(`Wrote ${outFile} (${staticPages.length} static pages)`);
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Counter } from "@/components/Counter";
|
||||||
|
import { aboutPage, site } from "@/data/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "درباره ی ما",
|
||||||
|
description:
|
||||||
|
"آشنایی با شرکت فنی مهندسی پارس؛ تجربه یک دهه در طراحی، نصب و خدمات آسانسور و پله برقی.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
className="page-banner"
|
||||||
|
style={{ backgroundImage: `url(${aboutPage.banner})` }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-sm text-white/80">{aboutPage.subtitle}</p>
|
||||||
|
<h1 className="text-3xl font-bold md:text-5xl">{aboutPage.title}</h1>
|
||||||
|
<div className="mx-auto mt-6 h-[3px] w-20 bg-white" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-16 md:py-24">
|
||||||
|
<div className="container-plift grid gap-8 lg:grid-cols-2">
|
||||||
|
<div className="relative min-h-[480px] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${aboutPage.cabin})` }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-y-0 right-0 flex w-full items-end bg-primary/95 p-8 text-white sm:w-[55%] sm:p-10">
|
||||||
|
<div>
|
||||||
|
<p className="mb-8 text-[15px] leading-8 text-white/90 text-justify">
|
||||||
|
{aboutPage.intro}
|
||||||
|
</p>
|
||||||
|
<p className="mb-8 text-[15px] leading-8 text-white/90 text-justify">
|
||||||
|
{aboutPage.serviceText}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/contact-us"
|
||||||
|
className="text-sm font-medium underline underline-offset-8"
|
||||||
|
>
|
||||||
|
تماس با ما
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white shadow-[0_20px_60px_rgba(0,0,0,0.08)]">
|
||||||
|
<Image
|
||||||
|
src={aboutPage.lobby}
|
||||||
|
alt="لابی و فضای نصب آسانسور پارس"
|
||||||
|
width={720}
|
||||||
|
height={420}
|
||||||
|
className="h-56 w-full object-cover md:h-64"
|
||||||
|
/>
|
||||||
|
<div className="p-8 md:p-10">
|
||||||
|
<h2 className="mb-5 text-2xl font-bold text-dark">
|
||||||
|
شرکت آسانسور پارس
|
||||||
|
</h2>
|
||||||
|
<p className="text-[15px] leading-8 text-[#373737] text-justify">
|
||||||
|
{aboutPage.techText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-[#eee] py-16 md:py-20">
|
||||||
|
<div className="container-plift grid gap-10 sm:grid-cols-3">
|
||||||
|
{aboutPage.stats.map((stat) => (
|
||||||
|
<div key={stat.labelTop} className="text-center">
|
||||||
|
<Counter
|
||||||
|
value={stat.value}
|
||||||
|
className="mb-3 block text-5xl font-semibold text-primary md:text-6xl"
|
||||||
|
/>
|
||||||
|
<div className="text-sm leading-7 text-muted">
|
||||||
|
<div>{stat.labelTop}</div>
|
||||||
|
<div>{stat.labelBottom}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { demoteCmsH1, formatFaDate, titleSlug } from "@/lib/format";
|
||||||
|
import { getBlogById } from "@/lib/meshkee";
|
||||||
|
import { site } from "@/data/site";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const blog = await getBlogById(id);
|
||||||
|
const description = (blog.abstract || site.description).slice(0, 160);
|
||||||
|
return {
|
||||||
|
title: blog.title,
|
||||||
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title: blog.title,
|
||||||
|
description,
|
||||||
|
images: blog.titleImageUrl ? [{ url: blog.titleImageUrl }] : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { title: "مقاله" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogDetailPage({ params }: Props) {
|
||||||
|
const { id, slug } = await params;
|
||||||
|
let blog;
|
||||||
|
try {
|
||||||
|
blog = await getBlogById(id);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blog?.id) notFound();
|
||||||
|
|
||||||
|
const canonicalSlug = titleSlug(blog.title);
|
||||||
|
if (slug !== canonicalSlug) {
|
||||||
|
redirect(`/blog/${blog.id}/${canonicalSlug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = formatFaDate(blog.publishedAt || blog.createdAt);
|
||||||
|
const author = [blog.author?.firstName, blog.author?.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="bg-extra-dark py-14 text-white md:py-16">
|
||||||
|
<div className="container-plift max-w-4xl">
|
||||||
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="mb-6 inline-block text-sm text-white/60 transition hover:text-white"
|
||||||
|
>
|
||||||
|
بازگشت به مقالات
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold leading-relaxed md:text-3xl">
|
||||||
|
{blog.title}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-4 text-sm text-white/55">
|
||||||
|
{date && <time>{date}</time>}
|
||||||
|
{author && <span>{author}</span>}
|
||||||
|
{blog.categoryName && <span>{blog.categoryName}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<article className="py-12 md:py-16">
|
||||||
|
<div className="container-plift max-w-4xl">
|
||||||
|
{blog.titleImageUrl && (
|
||||||
|
<div className="relative mb-10 aspect-[16/9] overflow-hidden bg-[#eee]">
|
||||||
|
<Image
|
||||||
|
src={blog.titleImageUrl}
|
||||||
|
alt={blog.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width:1024px) 100vw, 896px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blog.abstract && (
|
||||||
|
<p className="mb-8 text-base leading-8 text-muted text-justify">
|
||||||
|
{blog.abstract}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blog.mainTextHtml ? (
|
||||||
|
<div
|
||||||
|
className="blog-content text-sm leading-8 text-[#444] text-justify [&_h2]:mb-3 [&_h2]:mt-8 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:text-dark [&_h3]:mb-2 [&_h3]:mt-6 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-dark [&_img]:my-6 [&_img]:h-auto [&_img]:max-w-full [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:ps-5 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:ps-5"
|
||||||
|
dangerouslySetInnerHTML={{ __html: demoteCmsH1(blog.mainTextHtml) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted">متن این مقاله در دسترس نیست.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BlogCard } from "@/components/BlogCard";
|
||||||
|
import { listBlogs } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "مقالات",
|
||||||
|
description:
|
||||||
|
"مقالات و اخبار تخصصی آسانسور و پلهبرقی از آسانسور پارس؛ راهنماها و نکات فنی.",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BlogPage({ searchParams }: Props) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const page = Math.max(1, Number(params.page) || 1);
|
||||||
|
const pageSize = 9;
|
||||||
|
let items: Awaited<ReturnType<typeof listBlogs>>["items"] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await listBlogs(page, pageSize);
|
||||||
|
items = data.items || [];
|
||||||
|
total = data.total || 0;
|
||||||
|
} catch {
|
||||||
|
items = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="bg-extra-dark py-16 text-center text-white md:py-20">
|
||||||
|
<div className="container-plift">
|
||||||
|
<p className="mb-3 text-sm text-white/60">ممکن است دوست داشته باشید</p>
|
||||||
|
<h1 className="text-3xl font-bold md:text-4xl">مقالات ما</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-[#F7F7F7] py-14 md:py-20">
|
||||||
|
<div className="container-plift">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="mx-auto max-w-xl text-center text-sm leading-7 text-muted">
|
||||||
|
هنوز مقالهای منتشر نشده است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((blog) => (
|
||||||
|
<BlogCard key={String(blog.id)} blog={blog} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-12 flex items-center justify-center gap-3">
|
||||||
|
{page > 1 && (
|
||||||
|
<Link
|
||||||
|
href={`/blog?page=${page - 1}`}
|
||||||
|
className="border border-dark px-4 py-2 text-sm transition hover:bg-dark hover:text-white"
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
صفحه {page} از {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages && (
|
||||||
|
<Link
|
||||||
|
href={`/blog?page=${page + 1}`}
|
||||||
|
className="border border-dark px-4 py-2 text-sm transition hover:bg-dark hover:text-white"
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { ContactForm, ContactInfoCards } from "@/components/ContactForm";
|
||||||
|
import { contactPage, site } from "@/data/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "تماس با ما",
|
||||||
|
description:
|
||||||
|
"راههای ارتباط با آسانسور پارس در قم؛ تلفن پشتیبانی، موبایل و فرم تماس آنلاین.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContactPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
className="page-banner"
|
||||||
|
style={{ backgroundImage: `url(${contactPage.banner})` }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-sm text-white/80">{contactPage.subtitle}</p>
|
||||||
|
<h1 className="text-3xl font-bold md:text-5xl">{contactPage.title}</h1>
|
||||||
|
<div className="mx-auto mt-6 h-[3px] w-20 bg-white" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="relative z-10 -mt-16 pb-8">
|
||||||
|
<ContactInfoCards />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-light py-10">
|
||||||
|
<div className="container-plift flex flex-col items-start justify-between gap-6 border-b border-[#ddd] pb-8 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-dark md:text-xl">
|
||||||
|
با رسانــــــــــه های اجتماعــــــی مــــــا
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold text-dark md:text-xl">
|
||||||
|
ارتباط برقرار کنید
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={`mailto:${site.email}`}
|
||||||
|
className="inline-flex h-11 w-11 items-center justify-center bg-secondary text-white transition hover:bg-primary"
|
||||||
|
aria-label="ایمیل"
|
||||||
|
>
|
||||||
|
<MailIcon />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={site.social.telegram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex h-11 w-11 items-center justify-center bg-secondary text-white transition hover:bg-primary"
|
||||||
|
aria-label="تلگرام"
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-16 md:py-24">
|
||||||
|
<div className="container-plift max-w-4xl text-center">
|
||||||
|
<h2 className="section-title mb-3 text-2xl md:text-3xl">
|
||||||
|
{contactPage.formTitle}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-12 text-sm text-muted">{contactPage.formSubtitle}</p>
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-[#e8e8e8]">
|
||||||
|
<div className="h-[420px] w-full md:h-[600px]">
|
||||||
|
<iframe
|
||||||
|
title="موقعیت آسانسور پارس روی نقشه"
|
||||||
|
src={contactPage.mapEmbed}
|
||||||
|
className="h-full w-full border-0 grayscale"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MailIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path d="M4 4h16v16H4z" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M4 7l8 6 8-6" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M22 2L11 13"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M22 2L15 22l-4-9-9-4 20-7z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IranYekan";
|
||||||
|
src: url("/fonts/iran-yekan-thin.ttf") format("truetype");
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IranYekan";
|
||||||
|
src: url("/fonts/iran-yekan-light.ttf") format("truetype");
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IranYekan";
|
||||||
|
src: url("/fonts/iran-yekan-regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IranYekan";
|
||||||
|
src: url("/fonts/iran-yekan-medium.ttf") format("truetype");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IranYekan";
|
||||||
|
src: url("/fonts/iran-yekan-bold.ttf") format("truetype");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IranYekan";
|
||||||
|
src: url("/fonts/iran-yekan-extrabold.ttf") format("truetype");
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "IranYekan";
|
||||||
|
src: url("/fonts/iran-yekan-black.ttf") format("truetype");
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-primary: #5932ff;
|
||||||
|
--color-secondary: #4b4b4b;
|
||||||
|
--color-tertiary: #9a0000;
|
||||||
|
--color-ochre: #c3964e;
|
||||||
|
--color-dark: #232323;
|
||||||
|
--color-extra-dark: #0e0e0e;
|
||||||
|
--color-muted: #5c6873;
|
||||||
|
--color-light: #f6f6f6;
|
||||||
|
--color-border: #e4e4e4;
|
||||||
|
--font-sans: "IranYekan", Tahoma, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #5932ff;
|
||||||
|
--color-secondary: #4b4b4b;
|
||||||
|
--color-tertiary: #9a0000;
|
||||||
|
--color-ochre: #c3964e;
|
||||||
|
--color-dark: #232323;
|
||||||
|
--color-extra-dark: #0e0e0e;
|
||||||
|
--color-muted: #5c6873;
|
||||||
|
--color-light: #f6f6f6;
|
||||||
|
--color-border: #e4e4e4;
|
||||||
|
--font-sans: "IranYekan", Tahoma, sans-serif;
|
||||||
|
--header-h: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-muted);
|
||||||
|
background: #fff;
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-plift {
|
||||||
|
width: min(1170px, calc(100% - 2rem));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-dark);
|
||||||
|
color: #fff !important;
|
||||||
|
border: 1px solid var(--color-dark);
|
||||||
|
padding: 0.85rem 1.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dark:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--color-dark);
|
||||||
|
color: var(--color-dark);
|
||||||
|
padding: 0.7rem 1.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: var(--color-dark);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-btn {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-dark);
|
||||||
|
color: #fff;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-btn:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-banner {
|
||||||
|
position: relative;
|
||||||
|
min-height: 360px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero white-box sequence animation */
|
||||||
|
@keyframes heroFadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 22px, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heroPop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.72);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-anim {
|
||||||
|
opacity: 0;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-anim-1 {
|
||||||
|
animation-name: heroFadeUp;
|
||||||
|
animation-duration: 0.55s;
|
||||||
|
animation-delay: 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-anim-2 {
|
||||||
|
animation-name: heroFadeUp;
|
||||||
|
animation-duration: 0.6s;
|
||||||
|
animation-delay: 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-anim-3 {
|
||||||
|
animation-name: heroPop;
|
||||||
|
animation-duration: 0.55s;
|
||||||
|
animation-delay: 0.48s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-banner::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(14, 14, 14, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-banner > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: var(--color-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-justify {
|
||||||
|
text-align: justify;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meshkee credit — mirrored from safeteb.com footer bottom */
|
||||||
|
.footer-bottom-bar {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom-site {
|
||||||
|
color: #9c9c9c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit {
|
||||||
|
align-items: center;
|
||||||
|
color: #9c9c9c;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
position: relative;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover {
|
||||||
|
color: #e0e0e0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__frame {
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line {
|
||||||
|
background: #777;
|
||||||
|
position: absolute;
|
||||||
|
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line--l {
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: top;
|
||||||
|
transition-delay: 0s;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line--b {
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
left: 0;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition-delay: 0.14s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line--r {
|
||||||
|
height: 100%;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: scaleY(0);
|
||||||
|
transform-origin: bottom;
|
||||||
|
transition-delay: 0.28s;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__line--t {
|
||||||
|
height: 1px;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: right;
|
||||||
|
transition-delay: 0.42s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover .meshkee-credit__line--l,
|
||||||
|
.meshkee-credit:hover .meshkee-credit__line--r {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover .meshkee-credit__line--b,
|
||||||
|
.meshkee-credit:hover .meshkee-credit__line--t {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:not(:hover) .meshkee-credit__line--l {
|
||||||
|
transition-delay: 0.42s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:not(:hover) .meshkee-credit__line--b {
|
||||||
|
transition-delay: 0.28s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:not(:hover) .meshkee-credit__line--r {
|
||||||
|
transition-delay: 0.14s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:not(:hover) .meshkee-credit__line--t {
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__logo {
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 42px;
|
||||||
|
width: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.7;
|
||||||
|
position: relative;
|
||||||
|
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover .meshkee-credit__logo {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.25;
|
||||||
|
min-height: 42px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit__name {
|
||||||
|
color: #cfcfcf;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meshkee-credit:hover .meshkee-credit__name {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-fancy-item {
|
||||||
|
border-inline-start: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-fancy-item:first-child {
|
||||||
|
border-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-fancy-bg {
|
||||||
|
/* expand to the section (nearest positioned ancestor) */
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.products-fancy-item {
|
||||||
|
border-inline-start: 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-fancy-item:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.products-fancy-item {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-fancy-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-fancy-item:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Header } from "@/components/Header";
|
||||||
|
import { Footer } from "@/components/Footer";
|
||||||
|
import { AuthProvider } from "@/components/AuthProvider";
|
||||||
|
import { ToastProvider } from "@/components/ToastProvider";
|
||||||
|
import { site } from "@/data/site";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: site.name,
|
||||||
|
template: `%s | ${site.name}`,
|
||||||
|
},
|
||||||
|
description: site.description.slice(0, 160),
|
||||||
|
metadataBase: new URL("https://www.plift.ir"),
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "fa_IR",
|
||||||
|
siteName: site.name,
|
||||||
|
title: site.name,
|
||||||
|
description: site.description.slice(0, 160),
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: site.favicon,
|
||||||
|
shortcut: site.favicon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<body className="min-h-screen antialiased">
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<Header />
|
||||||
|
<main>{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import sitemapConfig from "@/generated/sitemap-config.json";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json(sitemapConfig);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { BlogCard } from "@/components/BlogCard";
|
||||||
|
import { Counter } from "@/components/Counter";
|
||||||
|
import { FeaturesCarousel } from "@/components/FeaturesCarousel";
|
||||||
|
import { HeroSlider } from "@/components/HeroSlider";
|
||||||
|
import { ProductsFancy } from "@/components/ProductsFancy";
|
||||||
|
import {
|
||||||
|
counters,
|
||||||
|
engineeringSection,
|
||||||
|
introSection,
|
||||||
|
site,
|
||||||
|
} from "@/data/site";
|
||||||
|
import { listBlogs } from "@/lib/meshkee";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
absolute: `${site.name} | طراحی، نصب و خدمات آسانسور و پله برقی`,
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"شرکت فنی مهندسی پارس؛ طراحی، نصب و خدمات پس از فروش آسانسور و پله برقی با استانداردهای ایمنی و مهندسی روز.",
|
||||||
|
openGraph: {
|
||||||
|
title: site.name,
|
||||||
|
description:
|
||||||
|
"طراحی، نصب و خدمات پس از فروش آسانسور و پله برقی — آسانسور پارس",
|
||||||
|
images: [{ url: site.logo }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
let blogs: Awaited<ReturnType<typeof listBlogs>>["items"] = [];
|
||||||
|
try {
|
||||||
|
const data = await listBlogs(1, 3);
|
||||||
|
blogs = data.items || [];
|
||||||
|
} catch {
|
||||||
|
blogs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeroSlider />
|
||||||
|
|
||||||
|
{/* info_43 — intro with overhanging image */}
|
||||||
|
<section className="relative z-[2] overflow-visible py-20 md:py-28">
|
||||||
|
<div className="container-plift grid items-stretch gap-10 lg:grid-cols-12 lg:gap-8">
|
||||||
|
<div className="flex flex-col items-center text-center lg:col-span-4 lg:items-start lg:text-start">
|
||||||
|
<h2 className="mb-16 text-[1.55rem] font-bold uppercase leading-snug text-dark md:mb-24 md:text-[1.65rem]">
|
||||||
|
{introSection.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-auto flex w-full max-w-[200px] flex-col items-stretch">
|
||||||
|
<a
|
||||||
|
href={`tel:${site.phoneRaw}`}
|
||||||
|
className="mb-8 block border-b border-[#cfcfcf] pb-2 text-center text-[16px] font-medium text-[#6a6a6a] transition hover:text-dark"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
۰۲۵ - ۳۲۹۴۴۴۴۵
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/about-us"
|
||||||
|
className="inline-flex items-center justify-center border border-dark bg-white px-6 py-3 text-[12px] font-medium tracking-wide text-dark no-underline transition hover:bg-dark hover:text-white"
|
||||||
|
style={{ color: "#232323" }}
|
||||||
|
>
|
||||||
|
{introSection.cta}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4">
|
||||||
|
<div className="mb-6 text-[16px] font-semibold uppercase text-dark">
|
||||||
|
{introSection.subtitle}
|
||||||
|
</div>
|
||||||
|
<p className="w-[95%] text-[14px] font-light leading-[26px] text-[#5C6873] text-justify">
|
||||||
|
{introSection.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-4">
|
||||||
|
<div className="relative lg:-mb-[14vw]">
|
||||||
|
<Image
|
||||||
|
src={introSection.image}
|
||||||
|
alt="طراحی و اجرای پروژه آسانسور پارس"
|
||||||
|
width={420}
|
||||||
|
height={630}
|
||||||
|
className="relative z-10 w-full object-cover shadow-[0_25px_60px_rgba(0,0,0,0.12)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<FeaturesCarousel />
|
||||||
|
|
||||||
|
{/* info_65 — engineering */}
|
||||||
|
<section
|
||||||
|
className="overflow-visible bg-cover bg-center py-20 md:py-28"
|
||||||
|
style={{ backgroundImage: `url(${engineeringSection.bg})` }}
|
||||||
|
>
|
||||||
|
<div className="container-plift grid items-center gap-12 lg:grid-cols-12">
|
||||||
|
<div className="relative min-h-[420px] lg:col-span-6 lg:min-h-[520px]">
|
||||||
|
<div className="absolute bottom-0 end-0 z-10 w-[65%] max-w-md translate-y-4 lg:translate-y-8">
|
||||||
|
<Image
|
||||||
|
src={engineeringSection.imageBack}
|
||||||
|
alt="نمای پروژه آسانسور پارس در ساختمان"
|
||||||
|
width={420}
|
||||||
|
height={560}
|
||||||
|
className="w-full object-cover shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-20 w-[50%] max-w-xs overflow-hidden md:w-[55%]">
|
||||||
|
<Image
|
||||||
|
src={engineeringSection.imageFront}
|
||||||
|
alt="جزئیات کابین و تجهیزات آسانسور پارس"
|
||||||
|
width={320}
|
||||||
|
height={420}
|
||||||
|
className="w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-5 lg:col-start-8">
|
||||||
|
<div className="mb-7 flex items-center gap-4">
|
||||||
|
<span className="h-px w-8 bg-[#C3964E]/70" />
|
||||||
|
<span className="text-[17px] font-medium uppercase text-[#A0A0A0]">
|
||||||
|
{engineeringSection.eyebrow}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-7 text-[1.7rem] font-bold uppercase leading-snug text-dark md:text-[2rem]">
|
||||||
|
{engineeringSection.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-8 max-w-md text-[14px] font-light leading-[26px] text-muted text-justify">
|
||||||
|
{engineeringSection.text}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link
|
||||||
|
href="/about-us"
|
||||||
|
className="inline-flex bg-dark px-7 py-3.5 text-[13px] font-medium tracking-wide text-white! no-underline transition hover:bg-primary"
|
||||||
|
style={{ color: "#fff" }}
|
||||||
|
>
|
||||||
|
{engineeringSection.cta}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ProductsFancy />
|
||||||
|
|
||||||
|
{/* counters */}
|
||||||
|
<section className="py-16 md:py-24">
|
||||||
|
<div className="container-plift">
|
||||||
|
<h2 className="mb-12 text-center text-[1.55rem] font-medium text-dark md:mb-16 md:text-[1.6rem]">
|
||||||
|
پروژه های ما
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{counters.map((item) => (
|
||||||
|
<div key={item.label} className="text-center">
|
||||||
|
<Counter
|
||||||
|
value={item.value}
|
||||||
|
className="mb-3 block text-4xl font-semibold tracking-tight text-dark md:text-5xl"
|
||||||
|
/>
|
||||||
|
<span className="text-[13px] uppercase tracking-wide text-muted">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section
|
||||||
|
className="relative bg-cover bg-center py-20 md:py-24"
|
||||||
|
style={{ backgroundImage: "url(/images/cta-bg.jpg)" }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-extra-dark/50" />
|
||||||
|
<div className="container-plift relative z-10 text-center">
|
||||||
|
<h2 className="text-lg font-semibold uppercase text-white md:text-xl">
|
||||||
|
آیا برای همکاری با ما آماده اید؟{" "}
|
||||||
|
<Link
|
||||||
|
href="/contact-us"
|
||||||
|
className="underline decoration-white/50 underline-offset-8 transition hover:text-[#C3964E] hover:decoration-[#C3964E]"
|
||||||
|
>
|
||||||
|
یک پروژه را شروع کنید
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Blog — Meshkee API */}
|
||||||
|
<section id="blog" className="bg-[#F7F7F7] py-16 md:py-20">
|
||||||
|
<div className="container-plift">
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<span className="mb-3 block text-sm font-medium text-[#A0A0A0]">
|
||||||
|
ممکن است دوست داشته باشید
|
||||||
|
</span>
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-dark">مقالات ما</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{blogs.length === 0 ? (
|
||||||
|
<p className="mx-auto max-w-xl text-center text-sm leading-7 text-muted">
|
||||||
|
هنوز مقالهای منتشر نشده است.{" "}
|
||||||
|
<Link href="/blog" className="text-dark underline underline-offset-4">
|
||||||
|
مشاهده صفحه مقالات
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{blogs.map((blog) => (
|
||||||
|
<BlogCard key={String(blog.id)} blog={blog} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-10 text-center">
|
||||||
|
<Link href="/blog" className="btn-dark inline-flex">
|
||||||
|
مشاهده بیشتر
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { demoteCmsH1, productHref, titleSlug } from "@/lib/format";
|
||||||
|
import { getProductById } from "@/lib/meshkee";
|
||||||
|
import { site } from "@/data/site";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function productName(product: {
|
||||||
|
nameFa?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
}) {
|
||||||
|
return product.nameFa || product.title || product.name || "محصول";
|
||||||
|
}
|
||||||
|
|
||||||
|
function productImage(product: {
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
images?: Array<string | { url?: string }> | null;
|
||||||
|
}) {
|
||||||
|
if (product.thumbnailUrl) return product.thumbnailUrl;
|
||||||
|
if (product.image) return product.image;
|
||||||
|
const first = product.images?.[0];
|
||||||
|
if (!first) return null;
|
||||||
|
return typeof first === "string" ? first : first.url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const product = await getProductById(id);
|
||||||
|
const name = productName(product);
|
||||||
|
const description =
|
||||||
|
product.summary?.trim() ||
|
||||||
|
`${name} — محصولات ${site.name}`;
|
||||||
|
const image = productImage(product);
|
||||||
|
return {
|
||||||
|
title: name,
|
||||||
|
description: description.slice(0, 160),
|
||||||
|
openGraph: {
|
||||||
|
title: name,
|
||||||
|
description: description.slice(0, 160),
|
||||||
|
images: image ? [{ url: image }] : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { title: "محصول" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductDetailPage({ params }: Props) {
|
||||||
|
const { id, slug } = await params;
|
||||||
|
let product;
|
||||||
|
try {
|
||||||
|
product = await getProductById(id);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product?.id) notFound();
|
||||||
|
|
||||||
|
const name = productName(product);
|
||||||
|
const canonical = titleSlug(name);
|
||||||
|
if (slug !== canonical) {
|
||||||
|
redirect(productHref(product));
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = productImage(product);
|
||||||
|
const categoryLabel = product.categoryNameFa || product.categoryName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="bg-extra-dark py-14 text-white md:py-16">
|
||||||
|
<div className="container-plift max-w-5xl">
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="mb-6 inline-block text-sm text-white/60 transition hover:text-white"
|
||||||
|
>
|
||||||
|
بازگشت به محصولات
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold leading-relaxed md:text-3xl">{name}</h1>
|
||||||
|
{categoryLabel && (
|
||||||
|
<p className="mt-3 text-sm text-white/55">{categoryLabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<article className="py-12 md:py-16">
|
||||||
|
<div className="container-plift grid gap-10 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-5">
|
||||||
|
<div className="relative aspect-[4/5] overflow-hidden bg-[#eee]">
|
||||||
|
{image ? (
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width:1024px) 100vw, 40vw"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted">
|
||||||
|
بدون تصویر
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-7">
|
||||||
|
{product.summary && (
|
||||||
|
<p className="mb-8 text-base leading-8 text-muted text-justify">
|
||||||
|
{product.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{product.descriptionHtml ? (
|
||||||
|
<div
|
||||||
|
className="blog-content text-sm leading-8 text-[#444] text-justify [&_h2]:mb-3 [&_h2]:mt-6 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:text-dark [&_h3]:mb-2 [&_h3]:mt-5 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-dark [&_img]:my-6 [&_img]:h-auto [&_img]:max-w-full [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:ps-5 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:ps-5"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: demoteCmsH1(product.descriptionHtml),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
!product.summary && (
|
||||||
|
<p className="text-sm text-muted">توضیحات این محصول در دسترس نیست.</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { categoryHref, productHref, titleSlug } from "@/lib/format";
|
||||||
|
import { getCategoryById, listProducts } from "@/lib/meshkee";
|
||||||
|
import { site } from "@/data/site";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ id: string; slug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function categoryName(category: { nameFa?: string | null; name?: string | null }) {
|
||||||
|
return category.nameFa || category.name || "دستهبندی";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const category = await getCategoryById(id);
|
||||||
|
const name = categoryName(category);
|
||||||
|
return {
|
||||||
|
title: name,
|
||||||
|
description:
|
||||||
|
category.description?.trim() ||
|
||||||
|
`محصولات دسته ${name} در ${site.name}`.slice(0, 160),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { title: "دستهبندی محصولات" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductCategoryPage({ params }: Props) {
|
||||||
|
const { id, slug } = await params;
|
||||||
|
let category;
|
||||||
|
try {
|
||||||
|
category = await getCategoryById(id);
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category?.id) notFound();
|
||||||
|
|
||||||
|
const name = categoryName(category);
|
||||||
|
const canonical = titleSlug(name);
|
||||||
|
if (slug !== canonical) {
|
||||||
|
redirect(categoryHref(category));
|
||||||
|
}
|
||||||
|
|
||||||
|
let products: Awaited<ReturnType<typeof listProducts>>["items"] = [];
|
||||||
|
try {
|
||||||
|
const data = await listProducts(1, 24, String(category.id));
|
||||||
|
products = data.items || [];
|
||||||
|
} catch {
|
||||||
|
products = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="bg-extra-dark py-14 text-center text-white md:py-16">
|
||||||
|
<div className="container-plift">
|
||||||
|
<Link
|
||||||
|
href="/products"
|
||||||
|
className="mb-6 inline-block text-sm text-white/60 transition hover:text-white"
|
||||||
|
>
|
||||||
|
بازگشت به محصولات
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold md:text-4xl">{name}</h1>
|
||||||
|
{category.description && (
|
||||||
|
<p className="mx-auto mt-4 max-w-2xl text-sm leading-7 text-white/70">
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-[#F7F7F7] py-14 md:py-20">
|
||||||
|
<div className="container-plift">
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="text-center text-sm text-muted">
|
||||||
|
محصولی در این دسته منتشر نشده است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{products.map((product) => {
|
||||||
|
const title =
|
||||||
|
product.nameFa || product.title || product.name || "محصول";
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={String(product.id)}
|
||||||
|
className="bg-white p-6 shadow-[0_12px_40px_rgba(0,0,0,0.06)]"
|
||||||
|
>
|
||||||
|
<h2 className="mb-3 text-base font-semibold text-dark">
|
||||||
|
<Link
|
||||||
|
href={productHref(product)}
|
||||||
|
className="transition hover:text-primary"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
{product.summary && (
|
||||||
|
<p className="mb-4 line-clamp-3 text-sm leading-7 text-muted">
|
||||||
|
{product.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={productHref(product)}
|
||||||
|
className="text-sm font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
مشاهده محصول
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { productsPage } from "@/data/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "محصولات",
|
||||||
|
description:
|
||||||
|
"انواع آسانسور کششی گیرلس و گیربکسی، هیدرولیک و پلهبرقی — معرفی محصولات آسانسور پارس.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductsPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
className="page-banner"
|
||||||
|
style={{ backgroundImage: `url(${productsPage.banner})` }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-sm text-white/80">{productsPage.subtitle}</p>
|
||||||
|
<h1 className="text-3xl font-bold md:text-5xl">{productsPage.title}</h1>
|
||||||
|
<div className="mx-auto mt-6 h-[3px] w-20 bg-white" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{productsPage.sections.map((section) => (
|
||||||
|
<ProductSection key={section.id} section={section} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductSection({
|
||||||
|
section,
|
||||||
|
}: {
|
||||||
|
section: (typeof productsPage.sections)[number];
|
||||||
|
}) {
|
||||||
|
const media = (
|
||||||
|
<div className="relative min-h-[320px] w-full lg:min-h-[440px]">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url(/images/products/plus-pattern.jpg)",
|
||||||
|
backgroundRepeat: "repeat",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 z-10 w-[90%] max-w-xl -translate-y-1/2 shadow-[0_22px_55px_rgba(0,0,0,0.14)] ${
|
||||||
|
section.flipped ? "end-0" : "start-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={section.image}
|
||||||
|
alt={section.title}
|
||||||
|
width={720}
|
||||||
|
height={400}
|
||||||
|
className="h-auto w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const copy = (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<span className="h-px w-8 shrink-0 bg-[#d0d0d0]" />
|
||||||
|
<span className="text-[15px] font-medium text-[#A0A0A0]">
|
||||||
|
{section.eyebrow}
|
||||||
|
</span>
|
||||||
|
<span className="h-px flex-1 bg-[#d0d0d0]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-[1.85rem] font-bold leading-snug text-dark md:text-[2.2rem]">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
{section.titleEn ? (
|
||||||
|
<p className="mt-1 mb-6 text-[1.85rem] font-bold uppercase text-dark md:text-[2.2rem]">
|
||||||
|
{section.titleEn}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mb-6" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mb-8 text-[14px] font-light leading-[28px] text-[#5C6873] text-justify">
|
||||||
|
{section.text}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/contact-us"
|
||||||
|
className="text-[14px] font-medium text-[#C3964E] underline decoration-[#C3964E]/35 underline-offset-8 transition hover:text-dark"
|
||||||
|
>
|
||||||
|
ادامه مطلب
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={section.id}
|
||||||
|
className="overflow-visible bg-cover bg-center py-16 md:py-24"
|
||||||
|
style={{ backgroundImage: "url(/images/products/section-bg.jpg)" }}
|
||||||
|
>
|
||||||
|
<div className="container-plift">
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center gap-12 lg:flex-row lg:gap-14 ${
|
||||||
|
section.flipped ? "lg:flex-row-reverse" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* In RTL: first item sits on the right. Non-flipped: text right, media left */}
|
||||||
|
<div className="w-full lg:w-[42%]">{copy}</div>
|
||||||
|
<div className="w-full lg:w-[58%]">{media}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { getMe, getTenant, refreshAuth } from "@/lib/meshkee";
|
||||||
|
import { BUSINESS_URL, CUSTOMER_URL, TOKEN_KEYS } from "@/lib/config";
|
||||||
|
import { displayName, isWebsiteAdmin } from "@/lib/format";
|
||||||
|
import type { MeshkeeUser } from "@/lib/types";
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: MeshkeeUser | null;
|
||||||
|
loading: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
label: string;
|
||||||
|
customerUrl: string;
|
||||||
|
businessUrl: string;
|
||||||
|
profileUrl: string;
|
||||||
|
loginUrl: string;
|
||||||
|
logout: () => void;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
function readToken(key: string) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTokens(access?: string | null, refresh?: string | null) {
|
||||||
|
try {
|
||||||
|
if (access) localStorage.setItem(TOKEN_KEYS.access, access);
|
||||||
|
else localStorage.removeItem(TOKEN_KEYS.access);
|
||||||
|
if (refresh) localStorage.setItem(TOKEN_KEYS.refresh, refresh);
|
||||||
|
else localStorage.removeItem(TOKEN_KEYS.refresh);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureTokensFromUrl() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const hash = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||||
|
const access =
|
||||||
|
url.searchParams.get("accessToken") ||
|
||||||
|
url.searchParams.get("token") ||
|
||||||
|
hash.get("accessToken") ||
|
||||||
|
hash.get("token");
|
||||||
|
const refresh =
|
||||||
|
url.searchParams.get("refreshToken") || hash.get("refreshToken");
|
||||||
|
|
||||||
|
if (!access && !refresh) return;
|
||||||
|
|
||||||
|
writeTokens(access, refresh);
|
||||||
|
url.searchParams.delete("accessToken");
|
||||||
|
url.searchParams.delete("refreshToken");
|
||||||
|
url.searchParams.delete("token");
|
||||||
|
url.hash = "";
|
||||||
|
window.history.replaceState({}, "", `${url.pathname}${url.search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<MeshkeeUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [businessId, setBusinessId] = useState<string | number | null>(null);
|
||||||
|
|
||||||
|
const loginUrl = useMemo(() => {
|
||||||
|
const returnTo =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.location.origin
|
||||||
|
: "https://www.plift.ir";
|
||||||
|
return `${CUSTOMER_URL}?redirect=${encodeURIComponent(returnTo)}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
captureTokensFromUrl();
|
||||||
|
const access = readToken(TOKEN_KEYS.access);
|
||||||
|
const refreshToken = readToken(TOKEN_KEYS.refresh);
|
||||||
|
|
||||||
|
if (!access && !refreshToken) {
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let token = access;
|
||||||
|
if (!token && refreshToken) {
|
||||||
|
const refreshed = await refreshAuth(refreshToken);
|
||||||
|
writeTokens(refreshed.accessToken, refreshed.refreshToken);
|
||||||
|
token = refreshed.accessToken;
|
||||||
|
if (refreshed.user) {
|
||||||
|
setUser(refreshed.user);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
setUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = await getMe(token);
|
||||||
|
setUser(me);
|
||||||
|
} catch {
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAuth(refreshToken);
|
||||||
|
writeTokens(refreshed.accessToken, refreshed.refreshToken);
|
||||||
|
const me = refreshed.user || (await getMe(refreshed.accessToken));
|
||||||
|
setUser(me);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
writeTokens(null, null);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writeTokens(null, null);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const tenant = await getTenant();
|
||||||
|
if (!cancelled) setBusinessId(tenant.id);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (!cancelled) await refresh();
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
writeTokens(null, null);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
isAdmin: user ? isWebsiteAdmin(user, businessId) : false,
|
||||||
|
label: user ? `${displayName(user)} عزیز` : "",
|
||||||
|
customerUrl: CUSTOMER_URL,
|
||||||
|
businessUrl: BUSINESS_URL,
|
||||||
|
profileUrl: `${CUSTOMER_URL}/profile`,
|
||||||
|
loginUrl,
|
||||||
|
logout,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { blogHref, formatFaDate } from "@/lib/format";
|
||||||
|
import type { Blog } from "@/lib/types";
|
||||||
|
|
||||||
|
export function BlogCard({ blog }: { blog: Blog }) {
|
||||||
|
const href = blogHref(blog);
|
||||||
|
const date = formatFaDate(blog.publishedAt || blog.createdAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex h-full flex-col overflow-hidden bg-white shadow-[0_12px_40px_rgba(0,0,0,0.06)]">
|
||||||
|
<Link href={href} className="relative block aspect-[16/10] bg-[#eee]">
|
||||||
|
{blog.titleImageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={blog.titleImageUrl}
|
||||||
|
alt={blog.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width:768px) 100vw, 33vw"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted">
|
||||||
|
بدون تصویر
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-1 flex-col p-6">
|
||||||
|
{date && (
|
||||||
|
<time className="mb-2 block text-xs text-[#A0A0A0]">{date}</time>
|
||||||
|
)}
|
||||||
|
<h3 className="mb-3 text-base font-semibold leading-7 text-dark">
|
||||||
|
<Link href={href} className="transition hover:text-primary">
|
||||||
|
{blog.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
{blog.abstract && (
|
||||||
|
<p className="mb-5 line-clamp-3 flex-1 text-sm leading-7 text-muted text-justify">
|
||||||
|
{blog.abstract}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="text-sm font-medium text-dark underline underline-offset-4 transition hover:text-primary"
|
||||||
|
>
|
||||||
|
بیشتر بخوانید
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useToast } from "@/components/ToastProvider";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { submitContact } from "@/lib/meshkee";
|
||||||
|
import { contactPage, site } from "@/data/site";
|
||||||
|
|
||||||
|
export function ContactForm() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setPending(true);
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const data = new FormData(form);
|
||||||
|
const name = String(data.get("name") || "").trim();
|
||||||
|
const email = String(data.get("email") || "").trim();
|
||||||
|
const phone = String(data.get("phone") || "").trim();
|
||||||
|
const message = String(data.get("message") || "").trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitContact({
|
||||||
|
title: `پیام تماس از ${name}`,
|
||||||
|
name,
|
||||||
|
email: email || undefined,
|
||||||
|
cellNumber: phone || undefined,
|
||||||
|
text: message,
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
setSent(true);
|
||||||
|
toast("پیام شما با موفقیت ارسال شد.", "success");
|
||||||
|
} catch (err) {
|
||||||
|
const msg =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: "ارسال پیام ناموفق بود. دوباره تلاش کنید.";
|
||||||
|
setError(msg);
|
||||||
|
toast(msg, "error");
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="grid gap-5 text-start md:grid-cols-2">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
name="name"
|
||||||
|
placeholder="* نام"
|
||||||
|
className="w-full border border-[#ddd] bg-transparent px-4 py-3 text-sm outline-none transition focus:border-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="* ایمیل"
|
||||||
|
className="w-full border border-[#ddd] bg-transparent px-4 py-3 text-sm outline-none transition focus:border-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
name="phone"
|
||||||
|
placeholder="شماره همراه *"
|
||||||
|
className="w-full border border-[#ddd] bg-transparent px-4 py-3 text-sm outline-none transition focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
name="message"
|
||||||
|
placeholder="متن پیام"
|
||||||
|
rows={8}
|
||||||
|
className="w-full resize-none border border-[#ddd] bg-transparent px-4 py-3 text-sm outline-none transition focus:border-primary"
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<p className="max-w-md text-xs leading-6 text-muted">{contactPage.privacy}</p>
|
||||||
|
<button type="submit" className="btn-dark min-w-[140px]" disabled={pending}>
|
||||||
|
{pending ? "در حال ارسال..." : "ارسال پیام"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sent && (
|
||||||
|
<p className="md:col-span-2 text-sm text-primary">
|
||||||
|
پیام شما ثبت شد. بهزودی با شما تماس میگیریم.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="md:col-span-2 text-sm text-[#9A0000]">{error}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactInfoCards() {
|
||||||
|
return (
|
||||||
|
<div className="container-plift grid gap-5 md:grid-cols-3">
|
||||||
|
<InfoCard
|
||||||
|
title="تلفن همراه"
|
||||||
|
icon={<PhoneIcon />}
|
||||||
|
body={
|
||||||
|
<div className="space-y-1 text-sm leading-7 text-muted">
|
||||||
|
{site.mobiles.map((m) => (
|
||||||
|
<a
|
||||||
|
key={m.phoneRaw}
|
||||||
|
href={`tel:${m.phoneRaw}`}
|
||||||
|
className="block transition hover:text-primary"
|
||||||
|
>
|
||||||
|
{m.name.replace("آقای", "مهندس")} :{m.phone}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title="تلفن پشتیبانی"
|
||||||
|
icon={<ChatIcon />}
|
||||||
|
body={
|
||||||
|
<a
|
||||||
|
href={`tel:${site.phoneRaw}`}
|
||||||
|
className="text-sm text-muted transition hover:text-primary"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
{site.phoneDisplay}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoCard
|
||||||
|
title="آدرس ما"
|
||||||
|
icon={<PinIcon />}
|
||||||
|
body={<p className="text-sm leading-7 text-muted">{site.address}</p>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
body: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-8 text-center shadow-[0_18px_50px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mx-auto mb-4 inline-flex text-primary">{icon}</div>
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-dark">{title}</h3>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<rect x="7" y="2" width="10" height="20" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<circle cx="12" cy="18" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PinIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M12 21s7-6.2 7-12a7 7 0 1 0-14 0c0 5.8 7 12 7 12z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="9" r="2.5" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Counter({ value, className = "" }: Props) {
|
||||||
|
const [n, setN] = useState(0);
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || started.current) return;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
if (started.current) return;
|
||||||
|
started.current = true;
|
||||||
|
const duration = 1400;
|
||||||
|
const start = performance.now();
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start) / duration);
|
||||||
|
const eased = 1 - Math.pow(1 - t, 3);
|
||||||
|
setN(Math.round(value * eased));
|
||||||
|
if (t < 1) requestAnimationFrame(tick);
|
||||||
|
else setN(value);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isVisible = () => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const vh = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
return rect.top < vh * 0.92 && rect.bottom > vh * 0.08;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isVisible()) {
|
||||||
|
run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
run();
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.2 },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className={className} dir="ltr">
|
||||||
|
{n.toLocaleString("en-US")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { featuresSection } from "@/data/site";
|
||||||
|
|
||||||
|
export function FeaturesCarousel() {
|
||||||
|
const items = featuresSection.items;
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [perView, setPerView] = useState(3);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
if (w < 768) setPerView(1);
|
||||||
|
else if (w < 992) setPerView(2);
|
||||||
|
else setPerView(3);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
window.addEventListener("resize", update);
|
||||||
|
return () => window.removeEventListener("resize", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (items.length <= perView) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setIndex((i) => (i + 1) % items.length);
|
||||||
|
}, 3000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [items.length, perView]);
|
||||||
|
|
||||||
|
const visible =
|
||||||
|
perView >= items.length
|
||||||
|
? items
|
||||||
|
: Array.from({ length: perView }, (_, i) => items[(index + i) % items.length]);
|
||||||
|
|
||||||
|
const showNav = items.length > perView;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden bg-[#F6F6F6] pt-20 pb-24 md:pt-[6.5rem] md:pb-28">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-4 hidden select-none overflow-hidden text-center text-[110px] font-semibold leading-none tracking-[-8px] text-white xl:block 2xl:text-[154px]"
|
||||||
|
>
|
||||||
|
آسانسور پارس
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container-plift relative z-10">
|
||||||
|
<div className="mx-auto mb-12 max-w-2xl text-center lg:mb-14">
|
||||||
|
<span className="mb-5 block text-[17px] font-medium text-[#A0A0A0]">
|
||||||
|
{featuresSection.eyebrow}
|
||||||
|
</span>
|
||||||
|
<h2 className="text-[1.7rem] font-bold uppercase leading-snug text-dark md:text-[2rem]">
|
||||||
|
{featuresSection.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid items-start gap-6 lg:grid-cols-12 lg:gap-5">
|
||||||
|
{/* side intro — ~1/4 */}
|
||||||
|
<div className="pt-2 lg:col-span-3">
|
||||||
|
<span className="mb-5 block text-[17px] font-medium text-dark">
|
||||||
|
{featuresSection.sideLabel}
|
||||||
|
</span>
|
||||||
|
<h3 className="mb-8 text-[1.35rem] font-bold leading-9 text-dark md:text-[1.55rem] md:leading-10">
|
||||||
|
{featuresSection.sideTitle}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{showNav && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="بعدی"
|
||||||
|
onClick={() => setIndex((i) => (i + 1) % items.length)}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center border border-[#ddd] text-lg text-dark transition hover:border-dark"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="قبلی"
|
||||||
|
onClick={() =>
|
||||||
|
setIndex((i) => (i - 1 + items.length) % items.length)
|
||||||
|
}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center border border-[#ddd] text-lg text-dark transition hover:border-dark"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* cards — show 3 on desktop like original */}
|
||||||
|
<div className="lg:col-span-9">
|
||||||
|
<div
|
||||||
|
className={`grid gap-5 ${
|
||||||
|
perView === 1
|
||||||
|
? "grid-cols-1"
|
||||||
|
: perView === 2
|
||||||
|
? "grid-cols-2"
|
||||||
|
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{visible.map((item, i) => (
|
||||||
|
<article
|
||||||
|
key={`${item.number}-${perView >= items.length ? item.number : `${index}-${i}`}`}
|
||||||
|
className="border border-black/[0.08] bg-white"
|
||||||
|
>
|
||||||
|
<div className="px-7 py-10 md:px-8 md:py-12 xl:px-9">
|
||||||
|
<div className="mb-5 text-[2.5rem] font-medium leading-none tracking-[-2px] text-[#A0A0A0]">
|
||||||
|
{item.number}
|
||||||
|
</div>
|
||||||
|
<h4 className="mb-3 text-[16px] font-semibold uppercase leading-7 text-dark">
|
||||||
|
{item.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[14px] font-light leading-[26px] text-[#5C6873] text-justify">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { footerLinks, site } from "@/data/site";
|
||||||
|
import { MeshkeeCredit } from "@/components/MeshkeeCredit";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-extra-dark text-white">
|
||||||
|
<div className="container-plift grid gap-10 py-16 md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
|
||||||
|
<div>
|
||||||
|
<span className="mb-5 block text-[17px] font-medium">لینک مفید</span>
|
||||||
|
<ul className="space-y-3 text-[15px] text-white/75">
|
||||||
|
{footerLinks.useful.map((item) => (
|
||||||
|
<li key={item.label}>
|
||||||
|
<Link href={item.href} className="transition hover:text-white">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-5 block text-[17px] font-medium">لینک صفحات</span>
|
||||||
|
<ul className="space-y-3 text-[15px] text-white/75">
|
||||||
|
{footerLinks.pages.map((item) => (
|
||||||
|
<li key={item.label}>
|
||||||
|
<Link href={item.href} className="transition hover:text-white">
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-5 block text-[17px] font-medium">با ما در تماس باشید</span>
|
||||||
|
<p className="mb-4 text-[15px] leading-8 text-white/75">{site.address}</p>
|
||||||
|
<div className="space-y-2 text-[15px] text-white/85">
|
||||||
|
{site.mobiles.map((m) => (
|
||||||
|
<a
|
||||||
|
key={m.phoneRaw}
|
||||||
|
href={`tel:${m.phoneRaw}`}
|
||||||
|
className="flex items-center gap-2 transition hover:text-white"
|
||||||
|
>
|
||||||
|
<PhoneIcon />
|
||||||
|
<span>
|
||||||
|
{m.name} {m.phone}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<a
|
||||||
|
href={`mailto:${site.email}`}
|
||||||
|
className="flex items-center gap-2 transition hover:text-white"
|
||||||
|
>
|
||||||
|
<MailIcon />
|
||||||
|
<span dir="ltr">{site.phoneDisplay}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-white">{site.name}</h2>
|
||||||
|
<p className="mb-5 text-[14px] leading-7 text-white/70 text-justify">
|
||||||
|
{site.description}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-white/80 transition hover:text-white"
|
||||||
|
>
|
||||||
|
<InstagramIcon />
|
||||||
|
اینستاگرام ما
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-white/10 py-8">
|
||||||
|
<div className="container-plift flex flex-col items-center justify-between gap-6 md:flex-row">
|
||||||
|
<Link href="/">
|
||||||
|
<Image
|
||||||
|
src={site.logoFooter}
|
||||||
|
alt={site.name}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="h-14 w-14 object-contain brightness-0 invert"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<p className="max-w-xl text-center text-sm leading-7 text-white/55 md:text-start">
|
||||||
|
{site.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-white/10 py-5">
|
||||||
|
<div className="container-plift">
|
||||||
|
<div className="footer-bottom-bar">
|
||||||
|
<div className="footer-bottom-site">{site.name}</div>
|
||||||
|
<MeshkeeCredit />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MailIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M22 6l-10 7L2 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstagramIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="3"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
rx="5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { UserMenu } from "@/components/UserMenu";
|
||||||
|
import { navItems, site } from "@/data/site";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > 12);
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = headerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--header-h",
|
||||||
|
`${el.offsetHeight}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
sync();
|
||||||
|
const ro = new ResizeObserver(sync);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
ref={headerRef}
|
||||||
|
className={`sticky top-0 z-50 bg-white transition-shadow ${
|
||||||
|
scrolled ? "shadow-[0_8px_30px_rgba(0,0,0,0.06)]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="container-plift flex items-center justify-between gap-4 py-2.5 lg:py-3">
|
||||||
|
<Link href="/" className="relative z-10 shrink-0" aria-label={site.name}>
|
||||||
|
<Image
|
||||||
|
src={site.logo}
|
||||||
|
alt={site.name}
|
||||||
|
width={72}
|
||||||
|
height={72}
|
||||||
|
className="h-11 w-11 object-contain md:h-12 md:w-12"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden items-center gap-0.5 lg:flex">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const active =
|
||||||
|
item.href === "/"
|
||||||
|
? pathname === "/"
|
||||||
|
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className={`px-2.5 py-1.5 text-[13px] transition-colors ${
|
||||||
|
active
|
||||||
|
? "font-medium text-dark"
|
||||||
|
: "text-[#373737] hover:text-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center border border-[#e4e4e4] lg:hidden"
|
||||||
|
aria-label="منو"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="sr-only">منو</span>
|
||||||
|
<div className="flex w-5 flex-col gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`h-0.5 bg-dark transition ${open ? "translate-y-2 rotate-45" : ""}`}
|
||||||
|
/>
|
||||||
|
<span className={`h-0.5 bg-dark transition ${open ? "opacity-0" : ""}`} />
|
||||||
|
<span
|
||||||
|
className={`h-0.5 bg-dark transition ${open ? "-translate-y-2 -rotate-45" : ""}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-[#eee] bg-white lg:hidden">
|
||||||
|
<nav className="container-plift flex flex-col py-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className="border-b border-[#f2f2f2] py-2.5 text-[13px] text-dark"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="py-2.5">
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { heroSlides } from "@/data/site";
|
||||||
|
|
||||||
|
export function HeroSlider() {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [animKey, setAnimKey] = useState(0);
|
||||||
|
|
||||||
|
const goTo = useCallback((next: number) => {
|
||||||
|
setIndex(next);
|
||||||
|
setAnimKey((k) => k + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
goTo((index - 1 + heroSlides.length) % heroSlides.length);
|
||||||
|
}, [goTo, index]);
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
goTo((index + 1) % heroSlides.length);
|
||||||
|
}, [goTo, index]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setIndex((i) => {
|
||||||
|
const next = (i + 1) % heroSlides.length;
|
||||||
|
setAnimKey((k) => k + 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 5500);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
goNext();
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
goPrev();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [goNext, goPrev]);
|
||||||
|
|
||||||
|
const slide = heroSlides[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative h-[calc(100svh-var(--header-h,5.5rem))] overflow-hidden bg-extra-dark">
|
||||||
|
{heroSlides.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={item.image}
|
||||||
|
className={`pointer-events-none absolute inset-0 bg-cover bg-center transition-opacity duration-700 ${
|
||||||
|
i === index ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundImage: `url(${item.image})` }}
|
||||||
|
aria-hidden={i !== index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 right-0 z-20 w-full max-w-[600px] xl:max-w-[560px] sm:w-[70%]">
|
||||||
|
<div className="bg-white px-8 py-14 sm:px-12 sm:py-16 xl:px-[4.5rem] xl:py-[5rem]">
|
||||||
|
<div key={animKey} className="flex items-center gap-6 md:gap-8">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="hero-anim hero-anim-1 mb-4 text-[15px] font-medium text-[#A0A0A0] sm:text-[17px]">
|
||||||
|
<span className="me-2">{slide.number}</span>
|
||||||
|
<span className="mx-1">——</span>
|
||||||
|
<span>{slide.eyebrow}</span>
|
||||||
|
</p>
|
||||||
|
<h1 className="hero-anim hero-anim-2 text-[1.85rem] font-bold leading-tight text-dark sm:text-[2.1rem] md:text-[2.35rem]">
|
||||||
|
{slide.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/about-us"
|
||||||
|
className="hero-anim hero-anim-3 inline-flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-full bg-dark text-white transition hover:bg-primary"
|
||||||
|
aria-label="بیشتر بدانید"
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-10 left-1/2 z-20 hidden -translate-x-1/2 items-center gap-2.5 md:flex">
|
||||||
|
{heroSlides.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
aria-label={`اسلاید ${i + 1}`}
|
||||||
|
onClick={() => goTo(i)}
|
||||||
|
className={`h-2 w-2 rounded-full transition ${
|
||||||
|
i === index ? "bg-white" : "bg-white/45 hover:bg-white/75"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left = next (RTL), Right = previous — icons point outward */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="اسلاید بعدی"
|
||||||
|
className="absolute top-1/2 left-4 z-30 hidden h-12 w-12 -translate-y-1/2 items-center justify-center text-white transition hover:opacity-70 md:inline-flex"
|
||||||
|
onClick={goNext}
|
||||||
|
>
|
||||||
|
<Arrow dir="left" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="اسلاید قبلی"
|
||||||
|
className="absolute top-1/2 right-4 z-30 hidden h-12 w-12 -translate-y-1/2 items-center justify-center text-white transition hover:opacity-70 md:inline-flex"
|
||||||
|
onClick={goPrev}
|
||||||
|
>
|
||||||
|
<Arrow dir="right" />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Arrow({ dir }: { dir: "left" | "right" }) {
|
||||||
|
return (
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d={dir === "left" ? "M15 5l-7 7 7 7" : "M9 5l7 7-7 7"}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronLeft() {
|
||||||
|
return (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M15 18l-6-6 6-6"
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export function MeshkeeCredit() {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://meshkee.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
dir="ltr"
|
||||||
|
className="meshkee-credit"
|
||||||
|
>
|
||||||
|
<span className="meshkee-credit__frame" aria-hidden="true">
|
||||||
|
<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"
|
||||||
|
width={42}
|
||||||
|
height={42}
|
||||||
|
className="meshkee-credit__logo"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { products, productsBg } from "@/data/site";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches plift.ir fancy-box-background:
|
||||||
|
* items are position:static so hover images expand over the full section.
|
||||||
|
*/
|
||||||
|
export function ProductsFancy() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="products"
|
||||||
|
className="products-fancy relative overflow-hidden bg-extra-dark bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: `url(${productsBg})` }}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-0 bg-[#232323]/30" />
|
||||||
|
|
||||||
|
<div className="relative z-[1] grid sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{products.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.title}
|
||||||
|
className="products-fancy-item group static min-h-[250px] sm:min-h-[500px] lg:min-h-[800px]"
|
||||||
|
>
|
||||||
|
{/* covers ALL 4 columns — absolute to section because item is static */}
|
||||||
|
<div
|
||||||
|
className="products-fancy-bg pointer-events-none absolute inset-0 z-[1] bg-cover bg-center opacity-0 transition-opacity duration-500 ease-in-out group-hover:opacity-100"
|
||||||
|
style={{ backgroundImage: `url(${item.image})` }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-[2] flex h-full min-h-[inherit] flex-col justify-end px-8 py-10 xl:px-10 xl:py-14">
|
||||||
|
{/* column overlay panel — only on hovered card */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 top-[35%] bg-[#4B4B4B]/0 transition-colors duration-500 group-hover:bg-[#4B4B4B]/75"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative translate-y-[70px] transition-transform duration-500 ease-in-out group-hover:translate-y-0">
|
||||||
|
<h3 className="mb-5 w-[80%] text-xl font-semibold uppercase leading-8 text-white xl:text-[20px]">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex translate-y-4 items-end gap-4 opacity-0 transition-all duration-500 ease-in-out group-hover:translate-y-0 group-hover:opacity-100">
|
||||||
|
<p className="m-0 w-[75%] text-[14px] font-light leading-[26px] text-white/60 text-justify">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="ms-auto inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-extra-dark text-white transition hover:bg-primary"
|
||||||
|
aria-label={item.title}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrowLeft() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M19 12H5M5 12l6 6M5 12l6-6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type ToastTone = "success" | "error" | "info";
|
||||||
|
|
||||||
|
type ToastItem = {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
tone: ToastTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToastContextValue = {
|
||||||
|
toast: (message: string, tone?: ToastTone) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const toast = useCallback((message: string, tone: ToastTone = "info") => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setItems((prev) => [...prev, { id, message, tone }]);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
}, 4200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ toast }), [toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div className="pointer-events-none fixed bottom-6 left-1/2 z-[100] flex w-[min(92vw,420px)] -translate-x-1/2 flex-col gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`pointer-events-auto rounded-md px-4 py-3 text-sm shadow-lg ${
|
||||||
|
item.tone === "success"
|
||||||
|
? "bg-dark text-white"
|
||||||
|
: item.tone === "error"
|
||||||
|
? "bg-[#9A0000] text-white"
|
||||||
|
: "bg-white text-dark ring-1 ring-black/10"
|
||||||
|
}`}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{item.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const { user, loading, label, isAdmin, loginUrl, profileUrl, businessUrl, logout } =
|
||||||
|
useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<span className="hidden text-[13px] text-[#999] sm:inline-flex">...</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={loginUrl}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[13px] text-[#373737] transition hover:text-primary"
|
||||||
|
>
|
||||||
|
<LoginIcon />
|
||||||
|
ورود
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex max-w-[180px] items-center gap-1.5 text-[13px] text-[#373737] transition hover:text-primary"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<UserIcon />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full left-0 z-50 mt-2 min-w-[180px] border border-[#eee] bg-white py-1 shadow-[0_12px_40px_rgba(0,0,0,0.12)]">
|
||||||
|
<a
|
||||||
|
href={profileUrl}
|
||||||
|
className="block px-4 py-2.5 text-[13px] text-dark transition hover:bg-[#f7f7f7]"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
پروفایل من
|
||||||
|
</a>
|
||||||
|
{isAdmin && (
|
||||||
|
<a
|
||||||
|
href={businessUrl}
|
||||||
|
className="block px-4 py-2.5 text-[13px] text-dark transition hover:bg-[#f7f7f7]"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
مدیریت وبسایت
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-4 py-2.5 text-start text-[13px] text-dark transition hover:bg-[#f7f7f7]"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<path
|
||||||
|
d="M10 17l5-5-5-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M15 12H3"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 19V5a2 2 0 0 0-2-2h-4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||||
|
<circle cx="12" cy="8" r="3.5" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M5 19.5c1.8-3.2 4.2-4.5 7-4.5s5.2 1.3 7 4.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
export const site = {
|
||||||
|
name: "آسانسور پارس",
|
||||||
|
nameEn: "Pars Elevator",
|
||||||
|
tagline: "شرکت آسانسور پارس توانمند در اجرای انواع آسانسور و پله برقی",
|
||||||
|
description:
|
||||||
|
"شرکت فنی مهندسی پــــارس برایند یک دهه تجربه و دانش مهندسی در زمینه آسانسور و پله برقی ،راه حلی مناسب برای انتخاب بهترین و ایمن ترین نوع آسانسور و پله برقی برای ساختمان شما خواهد بود.",
|
||||||
|
address: "قم ، بلوارصدوقی ، نبش کوچه ۵۷ ، پلاک ۳",
|
||||||
|
phone: "۳۲۹۴۴۴۴۵ - ۰۲۵",
|
||||||
|
phoneDisplay: "32944445 - 025",
|
||||||
|
phoneRaw: "02532944445",
|
||||||
|
mobiles: [
|
||||||
|
{ name: "آقای هدایتی", phone: "۰۹۱۰۲۰۴۱۶۱۲", phoneRaw: "09102041612" },
|
||||||
|
{ name: "آقای بلاغی", phone: "۰۹۱۲۷۴۹۸۳۵۷", phoneRaw: "09127498357" },
|
||||||
|
],
|
||||||
|
email: "info@wilaart.com",
|
||||||
|
social: {
|
||||||
|
telegram: "https://t.me/plift",
|
||||||
|
eitaa: "https://eitaa.com/parslift",
|
||||||
|
instagram: "/",
|
||||||
|
},
|
||||||
|
loginUrl: "https://customer.plift.ir",
|
||||||
|
customerUrl: "https://customer.plift.ir",
|
||||||
|
businessUrl: "https://business.plift.ir",
|
||||||
|
logo: "/images/logo.png",
|
||||||
|
logoFooter: "/images/logo-footer.png",
|
||||||
|
favicon: "/favicon.png",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const navItems = [
|
||||||
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
|
{ label: "محصولات", href: "/products" },
|
||||||
|
{ label: "انتخاب کابین", href: "/products" },
|
||||||
|
{ label: "کنترل کیفیت", href: "/about-us" },
|
||||||
|
{ label: "درباره ی ما", href: "/about-us" },
|
||||||
|
{ label: "مقالات", href: "/blog" },
|
||||||
|
{ label: "تماس با ما", href: "/contact-us" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const heroSlides = [
|
||||||
|
{
|
||||||
|
image: "/images/hero/slide-1.jpg",
|
||||||
|
number: "۰۱",
|
||||||
|
eyebrow: "توانمند در اجرای انواع آسانسور",
|
||||||
|
title: "آسانسور پارس",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/images/hero/slide-2.jpg",
|
||||||
|
number: "۰۲",
|
||||||
|
eyebrow: "دارای خدمات پس از فروش",
|
||||||
|
title: "آسانسور پارس",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const introSection = {
|
||||||
|
title: "آسانسور پارس",
|
||||||
|
subtitle: "همگام با تکنولوژی رو دنیا",
|
||||||
|
text: "با پیشرفت علم و تکنولوژی نیاز به نرم افزار های دقیق و هوشمند بیش از پیش احساس می گردد، از این رو طراحی سه بعدی و محاسبات تنش و کرنش با نرم افزار قدرتمند Catia و Abaqus برای هر پروژه انجام خواهد شد و همچنین در هر مرحله روند اجرایی پروژه، نقشه های فونداسیون کف و اسکلت و ریل گذاری و کابین و موتورخانه ارائه خواهد شد که تمامی این مستندات در اختیار مشتری قرار خواهد گرفت .",
|
||||||
|
image: "/images/about/about-side.jpg",
|
||||||
|
cta: "بیشتر بدانید",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const featuresSection = {
|
||||||
|
eyebrow: "آسانسور پارس",
|
||||||
|
title: "برخی از ویژگی های آسانسور پارس",
|
||||||
|
sideLabel: "آسانسور پارس",
|
||||||
|
sideTitle: "شرکت آسانسور پارس توانمند در اجرای انواع آسانسور و پله برقی",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
title: "اجرای انواع آسانسور و پله برقی",
|
||||||
|
description:
|
||||||
|
"طراحی و اجرای آسانسور و پلهبرقی، نیازمند رعایت دقیق استانداردهای ایمنی بینالمللی و محاسبات مهندسی برای تناسب ظرفیت با کاربری ساختمان است. بهرهگیری از سیستمهای کنترل مدرن (مانند درایوهای VVVF) و متریال مقاوم، ضمن افزایش کارایی و کاهش مصرف انرژی، طول عمر سیستم را تضمین میکند.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
title: "همگام با تکنولوژی روز دنیا",
|
||||||
|
description:
|
||||||
|
"ما با بهرهگیری از جدیدترین دستاوردهای مهندسی و هوشمندسازی در صنعت آسانسور و پلهبرقی، استانداردهای جهانی را در پروژههای خود پیادهسازی میکنیم. تمرکز ما بر استفاده از فناوریهای نوین کنترل و درایو، تضمینکننده سرعت، ایمنی و بهینهترین مصرف انرژی در سطح بازارهای پیشرو دنیاست.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
title: "ارائه خدمات نصب و راه اندازی",
|
||||||
|
description:
|
||||||
|
"تمامی مراحل نصب و راهاندازی آسانسور و پله برقی را با تکیه بر تیمهای فنی متخصص و طبق چکلیستهای دقیق مهندسی اجرا میکنیم. از جانمایی دقیق قطعات و مکانیکال گرفته تا تنظیمات پیشرفته سیستمهای کنترل، کیفیت و ایمنی را در بالاترین سطح تضمین مینماییم.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const engineeringSection = {
|
||||||
|
eyebrow: "آسانسور پارس",
|
||||||
|
title: "شرکت فنی مهندسی پــــارس",
|
||||||
|
text: "برایند یک دهه تجربه و دانش مهندسی در زمینه آسانسور و پله برقی ، راه حلی مناسب برای انتخاب بهترین و ایمن ترین نوع آسانسور و پله برقی برای ساختمان شما خواهد بود.",
|
||||||
|
bg: "/images/services-bg.jpg",
|
||||||
|
imageBack: "/images/about/about-alt.jpg",
|
||||||
|
imageFront: "/images/about/about-thumb.jpg",
|
||||||
|
cta: "بیشتر بدانید",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const products = [
|
||||||
|
{
|
||||||
|
title: "پله برقی و شیب رو برقی",
|
||||||
|
description:
|
||||||
|
"پله برقی وسیله جابجایی است که برای جابجایی افراد استفاده میشود، از پله های مستقل و به هم پیوسته تشکیل شده که حالت افقی دارد و برای بالا و پایین رفتن افراد به کار میرود.",
|
||||||
|
image: "/images/products/escalator.jpg",
|
||||||
|
href: "/products#escalator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "آسانسور هیدرولیک",
|
||||||
|
description:
|
||||||
|
"آسانسورهایی که با نیروی فشار روغن کار می کنند به آسانسورهای هیدرولیک معروف اند.",
|
||||||
|
image: "/images/products/hydraulic.jpg",
|
||||||
|
href: "/products#hydraulic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "آسانسور کششی گیرلسی",
|
||||||
|
description:
|
||||||
|
"در اینگونه از آسانسورها از گیربکس استفاده نمیشود و سرعت آنها میتواند تا ۷ متر بر ثانیه نیز برسد.",
|
||||||
|
image: "/images/products/gearless.jpg",
|
||||||
|
href: "/products#gearless",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "آسانسور کششی گیبرکسی",
|
||||||
|
description:
|
||||||
|
"اینگونه از آسانسورها دارای گیربکسی هستند که به موتور متصل است و چرخی را که سیم بکسل را به حرکت در آورد را میراند.",
|
||||||
|
image: "/images/products/geared.jpg",
|
||||||
|
href: "/products#geared",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const productsBg = "/images/products-bg.jpg";
|
||||||
|
|
||||||
|
export const counters = [
|
||||||
|
{ value: 43, label: "آسانسور" },
|
||||||
|
{ value: 12, label: "عدد پله برقی" },
|
||||||
|
{ value: 32, label: "بالابر" },
|
||||||
|
{ value: 12, label: "عدد آسانسور حمل بار" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const aboutPage = {
|
||||||
|
banner: "/images/about/banner.jpg",
|
||||||
|
cabin: "/images/about/cabin.jpg",
|
||||||
|
lobby: "/images/about/lobby.jpg",
|
||||||
|
side: "/images/about/about-side.jpg",
|
||||||
|
title: "درباره ی مـــــــــــــا",
|
||||||
|
subtitle: "درباره ما بیشتر بدانید",
|
||||||
|
intro:
|
||||||
|
"شرکت فنی مهندسی پــــارس برایند یک دهه تجربه و دانش مهندسی در زمینه آسانسور و پله برقی ،راه حلی مناسب برای انتخاب بهترین و ایمن ترین نوع آسانسور و پله برقی برای ساختمان شما خواهد بود.",
|
||||||
|
serviceText:
|
||||||
|
"شرکت آسانسور پارس داراي بخش مستقل از فروش با نام واحد خدمات پس از فروش بوده که نسبت به ارائه خدمات نصب و راه اندازی وسرويس ونگهداري ماهيانه منظم اقدام مي نمايد و قابليت پشتيبانی و خدمات بعد از آن را دارا مي باشد.",
|
||||||
|
techText:
|
||||||
|
"با پیشرفت علم و تکنولوژی نیاز به نرم افزارهای دقیق و هوشمند بیش از پیش احساس می گردد ،از این رو طراحی سه بعدی و محاسبات تنش و کرنش با نرم افزار قدرتمند Catia و Abaqus برای هر پروژه انجام خواهد شد و همچنین در هر مرحله روند اجرایی پروژه، نقشه های فونداسیون کف و اسکلت و ریل گذاری و کابین و موتورخانه ارائه خواهد شد که تمامی این مستندات در اختیار مشتری قرار خواهد گرفت .",
|
||||||
|
stats: [
|
||||||
|
{ value: 1227, labelTop: "آسانسور", labelBottom: "نصب شــــــــده" },
|
||||||
|
{ value: 1587, labelTop: "پروژه های", labelBottom: "در دست کــــــــــــــار" },
|
||||||
|
{
|
||||||
|
value: 1947,
|
||||||
|
labelTop: "مشتریان خوشحال",
|
||||||
|
labelBottom: "ما مشتریان را دوست داریم",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const contactPage = {
|
||||||
|
banner: "/images/contact/banner.jpg",
|
||||||
|
title: "با ما تماس بگیرید",
|
||||||
|
subtitle: "برای ارتباط با ما تماس بگیرید",
|
||||||
|
formTitle: "با ما ارتباط برقرار کنید",
|
||||||
|
formSubtitle: "در هر ساعتی از روز در خدمت شماییم",
|
||||||
|
privacy:
|
||||||
|
"ما متعهد به حفظ حریم شما می باشیم. ما هرگز بدون رضایت صریح شما اطلاعاتی در مورد شما جمع آوری نمی کنیم.",
|
||||||
|
mapEmbed:
|
||||||
|
"https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d580.5326252978325!2d50.85587785275461!3d34.60445361206812!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3f93bd72e39ea10b%3A0xb4327660682b4752!2z2KLYs9in2YbYs9mI2LEg2Ygg2b7ZhNmHINio2LHZgtuMINm-2KfYsdizINmC2YU!5e0!3m2!1sen!2s!4v1742030549747!5m2!1sen!2s",
|
||||||
|
latitude: "34.64370",
|
||||||
|
longitude: "50.89020",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const footerLinks = {
|
||||||
|
useful: [
|
||||||
|
{ label: "کانال تلگرام", href: "https://t.me/plift" },
|
||||||
|
{ label: "مقالات", href: "/blog" },
|
||||||
|
{ label: "کانال ایتا", href: "https://eitaa.com/parslift" },
|
||||||
|
{ label: "اخبار", href: "/blog" },
|
||||||
|
],
|
||||||
|
pages: [
|
||||||
|
{ label: "صفحه اصلی", href: "/" },
|
||||||
|
{ label: "محصولات", href: "/products" },
|
||||||
|
{ label: "مقالات", href: "/blog" },
|
||||||
|
{ label: "درباره ما", href: "/about-us" },
|
||||||
|
{ label: "تماس با ما", href: "/contact-us" },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const productsPage = {
|
||||||
|
banner: "/images/products/banner.jpg",
|
||||||
|
title: "محصولات ما",
|
||||||
|
subtitle: "در مورد محصولات ما بیشتر بدانید",
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "gearless",
|
||||||
|
eyebrow: "در مورد آسناسور های کششی بیشتر بدانید",
|
||||||
|
title: "آسانسور های کششی",
|
||||||
|
titleEn: "GEARLESS",
|
||||||
|
image: "/images/products/gearless-detail.jpg",
|
||||||
|
flipped: false,
|
||||||
|
text: "در اینگونه از آسانسورها از گیربکس استفاده نمیشود و سرعت آنها میتواند تا ۷ متر بر ثانیه نیز برسد. سیم بکسل در این مدل از آسانسورهای کششی به بالای کابین آسانسور متصل است و به دور فلکه برای به حرکت در آوردن آسانسور پیچیده شدهاست. سر دیگر سیم بکسل نیز به وزنه برای بهدست آوردن تعادل و ثبات سیستم مکانیکی وصل شده است. اینگونه از آسانسورهای کششی میتوانند برای ساختمانهای مرتفع مورد استفاده قرار گیرند.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "geared",
|
||||||
|
eyebrow: "آسانسور کششی گیربکسی",
|
||||||
|
title: "آسانسور کششی گیربکسی",
|
||||||
|
titleEn: "",
|
||||||
|
image: "/images/products/geared-detail.jpg",
|
||||||
|
flipped: true,
|
||||||
|
text: "آسانسور کششی قدیمیترین و تطبیقپذیرترین نوع آسانسور است. اینگونهی آسانسور یکی از پر استفادهترین گونههای آسانسور چه برای کاربرد خانگی چه برای کاربرد تجاری است. این آسانسورها از سیم بکسل، فلکه و وزنه برای حرکت استفاده میکنند. اینگونه از آسانسورها دارای گیربکسی هستند که به موتور متصل است و چرخی را که سیم بکسل را به حرکت در آورد را میراند. اینگونه از آسانسورها قادر هستند که به سرعتی بین ۱ تا ۲.۵ متر بر ثانیه دستیابند. حداکثر مسافتی که آسانسورهای گیربکسی (Geared) میتوانند طی کنند حدود ۷۶ متر است. بنابراین اینگونه از آسانسورها برای ساختمانهای کوتاه تا نیمه مرتفع مناسب است. حداکثر وزنی که آسانسورهای گیربکسی (Geared) میتوانند تحمل کنند حدود ۱۳۶۰۰ کیلوگرم است و هزینهی نصب اینگونه از آسانسورها به نسبت نوع Gearless کمتر است. شرکت آسانسور پارس با بهره گیری از تجارب گذشته و بکارگیری نیروهای متخصص و استفاده از قطعات اصلی و همکاری با برندهای معتبر جهانی و ایرانی ، در ارائه آسانسورهای گیربکسی با کیفیت بالا ، اهتمام دارد.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hydraulic",
|
||||||
|
eyebrow: "در مورد آسانسور هیدرولیک بیشتر بدانید",
|
||||||
|
title: "آسانسور هیدرولیک",
|
||||||
|
titleEn: "",
|
||||||
|
image: "/images/products/hydraulic-detail.jpg",
|
||||||
|
flipped: false,
|
||||||
|
text: "آسانسورهایی که با نیروی فشار روغن کار می کنند به آسانسورهای هیدرولیک معروف اند.در این آسانسورها جک و پاوریونیت تامین کننده ی نیروی محرکه می باشند.ساده ترین شکل این آسانسورها تنها شامل یک سیلندر و پیستون می باشد که به طور مستقیم به زیر کابین متصل شده اند. با چرخیدن موتور، تلمبه ،روغن را از مخزن به سیلندر پمپاژ کرده و با پر شدن ظرفیت سیلندر، روغن با فشار، کابین و پیستون را به طرف بالا به حرکت در می آورد. زمانی که کابین به طبقه ی مورد نظر می رسد سیستم کنترل به موتور علامتی برای قطع شدن پمپاژ روغن می دهد. همچنین برای پایین آمدن کابین، سیستم کنترل به دریچه فرمان و علامت می دهد که باز شود و سنگيني کابين موجب فشار آوردن پیستون به سیلندر می شود سپس روغن از دریچه به مخزن باز می گردد و در زمان توقف سیستم کنترل دستور بسته شدن دریچه را صادر می کند.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "escalator",
|
||||||
|
eyebrow: "در مورد پله برقی بیشتر بدانید",
|
||||||
|
title: "پله برقی و شیب رو برقی",
|
||||||
|
titleEn: "",
|
||||||
|
image: "/images/products/escalator-detail.jpg",
|
||||||
|
flipped: true,
|
||||||
|
text: "پله برقی وسیله جابجایی است که برای جابجایی افراد استفاده میشود،از پله های مستقل و به هم پیوسته تشکیل شده که حالت افقی دارد و برای بالا وپایین رفتن افراد به کار میرود. وسیله ای است که در مسیر حرکت افراد قرار گرفته شده و در جهت بالا و پایین بردن افراد در دو طبقه غیر همسطح به کار میرود.پله برقی یک چرخه تکرار شونده است.از پله های در حال گردش که در ان پله های بازگشت زیر پله های بالایی پنهان شده تشکیل شده می باشد. درست در سال ۱۸۸۱ بود که “جس وبلیورنو” تصمیم بزرگ زندگی خود را عملی نمود و دستگاهی به نام پله برقی را اختراع نمود ، در آن سالها بیشترین استفاده از این دستگاه برای جابجا نمودن آهن و سایر اجسام سنگین به درون کشتی ها بود و از این طریق کار را برای ملوان ها ساده تر نمود. حدود ۲۰ سال بعد و در قلب اروپا و شهر پاریس نمایشگاهی برگزار گردید که در آن نام پلکان متحرک بر این دستگاه گمارده شد و این دستگاه به جهانیان معرفی شد و از آن پس این دستگاه را شناختند و میتوانستند در سطح وسیعتری از آن استفاده نمایند.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"baseUrl": "https://www.plift.ir",
|
||||||
|
"staticPages": [
|
||||||
|
{
|
||||||
|
"path": "/",
|
||||||
|
"priority": 1,
|
||||||
|
"changefreq": "daily"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/about-us",
|
||||||
|
"priority": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/blog",
|
||||||
|
"priority": 0.7,
|
||||||
|
"changefreq": "daily"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/contact-us",
|
||||||
|
"priority": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/products",
|
||||||
|
"priority": 0.8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { API_BASE, DOMAIN } from "@/lib/config";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
body: unknown;
|
||||||
|
|
||||||
|
constructor(status: number, body: unknown, message?: string) {
|
||||||
|
super(message || `API ${status}`);
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchOptions = {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
token?: string | null;
|
||||||
|
searchParams?: Record<string, string | number | undefined | null>;
|
||||||
|
cache?: RequestCache;
|
||||||
|
next?: NextFetchRequestConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
{ method = "GET", body, token, searchParams, cache, next }: FetchOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const url = new URL(
|
||||||
|
path.startsWith("http") ? path : `${API_BASE}${path.startsWith("/") ? path : `/${path}`}`,
|
||||||
|
);
|
||||||
|
if (searchParams) {
|
||||||
|
for (const [key, value] of Object.entries(searchParams)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
Accept: "application/json",
|
||||||
|
};
|
||||||
|
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
cache,
|
||||||
|
next,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let data: unknown = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
typeof data === "object" && data && "message" in data
|
||||||
|
? Array.isArray((data as { message: unknown }).message)
|
||||||
|
? (data as { message: string[] }).message.join(", ")
|
||||||
|
: String((data as { message: unknown }).message)
|
||||||
|
: `Request failed (${res.status})`;
|
||||||
|
throw new ApiError(res.status, data, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tenantPath(suffix = "") {
|
||||||
|
return `/tenants/${DOMAIN}${suffix}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const DOMAIN = "plift.ir";
|
||||||
|
export const API_BASE = "https://api.meshkee.com/api/v1";
|
||||||
|
export const CUSTOMER_URL = `https://customer.${DOMAIN}`;
|
||||||
|
export const BUSINESS_URL = `https://business.${DOMAIN}`;
|
||||||
|
|
||||||
|
export const TOKEN_KEYS = {
|
||||||
|
access: "meshkee.accessToken",
|
||||||
|
refresh: "meshkee.refreshToken",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export function slugifyFa(input: string) {
|
||||||
|
return (
|
||||||
|
input
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^\u0600-\u06FFa-zA-Z0-9-]+/g, "")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 80) || "item"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public URL slug from display title — never use legacy CMS/DB slug. */
|
||||||
|
export function titleSlug(title: string) {
|
||||||
|
return slugifyFa(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogHref(blog: { id: string | number; title: string }) {
|
||||||
|
return `/blog/${blog.id}/${titleSlug(blog.title)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productHref(product: {
|
||||||
|
id: string | number;
|
||||||
|
nameFa?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
}) {
|
||||||
|
const name = product.nameFa || product.title || product.name || "product";
|
||||||
|
return `/products/${product.id}/${titleSlug(name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryHref(category: {
|
||||||
|
id: string | number;
|
||||||
|
nameFa?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
}) {
|
||||||
|
const name = category.nameFa || category.name || "category";
|
||||||
|
return `/products/category/${category.id}/${titleSlug(name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demoteCmsH1(html: string) {
|
||||||
|
return html
|
||||||
|
.replace(/<\s*h1(\s[^>]*)?>/gi, "<h2$1>")
|
||||||
|
.replace(/<\s*\/\s*h1\s*>/gi, "</h2>");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFaDate(iso?: string | null) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(new Date(iso));
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayName(user: {
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
cellNumber?: string | null;
|
||||||
|
}) {
|
||||||
|
const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
if (full) return full;
|
||||||
|
if (user.name?.trim()) return user.name.trim();
|
||||||
|
if (user.cellNumber) return user.cellNumber;
|
||||||
|
return "کاربر";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebsiteAdmin(
|
||||||
|
user: {
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isBusinessAdmin?: boolean;
|
||||||
|
role?: string | null;
|
||||||
|
roles?: string[] | null;
|
||||||
|
type?: string | null;
|
||||||
|
businesses?: Array<{ role?: string | null; id?: string | number }> | null;
|
||||||
|
},
|
||||||
|
businessId?: string | number | null,
|
||||||
|
) {
|
||||||
|
if (user.isAdmin || user.isBusinessAdmin) return true;
|
||||||
|
const role = (user.role || user.type || "").toLowerCase();
|
||||||
|
if (["admin", "owner", "business_admin", "business-admin", "staff"].includes(role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
user.roles?.some((r) =>
|
||||||
|
["admin", "owner", "business_admin"].includes(String(r).toLowerCase()),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!user.businesses?.length) return false;
|
||||||
|
return user.businesses.some((b) => {
|
||||||
|
const br = String(b.role || "").toLowerCase();
|
||||||
|
const isAdminRole = ["admin", "owner", "manager"].includes(br);
|
||||||
|
if (!businessId) return isAdminRole;
|
||||||
|
return String(b.id) === String(businessId) && isAdminRole;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { apiFetch, tenantPath } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
Blog,
|
||||||
|
BlogListResponse,
|
||||||
|
Category,
|
||||||
|
ContactSubmissionBody,
|
||||||
|
MeshkeeUser,
|
||||||
|
Product,
|
||||||
|
ProductListResponse,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export async function getTenant() {
|
||||||
|
return apiFetch<{ id: string | number; nameFa?: string; name?: string }>(
|
||||||
|
tenantPath(),
|
||||||
|
{ next: { revalidate: 3600 } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBlogs(page = 1, pageSize = 12) {
|
||||||
|
return apiFetch<BlogListResponse>(tenantPath("/blogs"), {
|
||||||
|
searchParams: { page, pageSize },
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlogById(blogId: string | number) {
|
||||||
|
return apiFetch<{ blog?: Blog } & Blog>(
|
||||||
|
tenantPath(`/blogs/by-id/${blogId}`),
|
||||||
|
{ next: { revalidate: 60 } },
|
||||||
|
).then((data) => ("blog" in data && data.blog ? data.blog : (data as Blog)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listProducts(page = 1, pageSize = 12, categoryId?: string) {
|
||||||
|
return apiFetch<ProductListResponse>(tenantPath("/products"), {
|
||||||
|
searchParams: { page, pageSize, categoryId },
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProductById(productId: string | number) {
|
||||||
|
return apiFetch<{ product?: Product } & Product>(
|
||||||
|
tenantPath(`/products/by-id/${productId}`),
|
||||||
|
{ next: { revalidate: 60 } },
|
||||||
|
).then((data) =>
|
||||||
|
"product" in data && data.product ? data.product : (data as Product),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoryById(categoryId: string | number) {
|
||||||
|
return apiFetch<{ category?: Category } & Category>(
|
||||||
|
tenantPath(`/categories/by-id/${categoryId}`),
|
||||||
|
{ next: { revalidate: 60 } },
|
||||||
|
).then((data) =>
|
||||||
|
"category" in data && data.category ? data.category : (data as Category),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitContact(body: ContactSubmissionBody) {
|
||||||
|
return apiFetch<{ message?: string }>(tenantPath("/contact-submissions"), {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(token: string) {
|
||||||
|
return apiFetch<{ user?: MeshkeeUser } & MeshkeeUser>("/auth/me", {
|
||||||
|
token,
|
||||||
|
cache: "no-store",
|
||||||
|
}).then((data) => ("user" in data && data.user ? data.user : (data as MeshkeeUser)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAuth(refreshToken: string) {
|
||||||
|
return apiFetch<{
|
||||||
|
user?: MeshkeeUser;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}>("/auth/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
body: { refreshToken },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
export type MeshkeeUser = {
|
||||||
|
id?: string | number;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
cellNumber?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
roles?: string[] | null;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isBusinessAdmin?: boolean;
|
||||||
|
businesses?: Array<{
|
||||||
|
id?: string | number;
|
||||||
|
role?: string | null;
|
||||||
|
domain?: string | null;
|
||||||
|
}> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Blog = {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
slug?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
abstract?: string | null;
|
||||||
|
mainTextHtml?: string | null;
|
||||||
|
titleImageUrl?: string | null;
|
||||||
|
categoryName?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
author?: {
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
} | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlogListResponse = {
|
||||||
|
items: Blog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContactSubmissionBody = {
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
cellNumber?: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Product = {
|
||||||
|
id: string | number;
|
||||||
|
nameFa?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
descriptionHtml?: string | null;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
images?: Array<string | { url?: string }> | null;
|
||||||
|
categoryId?: string | number | null;
|
||||||
|
categoryName?: string | null;
|
||||||
|
categoryNameFa?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductListResponse = {
|
||||||
|
items: Product[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
id: string | number;
|
||||||
|
name?: string | null;
|
||||||
|
nameFa?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||