Add the FarrokhWin Next.js site for Meshkee hosting.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-31 09:18:26 +03:30
co-authored by Cursor
commit 4dec4eb92c
116 changed files with 13456 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
NEXT_PUBLIC_WEBSITE_DOMAIN=farrokhwin.com
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
NEXT_PUBLIC_CUSTOMER_PORTAL_URL=https://customer.farrokhwin.com
NEXT_PUBLIC_BUSINESS_ORIGIN=https://business.farrokhwin.com
+37
View File
@@ -0,0 +1,37 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts
+151
View File
@@ -0,0 +1,151 @@
# FarrokhWin — AI / Project Context
## Source of truth
- Live reference: https://www.farrokhwin.com/
- Backend / Meshkee Website API: https://api.meshkee.com/docs/website
- Domain: `farrokhwin.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN`)
- Tenant business id: `28` (`GET /tenants/farrokhwin.com`)
- `enabledModules`: products, portfolio, blog, finance
- Customer portal (login / profile / cart): `https://customer.farrokhwin.com`
- Business dashboard: `https://business.farrokhwin.com`
- Language: `fa`, direction: `rtl`
## Stack
- Next.js 15 (App Router) + React 19 + TypeScript
- Tailwind CSS v4 (utility import) + custom CSS in `src/app/globals.css`
- Fonts (downloaded from the live Meshkee CDN into `public/fonts/`):
- `morabba` — default UI / headings
- `iran-yekan` — numbers / accent figures
- `Montserrat` via `next/font/google` (English labels, class `.en`)
## Color palette (from live site `:root`)
- Primary: `#19377d`
- Secondary / accent: `#34ace0`
- Header: `#23242D`
- Page background: `#1F2128` / `#23262D`
- Muted text: `#8F8F8F`
- White: `#FFFFFF`
## Pages
| Route | Purpose |
|-------|---------|
| `/` | Homepage (hero, stats, intro, Aparat video, glass article, window types, why us, features, **latest blogs carousel**, CTA, **latest products carousel**, brands) |
| `/about` | درباره ما |
| `/contact` | تماس با ما (info + FAQ + Meshkee contact form) |
| `/products` | Meshkee product catalog (`GET /tenants/{domain}/products`) |
| `/products/category/{id}/{nameFaSlug}` | Product category (`GET /tenants/{domain}/categories/by-id/{id}`) |
| `/products/{id}/{nameFaSlug}` | Product details (gallery, zoom, tabs, comments, similar products) |
| `/blog` | Meshkee blog list (`GET /tenants/{domain}/blogs`) |
| `/blog/{id}/{titleSlug}` | Blog details (body HTML + comments) |
| `/portfolio` | Meshkee portfolio list (`GET /tenants/{domain}/portfolios`) |
| `/portfolios/{id}/{titleFaSlug}` | Portfolio details (`GET /tenants/{domain}/portfolios/by-id/{id}`) |
| `/videos` | Placeholder — videos from Meshkee later |
Header: logo + phones + **AccountMenu** + hamburger overlay (image panel + RTL menu). Cart still in the hamburger.
## Auth / account menu
- Login / user icon → `https://customer.farrokhwin.com` (`customerPortalUrl` in `src/lib/config.ts`)
- Shared session cookies: `src/lib/shared-auth-cookie.ts` + `src/lib/session.tsx`
- `GET /api/auth/me` proxies Meshkee `/auth/me`
- Logged in: show `{name} عزیز` with dropdown **پروفایل من**, **خروج**, and if website admin **مدیریت وبسایت**
- Admin handoff: `POST /api/auth/handoff` → `business.farrokhwin.com?handoff=…`
- Admin = super_admin or membership on business id `28` (`isOwner` or `teamRole`)
- Comments: form only when authenticated; otherwise a login box linking to the customer portal. `POST /api/comments` → Meshkee tenant comments
## Meshkee API
Base: `{NEXT_PUBLIC_MESHKEE_API_BASE}/tenants/{domain}/...`
Helpers: `src/lib/meshkee.ts` → `meshkeeGet`, `meshkeeGetOrNull`, `tenantPath`, `getTenant`, `getBusinessId`
| Resource | List | Detail |
|----------|------|--------|
| Products | `GET /tenants/{domain}/products` (`src/lib/products.ts`) | `GET /tenants/{domain}/products/by-id/{id}` + `/technical-info` |
| Blogs | `GET /tenants/{domain}/blogs` (`src/lib/blog.ts`) | `GET /tenants/{domain}/blogs/by-id/{id}` |
| Comments | `GET /tenants/{domain}/comments?entityType=&entityId=` | `POST` via `/api/comments` |
| Portfolio | `GET /tenants/{domain}/portfolios` | not wired yet |
| Contact | `POST /tenants/{domain}/contact-submissions` via `POST /api/contact` | Body: `{ title, name, text, email?, cellNumber? }` |
Canonical URLs:
- Product: `/products/{id}/{nameFaSlug}` (`productHref` / `slugifyForUrl`)
- Blog: `/blog/{id}/{titleSlug}` (`blogHref`)
Product details:
- Lightbox gallery + hover zoom (`src/components/ProductGallery.tsx`)
- Tabs: توضیحات / مشخصات فنی / نظرات (`src/components/ProductTabs.tsx`)
- Similar products from `relatedProducts`, else other catalog items
Homepage carousels (`src/components/MediaCarousel.tsx`): latest blogs and latest products, **max 12** each. API items are primary; static slot images overlay by index when present.
## Static image slots
API: `GET /tenants/{domain}/website/static-images?pageKey=home|about|contact`
Catalog: `GET /meshkee/static-image-slots` → `STATIC_IMAGE_SLOTS` in `src/lib/static-images.ts`
| key | kind | itemCount | aspectRatio | recommendedWidth | section |
| --- | --- | --- | --- | --- | --- |
| `slider` | list | null | `1920:1080` | 1920 | Homepage hero banners |
| `home-video-poster` | single | 1 | `16:9` | 1280 | Homepage video |
| `home-windows` | list | 4 | `12:17` | 720 | Window type cards |
| `home-why-image` | single | 1 | `1:1` | 640 | Why Farrokh graphic |
| `home-blog` | list | 12 | `1:1` | 720 | Homepage latest-blog carousel (square overlay cards) |
| `home-products` | list | 12 | `4:3` | 640 | Homepage latest-products carousel (equal 4:3 boxes, contain) |
| `home-clients` | list | 4 | `3:2` | 320 | Brand logos |
| `about-hero` | single | 1 | `1920:480` | 1440 | About hero (currently CSS navy band) |
| `about-photo` | single | 1 | `4:3` | 800 | About photo |
| `contact-hero` | single | 1 | `1920:480` | 1440 | Contact hero band |
| `contact-faq-image` | single | 1 | `4:3` | 960 | Contact FAQ photo |
Rules:
- Use `slot.images` (all if `kind=list`, first if `kind=single`)
- `url` → image src; `linkUrl` → wrap in link
- Empty / fail → local fallbacks from `src/data/site.ts` / `public/images/`
- Homepage blog/product carousels keep Meshkee API items (ids, titles, hrefs); slot images only replace `image` at the same index
- **If a slot’s on-page size or aspect ratio changes in CSS, update `STATIC_IMAGE_SLOTS` in the same change**
## Content & assets
- Site copy, phones, addresses, FAQs: `src/data/site.ts`
- Logo: `public/images/logo/logo.png` (from live `kC9gmfo7ef.png`)
- Hero banners: `public/images/hero/slide-1.jpg` … `slide-3.jpg`
- Favicon: `/favicon.png`
- Meshkee footer credit (same as safeteb.com): `public/images/footer/MeshkeeLogo-White.png`
- Contact: `02538203597` · `09002032333` · `02533342698`
- Office: قم، بلوار امام رضا(ع)، مجتمع فردوس، واحد ۱۳۸
- Factory: قم، شهرک صنعتی شکوهیه، فاز دو، خیابان فکوری، کوچه ۲، پلاک KH214
- Social: Instagram / Telegram `@farrokhwin`, WhatsApp `989002032333`
- Homepage video: Aparat `w21jpe0` via official embed script (`src/components/AparatVideo.tsx`)
## Important paths
- Layout / shell: `src/app/layout.tsx`, `src/components/Header.tsx`, `src/components/Footer.tsx`, `src/components/Providers.tsx`
- Account: `src/components/AccountMenu.tsx`, `src/lib/session.tsx`
- Home: `src/app/page.tsx`, `src/components/HeroSlider.tsx`, `src/components/MediaCarousel.tsx`
- Products: `src/app/products/page.tsx`, `src/app/products/[id]/[slug]/page.tsx`
- Blog: `src/app/blog/page.tsx`, `src/app/blog/[id]/[slug]/page.tsx`
- About / contact: `src/app/about/page.tsx`, `src/app/contact/page.tsx`
- Config: `src/lib/config.ts`
## Env
```bash
NEXT_PUBLIC_WEBSITE_DOMAIN=farrokhwin.com
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
NEXT_PUBLIC_CUSTOMER_PORTAL_URL=https://customer.farrokhwin.com
NEXT_PUBLIC_BUSINESS_ORIGIN=https://business.farrokhwin.com
```
## Commands
```bash
npm install
npm run dev
npm run build
```
## Sitemap + robots
- **Do not** add `public/sitemap.xml` or `public/robots.txt` (nginx proxies both to the Meshkee API)
- Publish `GET /meshkee/sitemap-config.json` (generated at build from app routes)
- Generated at build: `scripts/generate-sitemap-config.mjs` → `src/lib/sitemap-config.generated.json`
- Run manually: `npm run generate:sitemap-config`
- After deploy: business dashboard → Website → Settings → Sync sitemap config
- Dynamic `/products/{id}/{slug}`, `/products/category/{id}/{slug}`, `/blog/{id}/{slug}`, and `/portfolios/{id}/{slug}` are not listed in the static sitemap config; Meshkee CMS syncs those URLs
### On-page SEO rules
- Unique `<title>` + meta description on every crawlable page (`src/lib/seo.ts` → `pageMetadata`)
- Exactly one `<h1>` per page; section headings use `<h2>` / `<h3>`
- Content images have meaningful `alt`
- Footer bottom Meshkee credit must stay in the safeteb.com style (animated frame + white logo + “Designed And Developed By Meshkee E-Commerce Team”)
+14
View File
@@ -0,0 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
export default eslintConfig;
+26
View File
@@ -0,0 +1,26 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
},
{
protocol: "https",
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
},
{
protocol: "https",
hostname: "c804387.parspack.net",
},
{
protocol: "https",
hostname: "cdn.meshkee.com",
},
],
},
};
export default nextConfig;
+6263
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "farrokhwin",
"version": "0.1.0",
"private": true,
"scripts": {
"prebuild": "node scripts/generate-sitemap-config.mjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs"
},
"dependencies": {
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.15.21",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"eslint": "^9.28.0",
"eslint-config-next": "^15.3.3",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
@font-face {
font-family: "montserrat";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-regular.ttf');
}
@font-face {
font-family: "montserrat-medium";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-medium.ttf');
}
@font-face {
font-family: "montserrat-light";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-light.ttf');
}
@font-face {
font-family: "montserrat-extralight";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-extralight.ttf');
}
@font-face {
font-family: "montserrat-bold";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-bold.ttf');
}
@font-face {
font-family: "montserrat-semibold";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-semibold.ttf');
}
@font-face {
font-family: "montserrat-extrabold";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-extrabold.ttf');
}
@font-face {
font-family: "montserrat-thin";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-thin.ttf');
}
@font-face {
font-family: "montserrat-black";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/montserrat-black.ttf');
}
@font-face {
font-family: "montserrat-alternates-regular";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/MontserratAlternates-Regular.ttf');
}
@font-face {
font-family: "montserrat-alternates-light";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/MontserratAlternates-Light.ttf');
}
@font-face {
font-family: "montserrat-alternates-extrabold";
src: url('https://cdn.meshkee.com/web-builder/fonts/montserrat/MontserratAlternates-ExtraBold.ttf');
}
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: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

+100
View File
@@ -0,0 +1,100 @@
/**
* Scans app page routes and writes src/lib/sitemap-config.generated.json
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const appDir = path.join(root, "src", "app");
const outFile = path.join(root, "src", "lib", "sitemap-config.generated.json");
const EXCLUDE_SEGMENTS = new Set([
"api",
"meshkee",
"admin",
"auth",
"login",
"checkout",
"cart",
"account",
]);
const PRIORITY = {
"/": { changefreq: "daily", priority: 1.0 },
"/about": { changefreq: "monthly", priority: 0.6 },
"/contact": { changefreq: "monthly", priority: 0.5 },
"/products": { changefreq: "weekly", priority: 0.8 },
"/portfolio": { changefreq: "weekly", priority: 0.7 },
"/blog": { changefreq: "weekly", priority: 0.7 },
"/videos": { changefreq: "weekly", priority: 0.6 },
};
function websiteDomain() {
return (
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ||
"farrokhwin.com"
);
}
function walkPages(dir, segments = []) {
/** @type {string[]} */
const pages = [];
if (!fs.existsSync(dir)) return pages;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
if (/^\(.+\)$/.test(entry.name)) {
pages.push(...walkPages(full, segments));
}
continue;
}
pages.push(...walkPages(full, [...segments, entry.name]));
continue;
}
if (
entry.name === "page.tsx" ||
entry.name === "page.ts" ||
entry.name === "page.jsx" ||
entry.name === "page.js"
) {
const routePath = segments.length ? `/${segments.join("/")}` : "/";
pages.push(routePath);
}
}
return pages;
}
function main() {
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b);
});
const staticPages = paths.map((routePath) => {
const meta = PRIORITY[routePath] ?? { changefreq: "monthly", priority: 0.5 };
return { path: routePath, ...meta };
});
const config = {
baseUrl: `https://${websiteDomain()}`,
staticPages,
};
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`);
}
main();
+119
View File
@@ -0,0 +1,119 @@
import Link from "next/link";
import PageHero from "@/components/PageHero";
import Reveal from "@/components/Reveal";
import SlotImage from "@/components/SlotImage";
import { aboutGoals, site } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
export const metadata = pageMetadata({
title: "درباره ما",
description: site.tagline,
path: "/about",
image: "/images/about/hero.jpg",
});
function GoalIcon({ name }: { name: string }) {
if (name === "star") {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M12 3.5l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.4l.9-5.4L4.2 9.2l5.4-.8L12 3.5z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
if (name === "diamond") {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 9l8 11 8-11-3.5-5h-9L4 9z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M4 9h16M8.5 4L12 9l3.5-5" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
}
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M4 11.5c0-2.8 2.1-5 5-5 1.4 0 2.6.6 3.5 1.6.9-1 2.1-1.6 3.5-1.6 2.9 0 5 2.2 5 5 0 5.2-8.5 9.5-8.5 9.5S4 16.7 4 11.5z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
export default async function AboutPage() {
const slots = await fetchStaticImageSlots("about");
const photo = overlaySlotImages(
pickSlot(slots, "about-photo"),
[{ id: "about-photo", image: "/images/windows/upvc.jpg", title: site.slogan }],
"about-photo",
);
const introText = site.footerAbout.split("\n")[0];
return (
<>
<PageHero
title="درباره مـــــــا بیشتـــــــر بدانید..."
en="ABOUT US"
crumbs={[
{ href: "/", label: "خانه" },
{ href: "/about", label: "درباره ما" },
]}
/>
<section className="section about-intro">
<div className="container about-intro-grid">
<Reveal variant="right">
<div className="about-photo">
<SlotImage
src={photo.items[0]?.image || "/images/windows/upvc.jpg"}
alt={photo.items[0]?.title || site.shortName}
fill
/>
</div>
</Reveal>
<Reveal variant="left" delay={120}>
<div>
<h2>{site.slogan}</h2>
<p>{introText}</p>
<Link className="text-link" href="/contact">
تماس با ما
</Link>
</div>
</Reveal>
</div>
</section>
<section className="section goals-section">
<div className="container">
<h2>هدف های شرکت ما</h2>
<p className="en">TARGET</p>
<div className="goals-layout">
<div className="goals-graphic">
<SlotImage src="/images/about/icon.png" alt="پنجره UPVC" fill />
</div>
<div className="goals-list">
{aboutGoals.map((goal) => (
<article key={goal.title} className="goal-item">
<span className="goal-icon">
<GoalIcon name={goal.icon} />
</span>
<div>
<h3>{goal.title}</h3>
<p>{goal.text}</p>
</div>
</article>
))}
</div>
</div>
</div>
</section>
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
export async function POST(request: Request) {
const token = getBearerToken(request);
if (!token) return unauthorizedResponse();
const res = await meshkeeFetch("/auth/handoff", {
method: "POST",
token,
});
if (!res.ok) {
return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status);
}
return jsonResponse(res.data);
}
+10
View File
@@ -0,0 +1,10 @@
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
export async function GET(request: Request) {
const token = getBearerToken(request);
if (!token) return unauthorizedResponse();
const res = await meshkeeFetch("/auth/me", { token });
if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status);
return jsonResponse(res.data);
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function POST(request: Request) {
const body = (await request.json()) as {
entityType?: string;
entityId?: string;
authorName?: string;
authorEmail?: string;
text?: string;
};
if (!body.entityType?.trim() || !body.entityId?.trim() || !body.authorName?.trim() || !body.text?.trim()) {
return NextResponse.json({ message: "نام و متن نظر الزامی است." }, { status: 400 });
}
const auth = request.headers.get("authorization");
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (auth) headers.Authorization = auth;
const response = await fetch(`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/comments`, {
method: "POST",
headers,
body: JSON.stringify({
entityType: body.entityType.trim(),
entityId: body.entityId.trim(),
authorName: body.authorName.trim(),
authorEmail: body.authorEmail?.trim() || undefined,
text: body.text.trim(),
}),
cache: "no-store",
});
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function POST(request: Request) {
const body = (await request.json()) as {
title?: string;
name?: string;
email?: string;
cellNumber?: string;
text?: string;
};
if (!body.title?.trim() || !body.name?.trim() || !body.text?.trim()) {
return NextResponse.json({ message: "نام و متن پیام الزامی است." }, { status: 400 });
}
const response = await fetch(
`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/contact-submissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: body.title.trim(),
name: body.name.trim(),
email: body.email?.trim() || undefined,
cellNumber: body.cellNumber?.trim() || undefined,
text: body.text.trim(),
}),
cache: "no-store",
},
);
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
+107
View File
@@ -0,0 +1,107 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import CommentBox from "@/components/CommentBox";
import SlotImage from "@/components/SlotImage";
import {
blogDate,
blogHref,
blogImage,
blogTitleSlug,
fetchBlogById,
getLocalBlogPost,
} from "@/lib/blog";
import { fetchComments } from "@/lib/comments";
import { sanitizeHtml } from "@/lib/format";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
type Params = { id: string; slug: string };
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { id } = await params;
const post = (await fetchBlogById(id)) ?? getLocalBlogPost(id);
if (!post) {
return {
title: "مقاله یافت نشد",
description: "این مطلب در وبلاگ پنجره فرخ موجود نیست.",
robots: { index: false },
};
}
return pageMetadata({
title: post.title,
description: post.abstract?.trim() || `${post.title} | مقالات پنجره فرخ`,
path: blogHref(post),
image: blogImage(post),
});
}
export default async function BlogDetailPage({ params }: { params: Promise<Params> }) {
const { id, slug } = await params;
const apiPost = await fetchBlogById(id);
const post = apiPost ?? getLocalBlogPost(id);
if (!post) notFound();
const canonical = blogTitleSlug(post.title);
if (decodeURIComponent(slug) !== canonical) {
redirect(blogHref(post));
}
const comments = apiPost ? await fetchComments("blog", post.id) : [];
const html = sanitizeHtml(post.mainTextHtml);
return (
<>
<section className="page-hero">
<div className="container page-hero-inner">
<div className="page-hero-en">
<span>ARTICLE</span>
<i />
</div>
<div className="page-hero-fa">
<nav className="crumbs">
<Link href="/">خانه</Link>
{" > "}
<Link href="/blog">مقالات</Link>
{" > "}
<span>{post.title}</span>
</nav>
<h1>{post.title}</h1>
{blogDate(post) ? <p className="muted">{blogDate(post)}</p> : null}
</div>
</div>
</section>
<section className="section">
<div className="container detail-article">
{blogImage(post) ? (
<div className="article-cover">
<SlotImage src={blogImage(post)} alt={post.title} />
</div>
) : null}
{post.abstract ? <p className="detail-lead">{post.abstract}</p> : null}
{html ? <div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} /> : null}
<section className="detail-tabs">
<h2>نظرات</h2>
{apiPost ? (
<CommentBox entityType="blog" entityId={post.id} comments={comments} />
) : (
<p className="comment-empty">ثبت نظر برای این مطلب در دسترس نیست.</p>
)}
</section>
<Link href="/blog" className="text-link">
بازگشت به مقالات
</Link>
</div>
</section>
</>
);
}
+100
View File
@@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Link from "next/link";
import BlogCoverCard from "@/components/BlogCoverCard";
import PageHero from "@/components/PageHero";
import {
blogCategory,
blogDateLong,
blogHref,
blogImage,
fetchBlogs,
getLocalBlogPosts,
} from "@/lib/blog";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}): Promise<Metadata> {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
return pageMetadata({
title: page > 1 ? `مقالات — صفحه ${page}` : "مقالات",
description: "آخرین مقالات پنجره فرخ درباره UPVC، شیشه دوجداره و صنعت درب و پنجره.",
path: page > 1 ? `/blog?page=${page}` : "/blog",
});
}
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const result = await fetchBlogs({ page, pageSize: 12 });
const usingApi = result.items.length > 0;
const posts = usingApi ? result.items : page === 1 ? getLocalBlogPosts() : [];
const pageCount = usingApi
? Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)))
: 1;
return (
<>
<PageHero
title="مقالات"
en="ARTICLES"
crumbs={[
{ href: "/", label: "خانه" },
{ href: "/blog", label: "مقالات" },
]}
/>
<section className="section">
<div className="container">
{posts.length === 0 ? (
<p className="comment-empty">مقاله‌ای برای نمایش وجود ندارد.</p>
) : (
<div className="blog-list-grid">
{posts.map((post) => (
<BlogCoverCard
key={post.id}
href={blogHref(post)}
image={blogImage(post)}
title={post.title}
category={blogCategory(post)}
date={blogDateLong(post)}
heading="h2"
/>
))}
</div>
)}
{pageCount > 1 ? (
<nav className="pagination" aria-label="صفحه‌بندی مقالات">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
if (pageCount > 12 && Math.abs(number - page) > 3 && number !== 1 && number !== pageCount) {
return number === 2 || number === pageCount - 1 ? <span key={number}>…</span> : null;
}
return (
<Link
key={number}
href={number === 1 ? "/blog" : `/blog?page=${number}`}
className={number === page ? "active" : undefined}
>
{number}
</Link>
);
})}
</nav>
) : null}
</div>
</section>
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
import ContactForm from "@/components/ContactForm";
import FaqList from "@/components/FaqList";
import PageHero from "@/components/PageHero";
import SlotImage from "@/components/SlotImage";
import { site } from "@/data/site";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot } from "@/lib/static-images";
export const metadata = pageMetadata({
title: "تماس با ما",
description: `ارتباط با پنجره فرخ. ${site.officeFull} تلفن ${site.phones.sales}`,
path: "/contact",
image: "/images/contact/hero.jpg",
});
export default async function ContactPage() {
const slots = await fetchStaticImageSlots("contact");
const faqImage = overlaySlotImages(
pickSlot(slots, "contact-faq-image"),
[{ id: "faq", image: "/images/contact/hero.jpg", title: "تماس با ما" }],
"contact-faq-image",
);
return (
<>
<PageHero
title="بـــــــا مـــــــــا تمــــــــاس بگیریـــــــــــد..."
en="CONTACT US"
crumbs={[
{ href: "/", label: "خانه" },
{ href: "/contact", label: "تماس با ما" },
]}
/>
<section className="contact-info">
<div className="container contact-info-grid">
<article>
<SlotImage src="/images/contact/social.png" alt="شبکه‌های اجتماعی" width={48} height={48} />
<h2>آدرس شبکه های اجتماعی</h2>
<a href={site.social.eitaa} target="_blank" rel="noopener noreferrer">
{site.social.handle}
</a>
</article>
<article>
<SlotImage src="/images/contact/phone.png" alt="تلفن تماس" width={48} height={48} />
<h2>تلفن تماس</h2>
<a className="contact-phones" href={`tel:+98${site.phones.mobile.replace(/^0/, "")}`}>
{site.phones.sales}
<br />
{site.phones.landline}
</a>
</article>
<article>
<SlotImage src="/images/contact/address.png" alt="آدرس" width={48} height={48} />
<h2>آدرس ما</h2>
<a href="#map">
{site.officeFull}
<br />
{site.factoryFull}
</a>
</article>
</div>
</section>
<section className="faq-section">
<div className="faq-split">
<div className="faq-photo">
<SlotImage
src={faqImage.items[0]?.image || "/images/contact/hero.jpg"}
alt="سوالات متداول پنجره فرخ"
fill
/>
</div>
<div className="faq-copy">
<p className="section-kicker">سوال های متداول شما</p>
<h2>برخی از سوالات متداول شما مشتریان عزیز</h2>
<FaqList />
</div>
</div>
</section>
<section className="section contact-form-section">
<div className="container">
<ContactForm />
</div>
</section>
<section className="contact-map" id="map">
<iframe
title="دفتر پنجره فرخ روی نقشه"
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3276.7487061444917!2d50.81011399999999!3d34.787101!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3f93adf4ab52d83d%3A0x3d84ac6e554cfc6a!2z2b7Zhtis2LHZhyDZgdix2K4vRmFycm9raCBVUFZD!5e0!3m2!1sen!2s!4v1692164739157!5m2!1sen!2s"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</section>
</>
);
}
+2442
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import Footer from "@/components/Footer";
import Header from "@/components/Header";
import Providers from "@/components/Providers";
import { site } from "@/data/site";
import { websiteDomain } from "@/lib/config";
import { getBusinessId } from "@/lib/meshkee";
import "./globals.css";
const montserrat = Montserrat({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
variable: "--font-montserrat",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(`https://${websiteDomain}`),
title: {
default: site.name,
template: `%s | ${site.shortName}`,
},
description: site.description,
icons: {
icon: "/favicon.png",
},
openGraph: {
locale: "fa_IR",
siteName: site.shortName,
type: "website",
},
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const businessId = await getBusinessId();
return (
<html lang="fa" dir="rtl" className={montserrat.variable}>
<body>
<Providers businessId={businessId || "28"}>
<div className="wrapper">
<Header />
<main>{children}</main>
<Footer />
</div>
</Providers>
</body>
</html>
);
}
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
@@ -0,0 +1,16 @@
import { STATIC_IMAGE_SLOTS } from "@/lib/static-images";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
}
+310
View File
@@ -0,0 +1,310 @@
import Link from "next/link";
import AparatVideo from "@/components/AparatVideo";
import GlassReadMore from "@/components/GlassReadMore";
import HeroSlider from "@/components/HeroSlider";
import MediaCarousel from "@/components/MediaCarousel";
import Reveal from "@/components/Reveal";
import SlotImage from "@/components/SlotImage";
import { blogCategory, blogDateLong, blogHref, blogImage, fetchBlogs } from "@/lib/blog";
import {
blogPosts,
brands,
features,
homeProducts,
site,
slides,
whyText,
windowTypes,
} from "@/data/site";
import { productHref, productImage, productTitle, fetchProducts } from "@/lib/products";
import { toFaDigits } from "@/lib/format";
import { pageMetadata } from "@/lib/seo";
import { fetchStaticImageSlots, overlaySlotImages, pickSlot, type SlotMediaItem } from "@/lib/static-images";
function carouselItems(overlaid: SlotMediaItem[], fallbacks: SlotMediaItem[], max = 12) {
const seen = new Set<string>();
return fallbacks.slice(0, max).map((item, index) => {
const slotItem = overlaid[index];
if (!slotItem?.image) return item;
return {
...item,
image: slotItem.image,
href: slotItem.href || item.href,
title: item.title || slotItem.title,
kicker: item.kicker || slotItem.kicker,
};
}).filter((item) => {
if (!item.id || seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
}
export const metadata = pageMetadata({
title: site.name,
description: site.description,
path: "/",
image: "/images/hero/slide-1.jpg",
absolute: true,
});
export default async function HomePage() {
const [slots, apiBlogs, apiProducts] = await Promise.all([
fetchStaticImageSlots("home"),
fetchBlogs({ pageSize: 12 }),
fetchProducts({ pageSize: 12 }),
]);
const slider = overlaySlotImages(
pickSlot(slots, "slider"),
slides.map((slide) => ({
id: slide.id,
image: slide.image,
title: slide.title,
})),
"slider",
);
const windows = overlaySlotImages(
pickSlot(slots, "home-windows"),
windowTypes.map((item) => ({
id: item.id,
image: item.image,
title: item.title,
text: item.text,
href: item.href,
})),
"home-windows",
);
const whyImage = overlaySlotImages(
pickSlot(slots, "home-why-image"),
[{ id: "why", image: "/images/features/01.png", title: "چرا پنجره فرخ" }],
"home-why-image",
);
const blogFallbacks: SlotMediaItem[] = apiBlogs.items.length
? apiBlogs.items.map((post) => ({
id: post.id,
image: blogImage(post),
title: post.title,
kicker: blogCategory(post) || undefined,
text: blogDateLong(post) || undefined,
href: blogHref(post),
}))
: blogPosts.map((post) => ({
id: post.id,
image: post.image,
title: post.title,
kicker: post.category,
href: post.href,
}));
const blogItems = overlaySlotImages(pickSlot(slots, "home-blog"), blogFallbacks, "home-blog");
const productFallbacks: SlotMediaItem[] = apiProducts.items.length
? apiProducts.items.map((product) => ({
id: product.id,
image: productImage(product),
title: productTitle(product),
kicker: product.title && product.nameFa ? product.title : product.categoryNameFa || undefined,
href: productHref(product),
}))
: homeProducts.map((product) => ({
id: product.id,
image: product.image,
title: product.title,
kicker: product.kicker,
href: product.href,
}));
const productItems = overlaySlotImages(pickSlot(slots, "home-products"), productFallbacks, "home-products");
const clientItems = overlaySlotImages(
pickSlot(slots, "home-clients"),
brands.map((brand) => ({
id: brand.id,
image: brand.image,
title: brand.title,
})),
"home-clients",
);
return (
<>
<HeroSlider slides={slider.items} />
<section className="stats-bar">
<div className="container stats-inner">
<div>
<strong>{toFaDigits(site.projects)}</strong>
<span>پروژه انجام شده</span>
</div>
<div>
<strong>{toFaDigits(site.workYears)}</strong>
<span>تجربه کاری</span>
</div>
<div>
<strong>درب و پنجره پنجره فرخ</strong>
<span>{site.slogan}</span>
</div>
</div>
</section>
<section className="section intro-section">
<div className="container intro-grid">
<Reveal>
<div className="years-box">
<span className="years-frame years-frame--left" aria-hidden />
<span className="years-frame years-frame--right" aria-hidden />
<span className="years-num">{toFaDigits(site.experienceYears)}</span>
<span className="years-label">ســــال تجربه در صنعت</span>
</div>
</Reveal>
<Reveal delay={80}>
<div className="intro-brand">
<p className="from-year">از سال {site.foundedYear}</p>
<h1>
شرکــــت درب و پنجــــــــــره
<br />
پنجره فرخ
</h1>
</div>
</Reveal>
<Reveal delay={160}>
<div>
<p className="muted">{site.tagline}</p>
<Link className="text-link" href="/about">
توضیحات بیشتر
</Link>
</div>
</Reveal>
</div>
</section>
<section className="video-heading">
<div className="container">
<h2>فرخ وین تولید کننده انواع پنجره UPVC و شیشه دو جداره</h2>
<AparatVideo />
</div>
</section>
<section className="section glass-section">
<div className="container">
<GlassReadMore />
</div>
</section>
<section className="section windows-section">
<div className="container">
<p className="section-kicker">دسته بندی های ما</p>
<h2>انواع درب و پنجره دوجداره</h2>
</div>
<div className="windows-wrap">
<div className="windows-grid">
{windows.items.map((item) => (
<article key={item.id} className="window-card">
<Link href={item.href || "/products"}>
<div className="window-photo">
<SlotImage src={item.image} alt={item.title || site.shortName} />
<span className="window-dim" aria-hidden />
<div className="window-content">
<h3>{item.title}</h3>
<span className="window-arrow" aria-hidden>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
<div className="window-hover">
<div className="window-hover-inner">
<p>{item.text}</p>
<span className="window-details">
نمایش جزئیات
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
</div>
</Link>
</article>
))}
</div>
</div>
</section>
<section className="section why-section">
<div className="why-watermark" aria-hidden>
Farrokh
</div>
<div className="container why-grid">
<Reveal>
<div className="why-photo">
<SlotImage
src={whyImage.items[0]?.image || "/images/features/01.png"}
alt={whyImage.items[0]?.title || "چرا پنجره فرخ"}
/>
</div>
</Reveal>
<Reveal delay={120} variant="left">
<div className="why-copy">
<h2>چرا پنجره فرخ ؟!</h2>
<p>{whyText}</p>
<Link className="text-link why-link" href="/products">
محصولات ما
</Link>
</div>
</Reveal>
</div>
</section>
<section className="section features-section">
<div className="container">
<p className="section-kicker">فرخ وین، تولید کننده شیشه دو جداره</p>
<div className="features-grid">
{features.map((feature) => (
<article key={feature.id}>
<SlotImage src={feature.image} alt={feature.title} width={72} height={72} />
<h3>{feature.title}</h3>
</article>
))}
</div>
</div>
</section>
<section className="section blog-section">
<div className="container">
<h2>آخرین مقالات</h2>
</div>
<MediaCarousel items={carouselItems(blogItems.items, blogFallbacks)} variant="blog" ariaLabel="آخرین مقالات" />
</section>
<section className="cta-section">
<div className="container">
<h2>
آیا آماده همکاری با ما هستید؟{" "}
<Link href="/contact">شروع یک پروژه</Link>
</h2>
</div>
</section>
<section className="section products-section">
<div className="container">
<p className="section-kicker">انتخاب مردم</p>
<h2>کارهای خاص</h2>
<MediaCarousel items={carouselItems(productItems.items, productFallbacks)} variant="product" ariaLabel="آخرین محصولات" />
</div>
</section>
<section className="section brands-section">
<div className="container">
<h2>برند های ما</h2>
<p className="en">OUR CUSTOMER</p>
<div className="brands-grid">
{clientItems.items.map((brand) => (
<Link key={brand.id} href="/products" className="brand-item" title={brand.title}>
<SlotImage src={brand.image} alt={brand.title || "برند"} />
</Link>
))}
</div>
</div>
</section>
</>
);
}
+98
View File
@@ -0,0 +1,98 @@
import type { Metadata } from "next";
import Link from "next/link";
import PageHero from "@/components/PageHero";
import SlotImage from "@/components/SlotImage";
import {
fetchPortfolios,
portfolioHref,
portfolioImage,
portfolioTitle,
} from "@/lib/portfolios";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}): Promise<Metadata> {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
return pageMetadata({
title: page > 1 ? `نمونه کارها — صفحه ${page}` : "نمونه کارها",
description: "نمونه کارهای اجرا شده پنجره فرخ در UPVC، شیشه دوجداره و سندبلاست.",
path: page > 1 ? `/portfolio?page=${page}` : "/portfolio",
});
}
export default async function PortfolioPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const result = await fetchPortfolios({ page, pageSize: 12 });
const pageCount = Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)));
return (
<>
<PageHero
title="نمونه کارها"
en="PORTFOLIO"
crumbs={[
{ href: "/", label: "خانه" },
{ href: "/portfolio", label: "نمونه کارها" },
]}
/>
<section className="section">
<div className="container">
{result.items.length === 0 ? (
<p className="comment-empty">نمونه کاری برای نمایش وجود ندارد.</p>
) : (
<div className="catalog-grid">
{result.items.map((item) => {
const title = portfolioTitle(item);
return (
<article key={item.id} className="catalog-card">
<Link href={portfolioHref(item)} className="catalog-card-media">
<SlotImage src={portfolioImage(item)} alt={title} fill />
</Link>
<div className="catalog-card-body">
<h2>
<Link href={portfolioHref(item)}>{title}</Link>
</h2>
</div>
</article>
);
})}
</div>
)}
{pageCount > 1 ? (
<nav className="pagination" aria-label="صفحه‌بندی نمونه کارها">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
if (pageCount > 12 && Math.abs(number - page) > 3 && number !== 1 && number !== pageCount) {
return number === 2 || number === pageCount - 1 ? <span key={number}>…</span> : null;
}
return (
<Link
key={number}
href={number === 1 ? "/portfolio" : `/portfolio?page=${number}`}
className={number === page ? "active" : undefined}
>
{number}
</Link>
);
})}
</nav>
) : null}
</div>
</section>
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import SlotImage from "@/components/SlotImage";
import { plainText, sanitizeHtml } from "@/lib/format";
import {
fetchPortfolioById,
portfolioHref,
portfolioImage,
portfolioTitle,
} from "@/lib/portfolios";
import { pageMetadata } from "@/lib/seo";
import { slugifyForUrl } from "@/lib/slug";
export const revalidate = 60;
type Params = { id: string; slug: string };
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { id } = await params;
const item = await fetchPortfolioById(id);
if (!item) {
return {
title: "نمونه کار یافت نشد",
description: "این نمونه کار در گالری پنجره فرخ موجود نیست.",
robots: { index: false },
};
}
const title = portfolioTitle(item);
return pageMetadata({
title,
description: item.abstract?.trim() || plainText(item.mainTextHtml).slice(0, 160) || `${title} | نمونه کارهای پنجره فرخ`,
path: portfolioHref(item),
image: portfolioImage(item),
});
}
export default async function PortfolioDetailPage({ params }: { params: Promise<Params> }) {
const { id, slug } = await params;
const item = await fetchPortfolioById(id);
if (!item) notFound();
const title = portfolioTitle(item);
if (decodeURIComponent(slug) !== slugifyForUrl(title)) {
redirect(portfolioHref(item));
}
const html = sanitizeHtml(item.mainTextHtml);
return (
<>
<section className="page-hero">
<div className="container page-hero-inner">
<div className="page-hero-en">
<span>PORTFOLIO</span>
<i />
</div>
<div className="page-hero-fa">
<nav className="crumbs">
<Link href="/">خانه</Link>
{" > "}
<Link href="/portfolio">نمونه کارها</Link>
{" > "}
<span>{title}</span>
</nav>
<h1>{title}</h1>
</div>
</div>
</section>
<section className="section">
<div className="container detail-article">
<div className="article-cover">
<SlotImage src={portfolioImage(item)} alt={title} />
</div>
{item.abstract ? <p className="detail-lead">{item.abstract}</p> : null}
{html ? <div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} /> : null}
<Link href="/portfolio" className="text-link">
بازگشت به نمونه کارها
</Link>
</div>
</section>
</>
);
}
+173
View File
@@ -0,0 +1,173 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import MediaCarousel from "@/components/MediaCarousel";
import ProductGallery from "@/components/ProductGallery";
import ProductTabs from "@/components/ProductTabs";
import { categoryHref, categoryTitle } from "@/lib/categories";
import { fetchComments } from "@/lib/comments";
import { plainText, sanitizeHtml } from "@/lib/format";
import {
fetchProductById,
fetchProducts,
fetchProductTechnicalInfo,
productGallery,
productHref,
productImage,
productTitle,
} from "@/lib/products";
import { pageMetadata } from "@/lib/seo";
import { slugifyForUrl } from "@/lib/slug";
import type { SlotMediaItem } from "@/lib/static-images";
import { rowsFromTechnicalInfo } from "@/lib/technical";
export const revalidate = 60;
type Params = { id: string; slug: string };
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { id } = await params;
const data = await fetchProductById(id);
if (!data?.product) {
return {
title: "محصول یافت نشد",
description: "این محصول در کاتالوگ پنجره فرخ موجود نیست.",
robots: { index: false },
};
}
const title = productTitle(data.product);
const description =
plainText(data.product.summary || data.product.descriptionHtml).slice(0, 160) ||
`${title} | محصولات پنجره فرخ`;
return pageMetadata({
title,
description,
path: productHref(data.product),
image: productGallery(data.product)[0],
});
}
export default async function ProductDetailPage({ params }: { params: Promise<Params> }) {
const { id, slug } = await params;
const data = await fetchProductById(id);
if (!data?.product) notFound();
const product = data.product;
const title = productTitle(product);
const canonicalSlug = slugifyForUrl(title);
if (decodeURIComponent(slug) !== canonicalSlug) {
redirect(productHref(product));
}
const [technicalInfo, comments] = await Promise.all([
fetchProductTechnicalInfo(product.id),
fetchComments("product", product.id),
]);
const descriptionHtml = sanitizeHtml(product.descriptionHtml);
const descriptionText = product.summary?.trim() || "";
let related = (data.relatedProducts ?? []).filter((item) => item.id !== product.id);
if (related.length < 8) {
const others = await fetchProducts({ pageSize: 12 });
const extra = others.items.filter(
(item) => item.id !== product.id && !related.some((row) => row.id === item.id),
);
related = [...related, ...extra];
}
related = related.slice(0, 12);
const relatedItems: SlotMediaItem[] = related.map((item) => ({
id: item.id,
image: productImage(item),
href: productHref(item),
title: productTitle(item),
kicker: item.title && item.nameFa ? item.title : item.categoryNameFa || undefined,
}));
return (
<section className="detail-page">
<div className="container">
<nav className="detail-crumbs" aria-label="مسیر صفحه">
<Link href="/">خانه</Link>
<span>/</span>
<Link href="/products">محصولات</Link>
{product.categoryId && (product.categoryNameFa || product.categoryName) ? (
<>
<span>/</span>
<Link
href={categoryHref({
id: product.categoryId,
nameFa: product.categoryNameFa,
name: product.categoryName,
})}
>
{categoryTitle({ nameFa: product.categoryNameFa, name: product.categoryName })}
</Link>
</>
) : product.categoryNameFa || product.categoryName ? (
<>
<span>/</span>
<span>{product.categoryNameFa || product.categoryName}</span>
</>
) : null}
<span>/</span>
<b>{title}</b>
</nav>
<div className="product-detail-grid">
<ProductGallery images={productGallery(product)} alt={title} />
<div className="product-detail-copy">
<p className="en">Product Details</p>
{product.categoryId && (product.categoryNameFa || product.categoryName) ? (
<p className="product-category">
<Link
href={categoryHref({
id: product.categoryId,
nameFa: product.categoryNameFa,
name: product.categoryName,
})}
>
{categoryTitle({ nameFa: product.categoryNameFa, name: product.categoryName })}
</Link>
</p>
) : product.categoryNameFa || product.categoryName ? (
<p className="product-category">{product.categoryNameFa || product.categoryName}</p>
) : null}
<h1>{title}</h1>
{product.title && product.nameFa ? <p className="en">{product.title}</p> : null}
{descriptionText && !descriptionHtml ? <p>{descriptionText}</p> : null}
<Link className="text-link" href="/contact">
تماس با ما
</Link>
</div>
</div>
<ProductTabs
descriptionHtml={descriptionHtml || undefined}
descriptionText={descriptionHtml ? undefined : descriptionText}
technicalRows={rowsFromTechnicalInfo(technicalInfo)}
comments={comments}
entityId={product.id}
/>
</div>
{relatedItems.length > 0 ? (
<section className="related-section products-section">
<div className="container">
<h2>محصولات مشابه</h2>
<p className="en">Similar Products</p>
<MediaCarousel
items={relatedItems}
variant="product"
ariaLabel="محصولات مشابه"
/>
</div>
</section>
) : null}
</section>
);
}
@@ -0,0 +1,133 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import CatalogCard from "@/components/CatalogCard";
import PageHero from "@/components/PageHero";
import {
categoryHref,
categoryTitle,
fetchCategories,
fetchCategoryById,
} from "@/lib/categories";
import { fetchProducts } from "@/lib/products";
import { pageMetadata } from "@/lib/seo";
import { slugifyForUrl } from "@/lib/slug";
export const revalidate = 60;
type Params = { id: string; slug: string };
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<Params>;
searchParams: Promise<{ page?: string }>;
}): Promise<Metadata> {
const { id } = await params;
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const category = await fetchCategoryById(id);
if (!category) {
return {
title: "دسته‌بندی یافت نشد",
description: "این دسته‌بندی در کاتالوگ پنجره فرخ موجود نیست.",
robots: { index: false },
};
}
const title = categoryTitle(category);
const description =
category.description?.trim() || `محصولات دسته ${title} در پنجره فرخ.`;
return pageMetadata({
title: page > 1 ? `${title} — صفحه ${page}` : title,
description,
path: page > 1 ? `${categoryHref(category)}?page=${page}` : categoryHref(category),
});
}
export default async function ProductCategoryPage({
params,
searchParams,
}: {
params: Promise<Params>;
searchParams: Promise<{ page?: string }>;
}) {
const { id, slug } = await params;
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const category = await fetchCategoryById(id);
if (!category) notFound();
const title = categoryTitle(category);
const canonicalSlug = slugifyForUrl(title);
if (decodeURIComponent(slug) !== canonicalSlug) {
redirect(categoryHref(category));
}
const [result, allCategories] = await Promise.all([
fetchProducts({ page, pageSize: 12, categoryId: category.id }),
fetchCategories("product"),
]);
const children = allCategories.filter((item) => item.parentId === category.id);
const pageCount = Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)));
const listHref = (number: number) =>
number === 1 ? categoryHref(category) : `${categoryHref(category)}?page=${number}`;
return (
<>
<PageHero
title={title}
en={category.name && category.nameFa ? category.name : "PRODUCTS"}
crumbs={[
{ href: "/", label: "خانه" },
{ href: "/products", label: "محصولات" },
]}
/>
<section className="section">
<div className="container">
{category.description ? <p className="detail-lead">{category.description}</p> : null}
{children.length > 0 ? (
<div className="category-pills">
{children.map((item) => (
<Link key={item.id} href={categoryHref(item)}>
{categoryTitle(item)}
</Link>
))}
</div>
) : null}
{result.items.length === 0 ? (
<p className="comment-empty">محصولی در این دسته ثبت نشده است.</p>
) : (
<div className="catalog-grid">
{result.items.map((product) => (
<CatalogCard key={product.id} product={product} />
))}
</div>
)}
{pageCount > 1 ? (
<nav className="pagination" aria-label="صفحه‌بندی دسته">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
if (pageCount > 12 && Math.abs(number - page) > 3 && number !== 1 && number !== pageCount) {
return number === 2 || number === pageCount - 1 ? (
<span key={number}>…</span>
) : null;
}
return (
<Link key={number} href={listHref(number)} className={number === page ? "active" : undefined}>
{number}
</Link>
);
})}
</nav>
) : null}
</div>
</section>
</>
);
}
+98
View File
@@ -0,0 +1,98 @@
import type { Metadata } from "next";
import Link from "next/link";
import CatalogCard from "@/components/CatalogCard";
import PageHero from "@/components/PageHero";
import { categoryHref, categoryTitle, fetchCategories } from "@/lib/categories";
import { fetchProducts } from "@/lib/products";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}): Promise<Metadata> {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
return pageMetadata({
title: page > 1 ? `محصولات — صفحه ${page}` : "محصولات",
description: "کاتالوگ پنجره UPVC، شیشه دوجداره و محصولات پنجره فرخ.",
path: page > 1 ? `/products?page=${page}` : "/products",
});
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const [result, categories] = await Promise.all([
fetchProducts({ page, pageSize: 12 }),
fetchCategories("product"),
]);
const pageCount = Math.max(1, Math.ceil((result.total || result.items.length) / (result.pageSize || 12)));
const topCategories = categories.filter((item) => !item.parentId);
return (
<>
<PageHero
title="محصولات"
en="PRODUCTS"
crumbs={[
{ href: "/", label: "خانه" },
{ href: "/products", label: "محصولات" },
]}
/>
<section className="section">
<div className="container">
{topCategories.length > 0 ? (
<div className="category-pills">
{topCategories.map((item) => (
<Link key={item.id} href={categoryHref(item)}>
{categoryTitle(item)}
</Link>
))}
</div>
) : null}
{result.items.length === 0 ? (
<p className="comment-empty">محصولی برای نمایش وجود ندارد.</p>
) : (
<div className="catalog-grid">
{result.items.map((product) => (
<CatalogCard key={product.id} product={product} />
))}
</div>
)}
{pageCount > 1 ? (
<nav className="pagination" aria-label="صفحه‌بندی محصولات">
{Array.from({ length: pageCount }, (_, index) => {
const number = index + 1;
if (pageCount > 12 && Math.abs(number - page) > 3 && number !== 1 && number !== pageCount) {
return number === 2 || number === pageCount - 1 ? (
<span key={number}>…</span>
) : null;
}
return (
<Link
key={number}
href={number === 1 ? "/products" : `/products?page=${number}`}
className={number === page ? "active" : undefined}
>
{number}
</Link>
);
})}
</nav>
) : null}
</div>
</section>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
import ComingSoon from "@/components/ComingSoon";
import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata({
title: "ویدئو",
description: "ویدئوهای پنجره فرخ به‌زودی از پنل مشکی خوانده می‌شود.",
path: "/videos",
});
export default function VideosPage() {
return (
<ComingSoon
title="ویدئو"
en="VIDEOS"
text="ویدئوها در مرحله بعد از بک‌اند مشکی نمایش داده می‌شوند."
/>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { businessDashboardUrl, customerPortalUrl } from "@/lib/config";
import { useSession, userDisplayName } from "@/lib/session";
function UserIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="1.75" />
<path
d="M5.5 19.25c1.4-3 3.8-4.5 6.5-4.5s5.1 1.5 6.5 4.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="10"
height="10"
viewBox="0 0 12 12"
fill="none"
aria-hidden
className={open ? "account-menu__caret open" : "account-menu__caret"}
>
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export default function AccountMenu() {
const { user, isAuthenticated, isWebsiteAdmin, logout, getAccessToken } = useSession();
const [open, setOpen] = useState(false);
const [handoffBusy, setHandoffBusy] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClickOutside = (event: Event) => {
if (menuRef.current && event.target instanceof Node && !menuRef.current.contains(event.target)) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClickOutside);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onClickOutside);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
event.preventDefault();
if (handoffBusy) return;
const popup = window.open("about:blank", "_blank");
setHandoffBusy(true);
try {
const token = getAccessToken();
const res = await fetch("/api/auth/handoff", {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!res.ok) throw new Error("handoff failed");
const data = (await res.json()) as { ticket?: string };
const url = new URL(businessDashboardUrl);
if (data.ticket) url.searchParams.set("handoff", data.ticket);
if (popup) {
popup.opener = null;
popup.location.replace(url.toString());
} else {
window.open(url.toString(), "_blank", "noopener,noreferrer");
}
} catch {
if (popup) popup.location.replace(businessDashboardUrl);
else window.open(businessDashboardUrl, "_blank", "noopener,noreferrer");
} finally {
setHandoffBusy(false);
setOpen(false);
}
}
if (!isAuthenticated || !user) {
return (
<a href={customerPortalUrl} className="header-tool header-login" aria-label="ورود">
<UserIcon />
<span>ورود</span>
</a>
);
}
return (
<div ref={menuRef} className="account-menu">
<button
type="button"
className="header-tool account-menu__trigger"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-haspopup="menu"
>
<UserIcon />
<span>{userDisplayName(user)} عزیز</span>
<ChevronIcon open={open} />
</button>
{open ? (
<div role="menu" className="account-menu__dropdown">
<a role="menuitem" href={`${customerPortalUrl}/profile`} onClick={() => setOpen(false)}>
پروفایل من
</a>
{isWebsiteAdmin ? (
<a role="menuitem" href={businessDashboardUrl} onClick={openBusinessDashboard}>
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
</a>
) : null}
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
logout();
}}
>
خروج
</button>
</div>
) : null}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useEffect, useRef } from "react";
const APARAT_HASH = "w21jpe0";
const APARAT_BOX_ID = "aparat-farrokh-w21jpe0";
export default function AparatVideo() {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
host.innerHTML = "";
const target = document.createElement("div");
target.id = APARAT_BOX_ID;
host.appendChild(target);
const script = document.createElement("script");
script.type = "text/JavaScript";
script.src = `https://www.aparat.com/embed/${APARAT_HASH}?data[rnddiv]=${APARAT_BOX_ID}&data[responsive]=yes`;
target.appendChild(script);
return () => {
host.innerHTML = "";
};
}, []);
return (
<div className="aparat-frame">
<div ref={hostRef} />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import SlotImage from "@/components/SlotImage";
import SlotLink from "@/components/SlotLink";
export default function BlogCoverCard({
href,
image,
title,
category,
date,
heading = "h3",
}: {
href?: string;
image: string;
title: string;
category?: string;
date?: string;
heading?: "h2" | "h3";
}) {
const Heading = heading;
return (
<article className="blog-card blog-card--cover">
<SlotLink href={href} className="blog-card-hit">
<SlotImage src={image} alt={title} fill />
{category ? <span className="blog-card-cat">{category}</span> : null}
<div className="blog-card-meta">
{date ? <time>{date}</time> : null}
<Heading>{title}</Heading>
</div>
</SlotLink>
</article>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import SlotImage from "@/components/SlotImage";
import { type CatalogProduct, productHref, productImage, productTitle } from "@/lib/products";
export default function CatalogCard({ product }: { product: CatalogProduct }) {
const title = productTitle(product);
return (
<article className="catalog-card">
<Link href={productHref(product)} className="catalog-card-media">
<SlotImage src={productImage(product)} alt={title} fill />
</Link>
<div className="catalog-card-body">
<h3>
<Link href={productHref(product)}>{title}</Link>
</h3>
{product.title && product.nameFa ? <p className="en">{product.title}</p> : null}
</div>
</article>
);
}
+27
View File
@@ -0,0 +1,27 @@
import PageHero from "@/components/PageHero";
type Props = {
title: string;
en: string;
text: string;
};
export default function ComingSoon({ title, en, text }: Props) {
return (
<>
<PageHero
title={title}
en={en}
crumbs={[
{ href: "/", label: "خانه" },
{ href: "#", label: title },
]}
/>
<section className="section coming-soon">
<div className="container">
<p>{text}</p>
</div>
</section>
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { FormEvent, useState } from "react";
import { customerPortalUrl } from "@/lib/config";
import { formatFaDate } from "@/lib/format";
import type { CommentItem } from "@/lib/comments";
import { useSession, userDisplayName } from "@/lib/session";
export default function CommentBox({
entityType,
entityId,
comments,
}: {
entityType: "product" | "blog";
entityId: string;
comments: CommentItem[];
}) {
const { user, isAuthenticated, ready, getAccessToken } = useSession();
const [status, setStatus] = useState<"idle" | "sending" | "ok" | "error">("idle");
const [message, setMessage] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!user) return;
const form = event.currentTarget;
const text = String(new FormData(form).get("text") || "").trim();
if (!text) return;
setStatus("sending");
setMessage("");
try {
const token = getAccessToken();
const response = await fetch("/api/comments", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
entityType,
entityId,
authorName: userDisplayName(user),
authorEmail: user.email,
text,
}),
});
if (!response.ok) {
setStatus("error");
setMessage("ارسال نظر انجام نشد. لطفاً دوباره تلاش کنید.");
return;
}
setStatus("ok");
setMessage("نظر شما ثبت شد و پس از تأیید نمایش داده می‌شود.");
form.reset();
} catch {
setStatus("error");
setMessage("ارتباط با سرور برقرار نشد.");
}
}
return (
<div className="comment-box">
{comments.length === 0 ? (
<p className="comment-empty">هنوز نظری ثبت نشده است.</p>
) : (
<div className="comment-list">
{comments.map((comment, index) => (
<article key={comment.id ?? index} className="comment-item">
<strong>{comment.authorName || "کاربر"}</strong>
{comment.createdAt ? <time>{formatFaDate(comment.createdAt)}</time> : null}
<p>{comment.text}</p>
</article>
))}
</div>
)}
{ready && !isAuthenticated ? (
<div className="comment-login">
<p>برای ثبت نظر وارد حساب کاربری شوید.</p>
<a className="btn" href={customerPortalUrl}>
ورود
</a>
</div>
) : null}
{ready && isAuthenticated ? (
<form className="comment-form" onSubmit={onSubmit}>
<label className="sr-only" htmlFor={`comment-${entityType}-${entityId}`}>
نظر شما
</label>
<textarea
id={`comment-${entityType}-${entityId}`}
name="text"
rows={4}
required
placeholder="نظر خود را بنویسید…"
/>
<button type="submit" className="btn" disabled={status === "sending"}>
{status === "sending" ? "در حال ارسال…" : "ثبت نظر"}
</button>
{message ? <p className={status === "ok" ? "ok" : "err"}>{message}</p> : null}
</form>
) : null}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { FormEvent, useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "ok" | "error">("idle");
const [message, setMessage] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
setStatus("sending");
setMessage("");
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: String(data.get("name") || ""),
title: "پیام از صفحه تماس",
email: String(data.get("email") || ""),
cellNumber: String(data.get("cellNumber") || ""),
text: String(data.get("text") || ""),
}),
});
if (!response.ok) {
setStatus("error");
setMessage("ارسال پیام انجام نشد. لطفاً دوباره تلاش کنید.");
return;
}
setStatus("ok");
setMessage("پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.");
form.reset();
} catch {
setStatus("error");
setMessage("ارتباط با سرور برقرار نشد.");
}
}
return (
<form className="contact-form" onSubmit={onSubmit}>
<p className="form-kicker">contact us</p>
<h2>ارسال پیام</h2>
<input name="name" type="text" required placeholder="* نام" />
<input name="email" type="email" required placeholder="* ایمیل" />
<input name="cellNumber" type="tel" required placeholder="شماره همراه *" />
<textarea name="text" required placeholder="متن پیام" rows={5} />
<button type="submit" className="btn" disabled={status === "sending"}>
{status === "sending" ? "در حال ارسال…" : "ارسال پیام"}
</button>
{message ? <p className={status === "ok" ? "ok" : "err"}>{message}</p> : null}
</form>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { faqs } from "@/data/site";
export default function FaqList() {
const [open, setOpen] = useState(0);
return (
<div className="faq-list">
{faqs.map((item, index) => {
const isOpen = open === index;
return (
<article key={item.q} className={`faq-item${isOpen ? " is-open" : ""}`}>
<button type="button" onClick={() => setOpen(isOpen ? -1 : index)}>
<span>{item.q}</span>
<span className="faq-icon" aria-hidden>
{isOpen ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M6 15l6-6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</button>
{isOpen ? <p>{item.a}</p> : null}
</article>
);
})}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import Image from "next/image";
import Link from "next/link";
import SlotImage from "@/components/SlotImage";
import { footerArticles, footerLinks, navLinks, site } from "@/data/site";
export default function Footer() {
return (
<footer className="site-footer">
<div className="container footer-grid">
<div>
<h2>مجموعه صنعتی پنجره فرخ</h2>
<p>{site.footerAbout}</p>
</div>
<div>
<h3>آدرس</h3>
<ul>
<li>
<a href={site.officeMap} target="_blank" rel="noopener noreferrer">
دفتر مرکزی: {site.office}
</a>
</li>
{footerLinks.map((link) => (
<li key={link.href + link.label}>
<Link href={link.href}>{link.label}</Link>
</li>
))}
</ul>
</div>
<div>
<h3>کارخانه</h3>
<ul>
<li>
<a href={site.factoryMap} target="_blank" rel="noopener noreferrer">
کارخانه: {site.factory}
</a>
</li>
<li>تولید کننده پنجره upvc</li>
</ul>
</div>
<div>
<h3>دسترسی آسان</h3>
<ul>
{footerArticles.map((link) => (
<li key={link.label}>
<Link href={link.href}>{link.label}</Link>
</li>
))}
{navLinks.slice(0, 2).map((link) => (
<li key={`quick-${link.href}`}>
<Link href={link.href}>{link.label}</Link>
</li>
))}
</ul>
</div>
</div>
<div className="footer-social">
<div className="container footer-social-inner">
<Link href="/" className="footer-logo">
<SlotImage src={site.logo} alt={site.shortName} width={140} height={46} />
</Link>
<div className="social-links">
<a href={site.social.telegram} target="_blank" rel="noopener noreferrer">
تلگرام واحد فروش
</a>
<a href={site.social.instagram} target="_blank" rel="noopener noreferrer">
اینستاگرام
</a>
<a href={site.social.whatsapp} target="_blank" rel="noopener noreferrer">
واتساپ واحد فروش
</a>
</div>
</div>
</div>
<div className="footer-bottom">
<div className="container">
<div className="footer-bottom-bar">
<div className="footer-bottom-site">{site.shortName}</div>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit"
>
<span className="meshkee-credit__frame" aria-hidden>
<span className="meshkee-credit__line meshkee-credit__line--l" />
<span className="meshkee-credit__line meshkee-credit__line--t" />
<span className="meshkee-credit__line meshkee-credit__line--r" />
<span className="meshkee-credit__line meshkee-credit__line--b" />
</span>
<Image
src="/images/footer/MeshkeeLogo-White.png"
alt="Meshkee"
className="meshkee-credit__logo"
width={42}
height={42}
/>
<span className="meshkee-credit__text">
<span className="meshkee-credit__label">Designed And Developed By</span>
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
</span>
</a>
</div>
</div>
</div>
</footer>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { glassTopics, glassTypes, qomText, upvcMakerText } from "@/data/site";
export default function GlassReadMore() {
const [open, setOpen] = useState(false);
return (
<div className="glass-readmore">
<div className={`glass-box${open ? " is-open" : ""}`}>
<p className="glass-lead">پنجره فرخ تولید کننده شیشه دوجداره</p>
<div className={`glass-ellipse${open ? "" : " is-clamped"}`}>
{glassTopics.map((topic) => (
<article key={topic.title}>
<h3>{topic.title}</h3>
<p>{topic.text}</p>
</article>
))}
<article>
<h3>انواع شیشه دو جداره</h3>
<p>پنجره‌های دو جداره به انواع مختلفی تقسیم می‌شوند که عمدتاً از ترکیب مختلف شیشه‌ها با طراحی دو جداره ساخته می‌شوند:</p>
<ul>
{glassTypes.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
<article>
<h3>تولید کننده شیشه دوجداره قم</h3>
<p>{qomText}</p>
</article>
<article>
<h3>فرخ وین، تولید کننده پنجره UPVC</h3>
<p>{upvcMakerText}</p>
</article>
</div>
</div>
<div className="glass-more-wrap">
<button type="button" className="see-more" onClick={() => setOpen((value) => !value)}>
<span>{open ? "بستن" : "مشاهده ببشتر"}</span>
<svg
className={open ? "is-open" : ""}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden
>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import AccountMenu from "@/components/AccountMenu";
import SlotImage from "@/components/SlotImage";
import { navLinks, site } from "@/data/site";
export default function Header() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [pathname]);
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [open]);
return (
<header className="site-header">
<nav className="header-bar">
<div className="container header-inner">
<Link href="/" className="brand" title={site.shortName}>
<SlotImage src={site.logo} alt={site.shortName} width={169} height={55} priority />
</Link>
<div className="header-actions">
<a className="header-phone" href={`tel:${site.phones.sales}`}>
تماس بگیرید : {site.phones.sales} - {site.phones.mobile}
</a>
<AccountMenu />
<button
type="button"
className={`push-button${open ? " is-open" : ""}`}
aria-label={open ? "بستن منو" : "باز کردن منو"}
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<span />
<span />
<span />
</button>
</div>
</div>
</nav>
<div className={`hamburger-menu${open ? " is-open" : ""}`} aria-hidden={!open}>
<button type="button" className="close-menu" aria-label="بستن منو" onClick={() => setOpen(false)}>
×
</button>
<div className="hamburger-grid">
<div className="hamburger-cover" style={{ backgroundImage: "url(/images/menu/panel.jpg)" }} />
<div className="hamburger-panel">
<ul className="menu-list">
{navLinks.map((link) => (
<li key={link.href}>
<Link href={link.href} className={pathname === link.href ? "is-active" : ""}>
{link.label}
</Link>
</li>
))}
</ul>
<div className="hamburger-meta">
<a href={site.cartUrl}>سبد خرید</a>
<AccountMenu />
</div>
</div>
</div>
</div>
</header>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import SlotImage from "@/components/SlotImage";
import type { SlotMediaItem } from "@/lib/static-images";
type Props = {
slides: SlotMediaItem[];
};
export default function HeroSlider({ slides }: Props) {
const [index, setIndex] = useState(0);
const items = slides.filter((slide) => slide.image);
useEffect(() => {
if (items.length < 2) return undefined;
const timer = window.setInterval(() => {
setIndex((current) => (current + 1) % items.length);
}, 3000);
return () => window.clearInterval(timer);
}, [items.length]);
if (!items.length) return null;
return (
<section className="hero">
{items.map((slide, slideIndex) => (
<div key={slide.id} className={`hero-slide${slideIndex === index ? " is-active" : ""}`}>
<SlotImage src={slide.image} alt={slide.title || "فرخ وین"} fill priority={slideIndex === 0} />
</div>
))}
{items.length > 1 ? (
<div className="hero-nav">
<button type="button" aria-label="اسلاید بعدی" onClick={() => setIndex((current) => (current + 1) % items.length)}>
‹
</button>
<button type="button" aria-label="اسلاید قبلی" onClick={() => setIndex((current) => (current - 1 + items.length) % items.length)}>
›
</button>
</div>
) : null}
</section>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { type CSSProperties, useEffect, useState } from "react";
import BlogCoverCard from "@/components/BlogCoverCard";
import SlotImage from "@/components/SlotImage";
import SlotLink from "@/components/SlotLink";
import type { SlotMediaItem } from "@/lib/static-images";
function perViewForWidth(width: number, max: number) {
if (width <= 760) return 1;
if (width <= 980) return Math.min(2, max);
return max;
}
export default function MediaCarousel({
items,
variant,
ariaLabel,
imageFit = "contain",
desktopPerView = 4,
}: {
items: SlotMediaItem[];
variant: "blog" | "product";
ariaLabel: string;
imageFit?: "contain" | "cover";
desktopPerView?: number;
}) {
const [index, setIndex] = useState(0);
const [perView, setPerView] = useState(desktopPerView);
const [paused, setPaused] = useState(false);
const maxIndex = Math.max(0, items.length - perView);
useEffect(() => {
function sync() {
setPerView(perViewForWidth(window.innerWidth, desktopPerView));
}
sync();
window.addEventListener("resize", sync);
return () => window.removeEventListener("resize", sync);
}, [desktopPerView]);
useEffect(() => {
setIndex((value) => Math.min(value, maxIndex));
}, [maxIndex]);
useEffect(() => {
if (items.length <= perView || paused) return undefined;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return undefined;
const timer = window.setInterval(() => {
setIndex((value) => (value >= maxIndex ? 0 : value + 1));
}, 5200);
return () => window.clearInterval(timer);
}, [items.length, maxIndex, paused, perView]);
if (items.length === 0) return null;
function go(next: number) {
setIndex(Math.min(maxIndex, Math.max(0, next)));
}
return (
<div
className={`media-carousel media-carousel--${variant}`}
style={{ "--carousel-index": index, "--carousel-per": perView } as CSSProperties}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{items.length > perView ? (
<div className={variant === "blog" ? "container" : undefined}>
<div className="media-toolbar">
<div className="media-dots" role="tablist" aria-label={ariaLabel}>
{Array.from({ length: maxIndex + 1 }, (_, page) => (
<button
key={page}
type="button"
role="tab"
aria-selected={page === index}
aria-label={`اسلاید ${page + 1}`}
className={page === index ? "media-dot active" : "media-dot"}
onClick={() => go(page)}
/>
))}
</div>
<div className="media-arrows">
<button
type="button"
className="media-arrow"
aria-label="قبلی"
onClick={() => setIndex(index <= 0 ? maxIndex : index - 1)}
>
‹
</button>
<button
type="button"
className="media-arrow"
aria-label="بعدی"
onClick={() => setIndex(index >= maxIndex ? 0 : index + 1)}
>
›
</button>
</div>
</div>
</div>
) : null}
<div className="media-viewport">
<div className="media-track">
{items.map((item) =>
variant === "blog" ? (
<BlogCoverCard
key={item.id}
href={item.href}
image={item.image}
title={item.title || "مقاله"}
category={item.kicker}
date={item.text}
/>
) : (
<article key={item.id} className="product-card">
<SlotLink href={item.href}>
<div className="product-photo">
<SlotImage src={item.image} alt={item.title || "محصول"} fill={imageFit === "cover"} />
</div>
<h3>{item.title}</h3>
{item.kicker ? <p>{item.kicker}</p> : null}
</SlotLink>
</article>
),
)}
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import Link from "next/link";
type Props = {
title: string;
en: string;
crumbs: { href: string; label: string }[];
};
export default function PageHero({ title, en, crumbs }: Props) {
return (
<section className="page-hero">
<div className="container page-hero-inner">
<div className="page-hero-en">
<span>{en}</span>
<i />
</div>
<div className="page-hero-fa">
<nav className="crumbs">
{crumbs.map((crumb, index) => (
<span key={crumb.href}>
{index > 0 ? " > " : null}
<Link href={crumb.href}>{crumb.label}</Link>
</span>
))}
</nav>
<h1>{title}</h1>
</div>
</div>
</section>
);
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useCallback, useEffect, useState, type MouseEvent } from "react";
type Props = {
images: string[];
alt: string;
};
export default function ProductGallery({ images, alt }: Props) {
const [active, setActive] = useState(0);
const [lightbox, setLightbox] = useState(false);
const [origin, setOrigin] = useState("50% 50%");
const list = images.length ? images : [];
const go = useCallback(
(delta: number) => {
if (list.length === 0) return;
setActive((value) => (value + delta + list.length) % list.length);
},
[list.length],
);
useEffect(() => {
if (!lightbox) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setLightbox(false);
if (event.key === "ArrowLeft") go(1);
if (event.key === "ArrowRight") go(-1);
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [go, lightbox]);
function updateOrigin(event: MouseEvent<HTMLButtonElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width) * 100;
const y = ((event.clientY - bounds.top) / bounds.height) * 100;
setOrigin(`${x}% ${y}%`);
}
if (list.length === 0) {
return <div className="product-gallery-empty">بدون تصویر</div>;
}
return (
<div className="product-gallery">
<button
type="button"
className="product-gallery-main"
onClick={() => setLightbox(true)}
onMouseMove={updateOrigin}
onMouseLeave={() => setOrigin("50% 50%")}
aria-label="بزرگ‌نمایی تصویر"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={list[active]} alt={alt} style={{ transformOrigin: origin }} />
</button>
{list.length > 1 ? (
<div className="product-gallery-thumbs">
{list.map((url, index) => (
<button
key={`${url}-${index}`}
type="button"
className={index === active ? "active" : undefined}
onClick={() => setActive(index)}
aria-label={`تصویر ${index + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={`${alt} — تصویر ${index + 1}`} />
</button>
))}
</div>
) : null}
{lightbox ? (
<div
className="product-lightbox"
role="dialog"
aria-modal="true"
aria-label="گالری تصاویر"
onClick={() => setLightbox(false)}
>
<button type="button" className="product-lightbox-close" aria-label="بستن">
×
</button>
{list.length > 1 ? (
<>
<button
type="button"
className="product-lightbox-nav next"
aria-label="قبلی"
onClick={(event) => {
event.stopPropagation();
go(-1);
}}
>
›
</button>
<button
type="button"
className="product-lightbox-nav prev"
aria-label="بعدی"
onClick={(event) => {
event.stopPropagation();
go(1);
}}
>
‹
</button>
</>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={list[active]} alt={alt} onClick={(event) => event.stopPropagation()} />
{list.length > 1 ? (
<p>
{active + 1} / {list.length}
</p>
) : null}
</div>
) : null}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import CommentBox from "@/components/CommentBox";
import type { CommentItem } from "@/lib/comments";
import type { TechnicalRow } from "@/lib/technical";
type TabId = "description" | "technical" | "comments";
export default function ProductTabs({
descriptionHtml,
descriptionText,
technicalRows,
comments,
entityId,
}: {
descriptionHtml?: string;
descriptionText?: string;
technicalRows: TechnicalRow[];
comments: CommentItem[];
entityId: string;
}) {
const [tab, setTab] = useState<TabId>("description");
return (
<section className="detail-tabs">
<div className="detail-tabs-nav" role="tablist" aria-label="جزئیات محصول">
<button type="button" className={tab === "description" ? "active" : undefined} onClick={() => setTab("description")}>
توضیحات
</button>
<button type="button" className={tab === "technical" ? "active" : undefined} onClick={() => setTab("technical")}>
مشخصات فنی
</button>
<button type="button" className={tab === "comments" ? "active" : undefined} onClick={() => setTab("comments")}>
نظرات
</button>
</div>
<div className="detail-tabs-body">
{tab === "description" ? (
descriptionHtml ? (
<div className="rich-text" dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
) : descriptionText ? (
<p>{descriptionText}</p>
) : (
<p className="comment-empty">توضیحاتی ثبت نشده است.</p>
)
) : null}
{tab === "technical" ? (
technicalRows.length > 0 ? (
<table className="tech-table">
<tbody>
{technicalRows.map((row) => (
<tr key={row.id}>
<th>{row.label}</th>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="comment-empty">مشخصات فنی برای این محصول ثبت نشده است.</p>
)
) : null}
{tab === "comments" ? (
<CommentBox entityType="product" entityId={entityId} comments={comments} />
) : null}
</div>
</section>
);
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { SessionProvider } from "@/lib/session";
import type { ReactNode } from "react";
export default function Providers({
businessId,
children,
}: {
businessId: string;
children: ReactNode;
}) {
return <SessionProvider businessId={businessId}>{children}</SessionProvider>;
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
type Props = {
children: ReactNode;
className?: string;
delay?: number;
stagger?: boolean;
variant?: "up" | "left" | "right";
};
export default function Reveal({
children,
className = "",
delay = 0,
stagger = false,
variant = "up",
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return undefined;
const reveal = () => setShown(true);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
reveal();
return undefined;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
reveal();
observer.disconnect();
}
},
{ threshold: 0.16, rootMargin: "0px 0px -10% 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const classes = [
"reveal",
`reveal--${variant}`,
stagger ? "stagger" : "",
shown ? "is-in" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
ref={ref}
className={classes}
style={{ "--reveal-delay": `${delay}ms` } as CSSProperties}
>
{children}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { CSSProperties } from "react";
type Props = {
src: string;
alt: string;
width?: number;
height?: number;
fill?: boolean;
className?: string;
sizes?: string;
priority?: boolean;
style?: CSSProperties;
fallbackSrc?: string;
};
export default function SlotImage({
src,
alt,
width,
height,
fill,
className,
priority,
style,
fallbackSrc,
}: Props) {
const resolved = src || fallbackSrc || "";
if (!resolved) return null;
if (fill) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolved}
alt={alt}
className={className ? `${className} slot-fill` : "slot-fill"}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", ...style }}
/>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolved}
alt={alt}
width={width}
height={height}
className={className}
style={style}
fetchPriority={priority ? "high" : undefined}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Link from "next/link";
import type { ReactNode } from "react";
function isExternal(href: string) {
return /^https?:\/\//i.test(href) || href.startsWith("//");
}
export default function SlotLink({
href,
className,
children,
}: {
href?: string;
className?: string;
children: ReactNode;
}) {
if (!href) return <>{children}</>;
if (isExternal(href)) {
return (
<a href={href} className={className} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
+246
View File
@@ -0,0 +1,246 @@
export const site = {
name: "فرخ وین | تولید کننده انواع پنجره UPVC و شیشه دو جداره",
shortName: "پنجره فرخ",
brandEn: "FARROKH WIN",
slogan: "پنجره فرخ، همیشه در رخ",
description:
"پنجره فرخ تولید کننده شیشه دوجداره، انواع پنجره های UPVC، شیشه سندبلاست یکی از بزرگترین و مورد اعتماد ترین نام ها در این صنعت می باشد.",
tagline:
"فرخ وین تولید کننده شیشه دوجداره و یکی از بزرگترین و مورد اعتماد ترین نام ها در صنعت تولید درب و پنجره upvc می باشد. چه بخواهید در و پنجره‌های خود را بازسازی کنید یا اینکه ساختمان جدیدی داشته باشیدما می توانیم در و پنجره دوجداره upvc در اشکال و استایل های مختلف را به شما ارائه دهیم تا با استفاده از این محصولات، زیبایی، راحتی و مصرف پایین انرژی را برایتان به ارمغان بیاوریم.",
footerAbout:
"یکی از بزرگترین و مورد اعتماد ترین نام ها در صنعت تولید درب و پنجره دو جداره upvc می باشد. چه بخواهید در و پنجره‌های خود را بازسازی کنید یا اینکه ساختمان جدیدی داشته باشیدما می توانیم در و پنجره دوجداره upvc در اشکال و استایل های مختلف را به شما ارائه دهیم.\nفرخ وین تولید کننده پنجره UPVC در خدمت شما مشتریان عزیز است.",
logo: "/images/logo/logo.png",
foundedYear: "۱۳۹۰",
experienceYears: "16",
projects: "97+",
workYears: "12 سال",
phones: {
sales: "02538203597",
mobile: "09002032333",
landline: "02533342698",
},
office:
"قم - بلوار امام رضا(ع) - مجتمع فردوس - واحد 138",
officeFull:
"آدرس دفتر مرکزی: قم. بلوار امام رضا(ع). مجتمع تجاری فردوس. طبقه همکف. پلاک ۱۳۸",
factory:
"قم-شهرک صنعتی شکوهیه-فاز دو-خیابان فکوری-کوچه 2-پلاک KH214",
factoryFull:
"آدرس کارخانه: قم، شهرک صنعتی شکوهیه، فاز۲، خیابان فکوری، کوچه۲، پلاک KH214",
officeMap: "https://goo.gl/maps/DZHEusDBxs7hzJGY6",
factoryMap: "https://maps.app.goo.gl/dEbhkvLPC6UPdbMo6",
social: {
instagram: "https://www.instagram.com/farrokhwin",
telegram: "https://t.me/farrokhwin",
whatsapp: "https://wa.me/989002032333",
eitaa: "https://eitaa.com/Farrokhwin",
handle: "@FARROKHWIN",
},
loginUrl: "https://customer.farrokhwin.com",
cartUrl: "https://customer.farrokhwin.com/cart",
aparatEmbed: "https://www.aparat.com/video/video/embed/videohash/w21jpe0/vt/frame",
};
export const navLinks = [
{ href: "/", label: "صفحه اصلی" },
{ href: "/products", label: "محصولات" },
{ href: "/portfolio", label: "نمونه کارها" },
{ href: "/videos", label: "ویدئو" },
{ href: "/blog", label: "مقالات" },
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "تماس با ما" },
];
export const slides = [
{ id: "1", image: "/images/hero/slide-1.jpg", title: "FARROKH WIN" },
{ id: "2", image: "/images/hero/slide-2.jpg", title: "درب و پنجره UPVC" },
{ id: "3", image: "/images/hero/slide-3.jpg", title: "شیشه دوجداره" },
];
export const windowTypes = [
{
id: "sliding",
title: "پنجره دوجداره کشویی ریلی",
text: "پنجره دوجداره کشویی ریلی یکی از قدیمی ترین و پراستفاده ترین انواع پنجره دوجداره یو پی وی سی است و افراد در فضاهای کوچک، از این مدل بازشو احساس رضایت دارند.",
image: "/images/windows/sliding.jpg",
href: "/products",
},
{
id: "tilt-turn",
title: "پنجره دوجداره دو حالته UPVC",
text: "پنجره دوجداره دو حالته UPVC برای تمامی مکان ها قابل استفاده است، چه برای محل کار چه برای محل زندگی زیبایی، کارایی، قیمت مناسب تنها بخشی از خصوصیات این پنجره هاست.",
image: "/images/windows/tilt-turn.jpg",
href: "/products",
},
{
id: "curved",
title: "پنجره دوجداره خم UPVC",
text: "پنجره دوجداره خم یکی از عناصر مهم و انتخاب خوبی است برای هر ساختمانی که ساکنان آن به امنیت و آرامش خود می اندیشند، چون دارای پروفیل بادوام، ماندگار و همچنین عایق مناسب صوتی، گرما و سرما است.",
image: "/images/windows/curved.jpg",
href: "/products",
},
{
id: "upvc",
title: "پنجره دوجداره UPVC",
text: "پنجره دوجداره UPVC آفتاب یکی از عناصر مهم و انتخاب خوبی است برای هر ساختمانی که ساکنان آن به امنیت و آرامش خود می اندیشند، پنجره دوجداره یو پی وی سی دارای پروفیل بادوام، ماندگار و همچنین عایق مناسب صوتی، گرما و سرما است.",
image: "/images/windows/upvc.jpg",
href: "/products",
},
];
export const whyText =
"گروه صنعتی پنجره فرخ با برخورداری از خط تولید کامل درب و پنجره دوجداره،شیشه های دو و چند جداره صنعتی،شیشه های سندبلاست و دکوراتیو،خط رنگ پروفیل و انواع توری پلیسه،محصولات خود را با بهترین کیفیت روانه بازار می کند.همچنین کارشناسان مجموعه،آماده ارائه خدمات مشاوره ای به صورت رایگان هستند.";
export const features = [
{ id: "1", title: "طراحی منحصر به فرد", image: "/images/features/02.png" },
{ id: "2", title: "خدمات پس از فروش", image: "/images/features/03.png" },
{ id: "3", title: "نماینده رسمی", image: "/images/features/04.png" },
{ id: "4", title: "نصب با کیفیت", image: "/images/features/05.png" },
{ id: "5", title: "تضمین کیفیت", image: "/images/features/06.png" },
{ id: "6", title: "خرید آنلاین", image: "/images/features/07.png" },
];
export const glassTopics = [
{
title: "شیشه دو جداره",
text: "شیشه دو جداره از دو یا چند لایه شیشه که موازی قرار گرفته‌اند و فاصله‌ای حدود چند میلی‌متری بین آنها وجود دارد، ساخته شده است. معمولاً فضای بین این دو لایه شیشه، توسط کارخانه تولید کننده با استفاده از گاز های بی اثر نظیر آرگون و موارد مشابه پر می‌شود. این فضا به عنوان یک عایق عمل می‌کند و از عبور صدا و تبادل حرارت بین داخل خانه و محیط خارجی جلوگیری می‌کند.",
},
{
title: "پنجره دوجداره",
text: "پنجره‌های دوجداره به عنوان یکی از انتخاب‌های محبوب در صنعت ساختمانی، از ویژگی‌ها و مزایای فراوانی برخوردارند که می‌توانند به بهبود کیفیت زندگی در فضاهای داخلی کمک کنند. با استفاده از تکنولوژی‌های مدرن و استفاده از مواد با کیفیت، پنجره‌های دوجداره به طور موثری از نفوذ حرارت و صدا جلوگیری می‌کنند. طراحی‌های مناسب این پنجره‌ها، اجازه می‌دهد تا نور طبیعی به صورت بهینه وارد فضا شود و از ایجاد رطوبت جلوگیری کند.",
},
{
title: "پروفیل دوجداره",
text: "پروفیل UPVC یک ماده پلیمری است که جزء اصلی آن پلی وینیل کلراید می‌باشد. این نوع پروفیل به دلیل ویژگی‌های فراوانی که دارد، به طور گسترده در ساخت درب و پنجره‌ها مورد استفاده قرار می‌گیرد. از مزایای این نوع پروفیل می‌توان به هزینه کم، دوام بالا، مقاومت در برابر زنگ‌زدگی و خوردگی، وزن سبک، هزینه نگهداری کم، و قابلیت تعمیر ساده اشاره نمود.",
},
{
title: "شیشه سه جداره (سکوریت و غیر سکوریت)",
text: "ضریب U برای شیشه‌های سه جداره در حدود ۰٫۵ تا ۰٫۸ W/m2K است. این مقدار نشان می‌دهد که شیشه‌های سه جداره نسبت به شیشه‌های دو جداره، عملکرد بهتری به عنوان عایق حرارتی دارند. با اینکه هزینه ساخت شیشه سه جداره بیشتر از دو جداره است، اما این نوع شیشه‌ها در خانه‌های سبز، مدرن و امروزی به کار می‌روند.",
},
{
title: "ضخامت شیشه دو جداره",
text: "در حالت استاندارد، از شیشه‌های دو جداره استفاده می‌شود که دارای جدار خارجی با ضخامت ۶ میلی متر و جدار داخلی با ضخامت ۴ میلی متر هستند. همچنین، فاصله بین دو شیشه نیز با استفاده از اسپیسر آلومینیومی به عرض ۱۲ میلی متر تعیین می‌شود. لایه‌های مختلف مورد استفاده در شیشه دو جداره دارای ضخامت‌های متفاوت باشند تا کارایی عایق صوتی بهبود یابد.",
},
];
export const glassTypes = [
"شیشه دو جداره ساده: این نوع از دو شیشه فلوت ساده ساخته شده است.",
"شیشه دو جداره رنگی: این نوع دارای یک لایه شیشه رنگی و یک لایه شیشه ساده است.",
"شیشه دو جداره رفلکس: این نوع از یک لایه شیشه رفلکس و یک لایه شیشه ساده تشکیل شده است.",
"شیشه دو جداره سکوریت: شیشه‌های سکوریت تحت فرآیندی با حرارت نزدیک به ۷۰۰ درجه سانتی گراد ساخته می‌شوند و برای مکان‌های پرخطر مورد استفاده قرار می‌گیرند.",
"شیشه دو جداره لمینت: این نوع شیشه از یک لایه شیشه لمینت در کنار یک لایه شیشه ساده یا دو لایه شیشه لمینت ساخته می‌شود و انتقال کمتر صدا و ایمنی بالا دارد.",
"شیشه دو جداره اسپایدر: شیشه‌های اسپایدر به صورت نقطه‌ای به هم متصل می‌شوند و علاوه بر شفافیت بالا، وزن مناسب و مقاومت بالایی نسبت به ضربه دارند.",
];
export const qomText =
"در شهر قم، تولید کننده‌هایی از شیشه‌های دوجداره وجود دارند که با بهره‌گیری از تکنولوژی‌های پیشرفته و استفاده از مواد با کیفیت، شیشه‌هایی با ویژگی‌های عایق حرارتی و صوتی بالا تولید می‌کنند. فرخ وین یکی از مطرح ترین تولیدکنندگان شیشه دوجداره در قم میباشد.";
export const upvcMakerText =
"تولید کنندگان پنجره UPVC از جمله مهمترین عوامل در صنعت ساختمانی هستند که با استفاده از فناوری‌های روز و مواد باکیفیت، پنجره‌هایی با عمر طولانی، عایقیت بالا، و ظاهر زیبا تولید می‌کنند. فرخ وین یکی از تولیدکنندگان پنجره upvc مطرح در کشور می باشد که با توجه به نیازهای مختلف بازار، محصولات متنوعی از جمله پنجره‌های دوجداره، سه‌جداره، شیشه سندبلاست و... ارائه می‌دهد.";
export const blogPosts = [
{
id: "2667",
title: "مقایسه جامع رنگ مخصوص و لمینت پروفیل + مزایا و معایب",
category: "پنجره UPVC",
image: "/images/blog/laminate.jpg",
href: "/blog",
},
{
id: "2666",
title: "شیشه استین گلاس (استند گلاس) چیست؟",
category: "شیشه سند بلاست",
image: "/images/blog/stained.jpg",
href: "/blog",
},
{
id: "2665",
title: "بهترین پنجره عایق صدا برای آرامش در خانه",
category: "پنجره UPVC",
image: "/images/blog/sound.jpg",
href: "/blog",
},
{
id: "2664",
title: "اسکای‌لایت (سقف شیشه‌ای) چیست؟",
category: "شیشه سند بلاست",
image: "/images/blog/placeholder.jpg",
href: "/blog",
},
];
export const homeProducts = [
{
id: "14129",
title: "پنجره upvc",
kicker: "upvc",
image: "/images/products/upvc.jpg",
href: "/products",
},
{
id: "14130",
title: "پنجره دوجدار upvc",
kicker: "winupvc",
image: "/images/products/double-glaze.jpg",
href: "/products",
},
{
id: "14131",
title: "دوجداره کشویی",
kicker: "TESTI",
image: "/images/products/sliding.jpg",
href: "/products",
},
];
export const brands = [
{ id: "wintech", title: "وین تک", image: "/images/brands/wintech.jpg" },
{ id: "pluspen", title: "پلاس پن", image: "/images/brands/pluspen.jpg" },
{ id: "hoffman", title: "وایزر وین شرکت هافمن", image: "/images/brands/hoffman.jpg" },
{ id: "kale", title: "یراق آلات کاله", image: "/images/brands/kale.jpg" },
];
export const aboutGoals = [
{
icon: "heart",
title: "طراحی عالی",
text: "کارشناسان گروه صنعتی پنجره فرخ،با تجربه بیش از 1000 پروژه انجام شده،متناسب با خواسته مشتریان و در نظر گرفتن مسائل فنی،طرح های زیبا و متنوعی ارائه می کنند.",
},
{
icon: "star",
title: "ایده های الهام بخش",
text: "این یک نوشته آزمایشی است که به طراحان و برنامه نویسان کمک میکند تا این عزیزان با بهره گیری از این نوشته تستی و آزمایشی بتوانند",
},
{
icon: "diamond",
title: "کیفیت بالا",
text: "مجموعه صنعتی فرخ با بهره گیری از ماشین آلات روز دنیا و کادر مجرب،محصولات خود را با بهترین کیفیت ممکن روانه بازار می کند.",
},
];
export const faqs = [
{
q: "منظور شما از کالا و محصول نهایی چیست؟",
a: "منظور از محصول نهایی،درب و پنجره UPVC به همراه شیشه های مربوطه و یا توری های پلیسه است.",
},
{
q: "چند نمونه از محصولات چیست؟",
a: "شرکت در زمینه درب و پنجره UPVC،شیشه های دو و سه جداره،شیشه های دکوراتیو و سندبلاست و انواع توری پلیسه محصولات خود را روانه بازار می کند.",
},
{
q: "آیا می توانم آنچه را که خریداری کرده ام را تغییر دهم؟",
a: "سفارشات تا قبل از نهایی شدن و وارد خط تولید شدن،قابل تغییر و ویرایش هستند.",
},
];
export const footerLinks = [
{ href: "/products", label: "محصولات" },
{ href: "/contact", label: "تماس با ما" },
{ href: "/about", label: "درباره ی ما" },
];
export const footerArticles = [
{ href: "/blog", label: "مقالات" },
{ href: "/blog", label: "شیشه سند بلاست" },
{ href: "/blog", label: "خرید پنجره UPVC" },
];
+108
View File
@@ -0,0 +1,108 @@
import { blogPosts } from "@/data/site";
import { formatFaDate, formatFaDateLong } from "@/lib/format";
import { meshkeeGet, meshkeeGetOrNull, tenantPath } from "@/lib/meshkee";
import { detailPath, slugifyForUrl } from "@/lib/slug";
export type BlogPost = {
id: string;
title: string;
slug?: string | null;
abstract?: string | null;
mainTextHtml?: string | null;
titleImageUrl?: string | null;
categoryId?: string | null;
categoryName?: string | null;
categoryNameFa?: string | null;
category?: string | null;
publishedAt?: string | null;
createdAt?: string | null;
commentCount?: number;
};
export type PagedBlogs = {
items: BlogPost[];
total: number;
page: number;
pageSize: number;
};
export async function fetchBlogs(params: { page?: number; pageSize?: number } = {}) {
const query = new URLSearchParams();
query.set("page", String(params.page ?? 1));
query.set("pageSize", String(params.pageSize ?? 12));
const data = await meshkeeGet<PagedBlogs>(tenantPath(`/blogs?${query.toString()}`), {
items: [],
total: 0,
page: 1,
pageSize: params.pageSize ?? 12,
});
return {
...data,
items: (data.items ?? []).map((item) => ({
...item,
id: String(item.id),
})),
};
}
export async function fetchBlogById(id: string) {
const data = await meshkeeGetOrNull<{ blog?: BlogPost }>(
tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`),
);
const blog = data?.blog;
return blog ? { ...blog, id: String(blog.id) } : null;
}
export function blogHref(post: Pick<BlogPost, "id" | "title">) {
return detailPath("/blog", post.id, post.title);
}
export function blogTitleSlug(title: string) {
return slugifyForUrl(title);
}
export function blogImage(post: BlogPost, fallback = "/images/blog/placeholder.jpg") {
return post.titleImageUrl?.trim() || fallback;
}
export function blogDate(post: BlogPost) {
return formatFaDate(post.publishedAt || post.createdAt);
}
export function blogDateLong(post: BlogPost) {
return formatFaDateLong(post.publishedAt || post.createdAt);
}
const CATEGORY_FA: Record<string, string> = {
"upvc window": "پنجره UPVC",
"sand blast": "شیشه سند بلاست",
"double glazed glass": "شیشه دوجداره",
};
export function blogCategory(
post: Pick<BlogPost, "title" | "categoryNameFa" | "categoryName" | "category">,
) {
const local = blogPosts.find((item) => item.title === post.title)?.category;
if (local) return local;
const raw = (post.categoryNameFa || post.categoryName || post.category || "").trim();
if (!raw) return "";
return CATEGORY_FA[raw.toLowerCase()] || raw;
}
export function getLocalBlogPosts(): BlogPost[] {
return blogPosts.map((post) => ({
id: post.id,
title: post.title,
slug: post.id,
abstract: post.category,
category: post.category,
titleImageUrl: post.image,
publishedAt: null,
createdAt: null,
}));
}
export function getLocalBlogPost(idOrSlug: string) {
const posts = getLocalBlogPosts();
return posts.find((post) => post.id === idOrSlug || post.slug === idOrSlug) ?? null;
}

Some files were not shown because too many files have changed in this diff Show More