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>
This commit is contained in:
Alireza Hassani
2026-09-05 14:55:42 +03:30
co-authored by Cursor
commit 5909899b1e
83 changed files with 10035 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
out
.env*.local
.DS_Store
npm-debug.log*
tmp-assets
+148
View File
@@ -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.
+20
View File
@@ -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.
+6
View File
@@ -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.
+16
View File
@@ -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;
+6262
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+89
View File
@@ -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)`);
+90
View File
@@ -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>
</>
);
}
+107
View File
@@ -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>
</>
);
}
+83
View File
@@ -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>
</>
);
}
+113
View File
@@ -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>
);
}
+439
View File
@@ -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);
}
}
+47
View File
@@ -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);
}
+226
View File
@@ -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>
</>
);
}
+136
View File
@@ -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>
</>
);
}
+117
View File
@@ -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>
);
}
+176
View File
@@ -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;
}
+50
View File
@@ -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>
);
}
+191
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+126
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+149
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+60
View File
@@ -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;
}
+121
View File
@@ -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>
);
}
+235
View File
@@ -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;
+27
View File
@@ -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
}
]
}
+77
View File
@@ -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}`;
}
+9
View File
@@ -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;
+103
View File
@@ -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;
});
}
+83
View File
@@ -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",
});
}
+82
View File
@@ -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;
};
+21
View File
@@ -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"]
}