Build Hediyeh Maison portfolio website

This commit is contained in:
amirhosein.ashourloo
2026-09-30 14:02:51 +03:30
parent 495ad45577
commit f77231ef8e
72 changed files with 4220 additions and 101 deletions
+375
View File
@@ -0,0 +1,375 @@
# context.md — هدیه مزون (Hediyeh Maison)
Living context file for AI assistants and future contributors. **Update this file on every
prompt whenever the project changes** (new pages, data source swaps, design decisions).
## What this project is
A Next.js rebuild of the original Laravel/Blade "Hediyeh Maison" storefront
(`../hediyeh-maison`, a Meshkee web-builder site). The original was a clothing **store**;
this rebuild repurposes the same visual identity into a **portfolio / showcase site** — no
cart, checkout, or product purchasing. Portfolio content is still local showcase data; Blog
listing/detail content is connected to the public Meshkee Website API for `hediehmezon.ir`.
While that tenant has no published articles, three clearly labelled local sample articles are
shown; they disappear automatically as soon as the CMS returns any published article.
- Language: Persian (`fa`) only, `dir="rtl"`.
- Framework: Next.js (App Router), TypeScript, Tailwind CSS v4.
- Carousel: Swiper (`swiper/react`).
- Icons: Font Awesome (`@fortawesome/react-fontawesome` + free-solid / free-regular / free-brands).
- Fonts: **Iran Yekan** (self-hosted `next/font/local`, all Persian text — body, UI, every
heading) + **Valky** (self-hosted `next/font/local`, Latin-only display face — used *only*
for the hero's "Hediyeh" / "MAISON" words). See `src/app/fonts.ts`.
## Fonts — do not use cdn.meshkee.com, do not use Google Fonts for the display face
The source theme (`hediyeh-maison/settings.php`, `metadata.php`) references
`cdn.meshkee.com/web-builder/fonts/...`. **These links are unreachable from this
environment** — confirmed by the user; don't attempt to fetch from `cdn.meshkee.com`, it
will hang/fail.
- Persian body/UI face: **Iran Yekan**, per explicit user instruction — self-hosted `.woff`
files copied from `../../raoofi/src/assets/fonts/` into `src/assets/fonts/`.
- Latin display face: the live source site's hero heading computes to
`font-family: Valky` at `font-size: 300px` (confirmed via `getComputedStyle` on
`http://hediyeh-maison.local:8000/`). The user placed `Valky Regular.ttf` in `public/` —
it now lives at `src/assets/fonts/valky-regular.ttf`, loaded via `next/font/local` as the
`valky` export (`--font-valky` / `.font-latin` utility class in `globals.css`).
**An earlier version of this site used Google's Playfair Display as a substitute — that
was wrong and was replaced.** `.font-latin` (Valky) must only wrap genuine Latin/English
copy (currently: the Hero's "Hediyeh" and "MAISON"). Every Persian heading uses plain
`font-bold`/`font-extrabold` on the default Iran Yekan body font — Valky has no Persian
glyphs, so applying it to Persian text silently falls back to a generic serif and looks
wrong. If you add new Latin display copy, use `.font-latin`; if it's Persian, don't.
## Content & asset provenance
The Blade project's theme-level static images (`hediyeh-maison/assets/img/**`) are **broken
Git LFS pointers** (never pulled — `git lfs pull` fails, git.meshkee.com unreachable from
this environment). Do not copy from `assets/img/`.
The **real, working photography** lives in `hediyeh-maison/assets/files/` (hashed filenames,
CMS-uploaded media, valid binaries). These were identified by inspecting the live rendered
DOM (`img.currentSrc`) on the local dev site (`http://hediyeh-maison.local:8000`, ports
proxied through the Blade app) and copied into `public/images/` with descriptive names.
Mapping (source hash → usage):
| Source file (`assets/files/`) | Used as |
|---|---|
| `PGnhGwcJfR.png` | `public/images/brand/logo.png` (header + footer) |
| `cYbVH0Fg4Z.png` | `public/images/brand/monogram.png` (small cursive "H" mark, unused so far) |
| `X8XbnYcoSl.jpg` | `public/images/hero/hero-1.jpg` |
| `VEs3mZuCir.jpg` | `public/images/hero/hero-2.jpg` |
| `WfD9E9TJd1.png` | `public/images/categories/trousers.png` (شلوار) |
| `DbVccNhN6N.png` | `public/images/categories/sunglasses.png` (عینک آفتابی) |
| `VEVtsmcNRu.png` | `public/images/categories/coat.png` (پالتو) |
| `PzlA8CwvA8.png` | `public/images/categories/hoodie.png` (هودی) |
| `G6U04V8fhX.png` | `public/images/categories/scarf.png` (شال و روسری) |
| `ptZvorsSiP.jpg` | `public/images/banners/banner-bags.jpg` (کیف editorial banner) |
| `o8x3phkJEQ.jpg` | `public/images/banners/banner-clothing.jpg` (لباس editorial banner) |
| `1L3fCdTL2g.png` | `public/images/about/about-image.png` |
| `7xIvsBoGth.png` | `public/images/about/signature.png` |
| `uT9b3ZoiSs.jpg` | `public/images/about/storefront.jpg` (⚠ shows a *different* real shop's signage — "UTERO" — currently unused in any page; do not display it as Hediyeh Maison's own storefront) |
| `2SLidM7KQV.jpg`, `5vCbHMQbmt.jpg`, `6ncMx3U2nt.jpg` | `public/images/interior/*.jpg` (boutique interior shots, reused across about/portfolio/blog) |
| `pHcCJphsTp.png`, `oryXTQOfe0.png`, `swJLLftw08.png`, `1t8I5IZoob.png`, `kTwhmXLhxe.png` | `public/images/portfolio/*-alt.png` (duplicate product angles, used as extra portfolio/blog imagery) |
Several other files in `assets/files/` are unrelated demo leftovers (cake/bakery photos,
duplicate wavy-pattern subheader banners with baked-in **English** copy like "About Us",
"Contact Us") and were intentionally **not** used — see next section.
### Subheader banners were rebuilt as SVG, not reused as images
The source site's inner-page hero banners (`assets/files/5qiwBQYHMA.jpg` and several
duplicates) are a cream background with a wavy line texture **and English text baked into
the raster** ("About Us", "Contact Us", "Products", "Articles", "Article Details",
"Product Details"). Since this site is Persian/RTL with different headings, reusing those
images verbatim would show mismatched/wrong-language text. Instead, `WavePattern.tsx`
recreates the line texture as inline SVG, composed in `PageHero.tsx` with our own Persian
`<h1>`. This is the pattern used for About/Portfolio/Blog/Contact hero banners.
### Hero — rebuilt to match the live source structure exactly
An earlier version of the hero was a loose reinterpretation (wrong font, a single centered
tagline + invented CTA button, MAISON pinned to the bottom, opacity-faded giant text) and
the user flagged it as "nothing like the original." It was rebuilt from the *actual* live
DOM of `http://hediyeh-maison.local:8000/` (`.modern-slider-item__*` classes), read via
`getComputedStyle`/`getBoundingClientRect`, not from a screenshot guess. Ground truth:
- Giant background word: `<h2 class="modern-slider-item__main-heading">`, `font-family:
Valky`, `font-size: 300px`, `color: rgb(180,144,118)` (= `--color-primary`, **full
opacity**, not faded) — full-bleed, allowed to overflow both edges of the viewport since
it's wider than the container. `Hero.tsx` reproduces this as an absolutely-positioned
`.font-latin` span using responsive fixed sizes and `text-primary` (no `/opacity`). The
current compact desktop direction caps it at 220px/240px rather than the source's 300px.
- "MAISON": also Valky, white, `font-size: 48px` (≈16% of the main heading), positioned
around **64% down the photo**, not flush to the bottom.
- The arch photo's real aspect ratio is `480:820` (tall/narrow, ≈0.585), not a generic
3:4 crop — `Hero.tsx` uses `aspect-[480/820]`.
- The two Persian white pill labels that flanked the central image were removed per the
current design direction; do not restore them unless explicitly requested.
- There is **no CTA button** in the source hero — the earlier "مشاهده نمونه‌کارها" button
was invented and has been removed; portfolio access stays in the nav.
- `hero-2.jpg` (`VEs3mZuCir.jpg`) is a genuinely black-and-white editorial photo in the
source data, not a bug — don't "fix" it to color.
- A small `.modern-slider-item__circle` decorative badge (~117×117px, top-right of the
photo) exists in the source but its image asset is itself a broken/404 link even on the
live site — intentionally not reproduced.
If the hero still looks off, re-diff against the live site with the same
`getComputedStyle`/DOM-read approach rather than eyeballing a screenshot — the CMS positions
things with absolute px/percent values that are easy to misjudge visually.
**Round 2 fixes** (user supplied a screenshot of the real source hero at a wide desktop
viewport and it still looked "nothing like" this site — correctly, two things were still
wrong):
1. **Missing outline ring.** The source photo has a second, larger arch-shaped thin outline
offset outward around it (visible clearly at the top and, more, below the photo's own
bottom edge). This isn't reproducible from the source DOM (the one plausible source
element, `.modern-slider-item__circle`, is a small unrelated ~117×117 badge whose own
image 404s even on the live site) — it's rebuilt directly as a CSS decoration: an
`aria-hidden` absolutely-positioned sibling div, same arch shape, offset via negative
inset (`-inset-3 -bottom-6` scaling up to `-inset-5 -bottom-12` at `lg`), `border
border-primary/45`.
2. **Photo/text sized like the 753px-wide measurement, everywhere.** The first pass
measured the source at a narrow ~753px pane and used those proportions (photo capped
~260px, text via `vw`) at *every* viewport. But the source uses **fixed, non-fluid**
px values above some breakpoint — re-measuring the source at 1585px showed the image at
its **native 480×820**, not scaled down. The current compact implementation steps the
photo width through `42vw` → `300px` → `340px` → `380px`. The slide now uses normal-flow
top/bottom padding around the artwork, so its height follows `slot.aspectRatio` instead of
relying on hard-coded slide heights. This keeps the image close to the header and the
pagination close to the image even if the CMS ratio changes.
Also: a stray `max-w-[210px]` on the photo wrapper was
accidentally left active at *every* breakpoint (Tailwind doesn't auto-clear an
unprefixed utility at larger breakpoints — a later `lg:w-[380px]` does not remove an
earlier bare `max-w-[210px]`), silently capping the photo at 210px regardless of
viewport. Watch for this pattern elsewhere: any bare (unprefixed) sizing utility mixed
with responsive overrides needs an explicit `sm:max-w-none`-style reset, or just don't
set it unprefixed at all.
**Round 3 fix (exact source values, read via `getComputedStyle`)**: the photo `img` has
`border-radius: 1000px` on *all* corners (a full pill — rounded bottom too, not just the
top arch), and the outline ring is `.modern-slider-item__image::before` with
`border-radius: 1000px`, `1px solid rgb(199,189,182)` (`#c7bdb6`), `inset: -20px` uniformly
(same offset at the bottom as the top — an earlier version wrongly extended it lower).
`Hero.tsx` now uses `rounded-[1000px]` on both the photo and the ring, ring color
`border-[#c7bdb6]`.
When verifying the hero visually, take the screenshot at a genuinely wide viewport
(`resize_window` with an explicit `width`/`height`, e.g. 1280×760+ — not the Browser pane's
default responsive size, which is much narrower and misrepresents how the fixed-px hero
reads at real desktop widths) — and take it twice if the first one looks blank/broken,
since this pane occasionally serves a stale/mid-transition frame during Swiper autoplay.
### Meshkee footer credit (bottom bar)
Per user instruction, the footer's bottom row matches **https://safeteb.com/** exactly:
site name on one side, a `meshkee-credit` link (hover border animation, Meshkee logo,
"Designed And Developed By" / team name) on the other, linking to `https://meshkee.com`.
CSS was extracted live from safeteb.com and ported into `globals.css`
(`.footer-bottom-bar`, `.meshkee-credit*` rules). The logo asset was downloaded from
`https://safeteb.com/images/footer/MeshkeeLogo-White.png` →
`public/images/brand/meshkee-logo-white.png`. Implementation: `components/layout/Footer.tsx`.
### Real site content vs. placeholder data
The source Blade site (`hediyeh-maison/custom/fa/pages.php`,
`frames/*/config.php`) is mostly the **Meshkee web-builder demo/default theme** — most
config-level text is literal placeholder ("متن تستی", lorem ipsum, fake nav items like
"صفحه ی آماده 1..29"). The actually meaningful content came from what renders live on the
dev server (DB-driven overrides), captured via browser inspection:
- Home: hero taglines, feature strip (support/variety/secure payment — adapted, see below),
5 category tiles (شلوار/عینک آفتابی/پالتو/هودی/شال و روسری with real counts), 2 editorial
banners (کیف/لباس), newsletter section.
- About: founded year 1986, 3× 95% stats, tagline "از طبیعت، پاستیل رنگی و فعالیت‌های روزانه
الهام گرفته‌ایم."
- Contact: address "قم، سالاریه، میدان پیچک", phone `09123456789`, email `name@info.com`
(footer's placeholder address differed from the contact page's; the contact page's address
was kept as the single canonical one across footer + contact, per normal practice —
see `lib/site-config.ts`).
- Articles (`/articles`) page could not be inspected — it errors locally (TLS error calling
`www.meshkee.ir/api/v1/get-articles`). **Per explicit user instruction, blog and portfolio
card designs are original work**, not cloned from a live reference. Blog data now comes
from Meshkee through `src/lib/meshkee.ts`; product showcase data remains in
`data/portfolio.ts`.
### Deliberate content adaptations (store → portfolio)
Since the site is no longer a store:
- Nav "محصولات" (Products) → "نمونه‌کارها" (Portfolio); "مقالات" kept as Blog.
- Feature strip's "اکنون بخرید، بعدا پرداخت کنید" (buy-now-pay-later) dropped — replaced with
"مشاوره تخصصی" (expert consultation), keeping support + variety as-is.
- Category tile counts relabeled "XX مورد" → "XX نمونه‌کار".
- Editorial banner CTAs "خرید" / "همین حالا خرید کنید" → "مشاهده نمونه‌کارها".
- Newsletter/"club" CTA "عضویت" kept (generic, still applies to a portfolio site).
- Newsletter remains client-side only. Contact submissions are proxied server-side by
`src/app/api/contact/route.ts` to
`POST /tenants/{domain}/contact-submissions`, with validation and reset/success states.
## Design tokens (`src/app/globals.css`, `@theme` block)
`--color-primary`/`secondary`/`tertiary` come from the original theme's `settings.php`.
`--color-cream` is **not** a guess — it's the live-computed background color of the source
hero section (`getComputedStyle` → `rgb(226,219,214)`), so this now matches the original's
actual muted taupe-grey rather than a warmer generic "cream":
```
--color-cream: #e2dbd6 body background (source hero bg, computed live)
--color-cream-deep: #d5c9c0 section/footer background (darker shade of the above)
--color-primary: #b49076 CTA, accents, big display text (source primaryColor)
--color-primary-dark: #8f6f58 hover state
--color-secondary: #8fa06c sparse accent (source secondaryColor, muted)
--color-tertiary: #2a9fc9 sparse accent (source tertiaryColor, muted)
--color-ink: #241f1a body text
--color-ink-soft: #6c6259 muted text
--color-slate: #5c6873 one-off accent — the source hero's right-side tagline text
color (computed live), used only there
--color-border: #d3c5bb
```
## Page inventory
| Route | Notes |
|---|---|
| `/` | Hero (Swiper), feature strip, category circles, editorial banners, newsletter |
| `/portfolios` | Portfolio-backed products/sample works. Sidebar category list + 3-col card grid, reads `?category=` |
| `/portfolios/[id]/[titleFaSlug]` | Canonical portfolio detail path via `portfolios/by-id/{id}` with local fallback while CMS is empty; stale title slugs redirect; right-side interactive gallery with a large image, clickable thumbnails, slide navigation, hover zoom and an accessible full-screen viewer |
| `/blog` | Real Meshkee CMS articles; temporary sample cards only while the API list is empty |
| `/blog/[id]/[titleSlug]` | CMS detail from `blogs/by-id/{id}`; redirects a stale title slug to its canonical path; accessible breadcrumb, rich text/tables and sticky related accordion |
| `/about` | Founded-year stat, story, 3 stats, closing image band |
| `/contact` | Info list, embedded Google Maps iframe (no API key needed, `output=embed`), contact form |
## Meshkee Website API integration
Per `https://api.meshkee.com/docs/website/AI_PROMPT.md` (the Meshkee Website API brief —
read this before touching data fetching):
- Canonical tenant domain: `hediehmezon.ir`; `GET /tenants/hediehmezon.ir` resolves to
business id `50` and confirms the `blog` module is enabled. Override locally with
`MESHKEE_WEBSITE_DOMAIN` when necessary.
- Home static media is fetched once by `src/app/page.tsx` through
`GET /tenants/{websiteDomain()}/website/static-images?pageKey=home`. It always uses the
project's central `websiteDomain()` resolver—never a second/hard-coded tenant. The page
matches `slider`, `categories`, and `two-banner-below-slider`; list slots render every
image and single slots render only `images[0]`. Empty slots, API errors, or missing keys
retain the existing local imagery. `url` is rendered as native `<img src>`, `linkUrl`
wraps the item in `<a>`, and the API `aspectRatio` controls the media box.
- `GET /meshkee/static-image-slots` publishes the dashboard catalog: duplicatable `slider`,
duplicatable `categories`, and a two-item `two-banner-below-slider`. **Whenever a static
media box's rendered size or aspect ratio changes, update this catalog in the same change**
(`aspectRatio` and, when relevant, `recommendedWidth` / `itemCount`). Never invent upload
or dashboard write APIs.
- `src/lib/meshkee.ts` owns server-only blog reads. It resolves the tenant first, then uses
`GET /tenants/{domain}/blogs` and `GET /tenants/{domain}/blogs/by-id/{id}` with five-minute
Next data-cache revalidation. Detail pages honor API 404 via Next `notFound()`, emit CMS
`schema.jsonLd`, and prefer `seoMetaTitle` / `seoMetaDescription`.
- Related articles are fetched with the current article's `categoryId`, current id excluded,
and at most five items rendered. The native `<details open>` accordion avoids shipping a
Client Component solely for collapse behavior.
- `src/data/fallback-blogs.ts` contains explicitly labelled sample copy because the tenant had
zero published articles on 2026-09-29. `/blog` uses it only when the real list is empty;
detail fallback ids are also disabled as soon as the CMS list becomes non-empty.
- `DetailPageShell.tsx` is shared by blog and product canonical detail pages; do not re-create
detail-page max width, horizontal padding, vertical padding, or breadcrumb placement inside
individual detail pages.
- CMS rich text uses `.cms-rich-text` in `globals.css`. `normalizeArticleHtml()` supplies a
meaningful fallback alt to CMS images and wraps every incoming `<table>` in its own focusable
`.cms-table-scroll` region. The wrapper—not the article/page—owns horizontal scrolling.
- `data/portfolio.ts` remains temporary local presentation data while this tenant's public
portfolio list is empty. These visible "products" are portfolio/sample-work records, not
store products; their public URLs follow `/portfolios/{id}/{titleFaSlug}`.
- Portfolio detail imagery is rendered by the isolated client component
`components/portfolio/PortfolioGallery.tsx`; the page and CMS fetching remain server-side.
It consumes every unique URL assembled from the portfolio's title image, thumbnail, legacy
image and `images[]`, shows one large image with a thumbnail rail when more than one image
exists, supports Swiper drag/keyboard controls, pointer-position hover zoom, and an accessible
full-screen viewer. Do not return to the old two-column grid of equally sized images.
- The detail page's “محصولات دیگر” row is `RelatedPortfolioCarousel.tsx`: two cards on mobile,
three on tablet and five on desktop, with touch/keyboard dragging and clickable pagination.
CMS-backed pages fetch the current category dynamically from `GET /portfolios` and exclude
the current record; when that category has too few results they append unique items from the
full portfolio list. This is data-driven—do not restore a fixed four-card grid or hard-code
future CMS products.
- This is explicitly **not an e-commerce site** — do not add local login/register/cart/checkout.
Shared customer authentication is still reflected in the header from the parent-domain
Meshkee cookies; the actual login/profile UI remains on `customer.hediehmezon.ir`.
- `src/lib/auth.ts` reconstructs normal or chunked `meshkee_customer_access_token` cookies.
`GET /api/auth/session` calls official `GET /auth/me` server-side and returns only the name
and admin flag required by the header. Guests link to `customer.hediehmezon.ir/login`.
Logged-in users see “{name} عزیز” with profile and logout; admin/owner roles also see
`business.hediehmezon.ir`. `POST /api/auth/logout` only expires shared access/refresh cookies.
- `next.config.ts` already sets `output: "standalone"` per the brief's mandatory production
runtime rule.
- Do not add local `public/sitemap.xml`, `public/robots.txt`, `app/sitemap.ts`, or
`app/robots.ts` — Meshkee nginx proxies these.
- `scripts/generate-sitemap-config.mjs` scans `src/app` before `dev` and `build`, excludes
dynamic/private/auth/cart/checkout/admin routes, and publishes
`public/meshkee/sitemap-config.json`. Keep `templates` absent while canonical detail paths
match Meshkee defaults.
- `src/lib/paths.ts` is the single source for Persian-preserving canonical slug generation.
- Every public page has a unique title/description/canonical URL and exactly one visible H1.
- The remaining site-wide integration is Organization/LocalBusiness JSON-LD from
`business-info.schema.jsonLd` in the root layout, and per-page SEO meta overrides
(`seoMetaTitle`/`seoMetaDescription`) per the brief.
## Running locally
```bash
npm run dev # Next dev server (Turbopack)
npm run build # regenerates sitemap-config, then creates the standalone production build
```
A `.claude/launch.json` exists at the `hediyeh-mason/` repo root (one level up) to preview
this app via the `hediyeh-maison-website` config (`npm --prefix website run dev`).
## Product & article cards (source-derived)
The visible products are sample works backed by the Meshkee portfolio module. Until portfolio
records are published, the existing cards use local presentation data under the canonical
`/portfolios/{id}/{titleFaSlug}` path.
**Never write "نمونه‌کار" in the UI** — categories/counts say "محصول" ("۲۶۵ محصول"), nav says
"محصولات". Cards were rebuilt from the source theme's blade + CSS (the live `/products` and
`/articles` pages error locally, so read `frames/product_show` / `frames/article_show` and
`assets/css/theme7b30.css`):
- `PortfolioCard`: `.product-grid-item` — portrait image (`padding-bottom:122.6%`, square
corners), second image cross-fades on hover, hover "مشاهده محصول" button, 18px title, Latin
name (`titleEn`), category tag. No price/rating/cart. `PortfolioGrid` = sidebar category list
(counts) + 3-col grid, like the source's `col-lg-3` filter + `col-lg-9` grid.
- `BlogCard`: `.modern-blog__post` — 15px-radius image (`padding-bottom:84%`), bottom black
gradient, `category / date` meta + white title overlaid at the bottom. No excerpt/read-time.
## Home sections — source-accurate details (round 4)
The **page background is white** (`body` has no background in the source theme); only the
hero (`#e2dbd6`), inner-page subheaders and the category pills use the taupe. Newsletter band
is `#fafafa`, footer has no background + `border-top:#dedede`. Header is taupe over the home
hero (until scrolled) and white elsewhere.
- **Mobile header/navigation**: the header is 64px tall (`h-16`); the logo sits left and the
hamburger/login controls sit right, with the hamburger first from the left. Navigation
opens as an animated white drawer from the right over a blurred overlay. The active route
uses `bg-cream-deep/50`, not the full-strength cream-deep background.
- **Feature strip** (`.features--modern`): 3 items spread in one row (first flush start, last
flush end), primary-colored 36px icon, 22px title, and **no vertical dividers**. The next
category section owns the shared hairline divider at every breakpoint.
- **Categories** (`CategoryCircles`): a real Swiper carousel with 2 slides per view below
640px, 3 from 640px, and 5 from 1024px. It supports touch/drag and keyboard navigation.
Mobile/tablet show clickable pagination dots below the cards; desktop hides pagination
because all five cards fit. Preserve the category artwork's native `272:350` ratio and keep
labels in normal flow below it—do not compress the art or pull labels upward with negative
margins. This section owns the divider above it (visible at every breakpoint) and keeps
responsive top padding on a wrapper outside Swiper between that divider and the artwork;
do not put this spacing on `.swiper` itself because Swiper's own CSS resets it. CMS-backed
category cards use `images[].titleFa` beneath the image and omit the local product count.
Titles keep the source's literal tatweel ("شلــــــــــوار", "پالتــــــــــو",
"هـــــــــــودی") — no CSS dash. Product PNGs already contain their own pill background,
so they're rendered directly (no wrapper background, or you get a double ring).
- **Banners** (`.modern-banners`): **two banners side by side** (`col-lg-6`), 15px radius, image
ratio 84.5%, centered content: 100px title, 370px description, 34px underline under the link.
Copy: "کیف / شیک و مجلسی، فقط برای شما" and "لباس / پوشاک، کفش، کیف و لوازم جانبی، اقلام
ضروری برای این فصل". CMS-backed banners use `titleFa`, `titleEn`, and `subtext` and adopt
the slot's API-provided aspect ratio.
+29 -1
View File
@@ -1,7 +1,35 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
async redirects() {
return [
{
source: "/portfolio/:path*",
destination: "/portfolios",
permanent: true,
},
{
source: "/products/:path*",
destination: "/portfolios",
permanent: true,
},
];
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
pathname: "/**",
},
{
protocol: "https",
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
pathname: "/**",
},
],
},
};
export default nextConfig;
+96 -1
View File
@@ -8,9 +8,15 @@
"name": "website",
"version": "0.1.0",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"swiper": "^14.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -453,6 +459,76 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz",
"integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.1.tgz",
"integrity": "sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.3.1.tgz",
"integrity": "sha512-8eiNzycuFhy3mZsi5EVO8JpO3H8l7/kSCl4Gk4iUew9hJuzYFqAAD6kqgeEup3+YZ9hL+5cjjorGzrRLkYuURg==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.3.1.tgz",
"integrity": "sha512-q1EsmL7Q8DDnkRBUjSvrxbq7c9oVwwVjCn/xa5apKmdp65YSgzUg2y0Ltnd5aDbT6GdAQQdXql5Ha90ArqIReQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
"integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.5.0.tgz",
"integrity": "sha512-63mlRr6fiBbJ0wjr1Cf6dsDGtP2lNvk9lnatKgxs/fIkhslsZT291hIUzJkuUkI9yr69ZvWnWfgb2qXm4QyVaA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -6259,6 +6335,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swiper": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-14.2.0.tgz",
"integrity": "sha512-GsL4M9Fq7Sg22OyL3SjdNj/0bxTfIxPJJHHW394Li0u5vYtExi0a09iBUc8CuLafJi5pAlH2AHUe1PocxHl45g==",
"funding": [
{
"type": "custom",
"url": "https://sponsors.nolimits4web.com"
},
{
"type": "github",
"url": "https://github.com/sponsors/nolimits4web"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+10 -1
View File
@@ -3,15 +3,24 @@
"version": "0.1.0",
"private": true,
"scripts": {
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs",
"predev": "npm run generate:sitemap-config",
"dev": "next dev",
"prebuild": "npm run generate:sitemap-config",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8"
"react-dom": "19.2.8",
"swiper": "^14.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+30
View File
@@ -0,0 +1,30 @@
{
"baseUrl": "https://hediehmezon.ir",
"staticPages": [
{
"path": "/",
"changefreq": "daily",
"priority": 1
},
{
"path": "/about",
"changefreq": "monthly",
"priority": 0.6
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/contact",
"changefreq": "yearly",
"priority": 0.5
},
{
"path": "/portfolios",
"changefreq": "weekly",
"priority": 0.8
}
]
}
+76
View File
@@ -0,0 +1,76 @@
import { mkdir, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
const projectRoot = process.cwd();
const appRoot = path.join(projectRoot, "src", "app");
const outputDir = path.join(projectRoot, "public", "meshkee");
const outputFile = path.join(outputDir, "sitemap-config.json");
const baseUrl = `https://${(process.env.MESHKEE_WEBSITE_DOMAIN || "hediehmezon.ir")
.trim()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.split("/")[0]}`;
const excludedSegments = new Set([
"account",
"admin",
"auth",
"business",
"cart",
"checkout",
"customer",
"dashboard",
"login",
"register",
"signin",
"signup",
]);
async function collectPages(directory, segments = []) {
const entries = await readdir(directory, { withFileTypes: true });
const routes = [];
if (entries.some((entry) => entry.isFile() && /^page\.(?:js|jsx|ts|tsx)$/.test(entry.name))) {
const publicSegments = segments.filter(
(segment) => !segment.startsWith("(") && !segment.startsWith("@"),
);
const isDynamic = publicSegments.some((segment) => segment.includes("[") || segment.includes("]"));
const isExcluded = publicSegments.some((segment) => excludedSegments.has(segment.toLowerCase()));
if (!isDynamic && !isExcluded) {
routes.push(publicSegments.length === 0 ? "/" : `/${publicSegments.join("/")}`);
}
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith("_") || entry.name === "api") continue;
routes.push(...(await collectPages(path.join(directory, entry.name), [...segments, entry.name])));
}
return routes;
}
function sitemapEntry(route) {
if (route === "/") return { path: route, changefreq: "daily", priority: 1 };
if (route === "/portfolios" || route === "/blog") {
return { path: route, changefreq: "weekly", priority: 0.8 };
}
if (route === "/about") return { path: route, changefreq: "monthly", priority: 0.6 };
if (route === "/contact") return { path: route, changefreq: "yearly", priority: 0.5 };
return { path: route, changefreq: "monthly", priority: 0.6 };
}
const routes = [...new Set(await collectPages(appRoot))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b, "en");
});
const config = {
baseUrl,
staticPages: routes.map(sitemapEntry),
};
await mkdir(outputDir, { recursive: true });
await writeFile(outputFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(`Generated ${path.relative(projectRoot, outputFile)} with ${routes.length} static routes.`);
+81
View File
@@ -0,0 +1,81 @@
import Image from "next/image";
import type { Metadata } from "next";
import PageHero from "@/components/shared/PageHero";
import { siteConfig } from "@/lib/site-config";
export const metadata: Metadata = {
title: "درباره ما",
description:
"با داستان هدیه مزون، نگاه ما به طراحی لباس و روند تبدیل ایده، پارچه و رنگ‌های آرام به پوشاکی ماندگار آشنا شوید.",
alternates: { canonical: "/about" },
};
const stats = [
{ value: "۹۵٪", label: "رضایت مشتریان" },
{ value: "۹۵٪", label: "همکاری موفق" },
{ value: "۹۵٪", label: "محصول ثبت‌شده" },
];
export default function AboutPage() {
return (
<>
<PageHero
eyebrow="درباره ما"
title={siteConfig.tagline}
description={siteConfig.description}
/>
<section className="mx-auto max-w-7xl px-5 py-16 sm:px-8">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<p className="text-5xl font-bold text-primary">{stat.value}</p>
<p className="mt-2 text-sm text-ink-soft">{stat.label}</p>
</div>
))}
</div>
</section>
<section className="bg-[#f4f4f4]">
<div className="mx-auto grid min-h-[805px] max-w-[1540px] grid-cols-1 items-center gap-10 px-5 py-12 sm:px-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] lg:gap-10 lg:px-10 lg:py-5">
<div className="order-2 flex justify-center lg:order-1">
<Image
src="/images/about/about-image.png"
alt="فضای داخلی مزون هدیه"
width={604}
height={765}
sizes="(min-width: 1024px) 40vw, 90vw"
className="h-auto w-full max-w-[604px]"
/>
</div>
<div className="order-1 flex flex-col items-center text-center lg:order-2 lg:pl-10">
<div>
<h2 className="text-3xl font-normal text-primary sm:text-[42px]">مزون هدیه</h2>
<p className="mt-1 text-lg text-ink sm:text-[22px]">
لباس رویاهای خود را با مزون ما پیدا کنید
</p>
</div>
<p className="mt-14 max-w-[690px] text-[13px] leading-8 text-slate sm:text-sm sm:leading-9">
در مزون هدیه، هر لباس از یک رویا آغاز می‌شود؛ رویایی که با ظرافت، خلاقیت و توجه به
جزئیات به واقعیت تبدیل می‌کنیم. طراحی‌های ما با الهام از طبیعت، رنگ‌های آرام و سبک
زندگی امروز شکل می‌گیرند تا زیبایی و راحتی را در کنار هم تجربه کنید. از انتخاب پارچه
تا آخرین دوخت، همه‌چیز با دقت و عشق انجام می‌شود تا لباسی داشته باشید که شخصیت و
سلیقهٔ منحصربه‌فرد شما را روایت کند. باور داریم هر انتخاب زیبا می‌تواند آغاز یک حس
تازه و ماندگار باشد.
</p>
<Image
src="/images/about/signature.png"
alt="امضای هدیه مزون"
width={121}
height={55}
className="mt-14 h-auto w-[121px]"
/>
</div>
</div>
</section>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { ACCESS_COOKIE, REFRESH_COOKIE, sharedAuthCookieNames, WEBSITE_DOMAIN } from "@/lib/auth";
export async function POST(request: Request) {
const cookieStore = await cookies();
const names = new Set([
ACCESS_COOKIE,
REFRESH_COOKIE,
...sharedAuthCookieNames(cookieStore),
]);
const response = NextResponse.redirect(new URL("/", request.url), 303);
for (const name of names) {
response.cookies.set({ name, value: "", maxAge: 0, path: "/", sameSite: "lax" });
}
for (const name of names) {
response.headers.append(
"Set-Cookie",
`${name}=; Max-Age=0; Path=/; Domain=.${WEBSITE_DOMAIN}; SameSite=Lax; Secure`,
);
}
return response;
}
+35
View File
@@ -0,0 +1,35 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import {
ACCESS_COOKIE,
normalizeHeaderSession,
readChunkedCookie,
} from "@/lib/auth";
const API_BASE_URL = "https://api.meshkee.com/api/v1";
export async function GET() {
const token = readChunkedCookie(await cookies(), ACCESS_COOKIE);
if (!token) {
return NextResponse.json({ user: null }, { headers: { "Cache-Control": "no-store" } });
}
try {
const response = await fetch(`${API_BASE_URL}/auth/me`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
if (!response.ok) {
return NextResponse.json({ user: null }, { headers: { "Cache-Control": "no-store" } });
}
const user = normalizeHeaderSession(await response.json(), token);
return NextResponse.json({ user }, { headers: { "Cache-Control": "no-store" } });
} catch {
return NextResponse.json({ user: null }, { headers: { "Cache-Control": "no-store" } });
}
}
+121
View File
@@ -0,0 +1,121 @@
const API_BASE_URL = "https://api.meshkee.com/api/v1";
type ContactPayload = {
title?: unknown;
name?: unknown;
email?: unknown;
cellNumber?: unknown;
text?: unknown;
};
function cleanString(value: unknown, maxLength: number) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function resolveDomain(request: Request) {
const configuredDomain = process.env.MESHKEE_WEBSITE_DOMAIN?.trim();
if (configuredDomain) {
return configuredDomain.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0];
}
const forwardedHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = forwardedHost || request.headers.get("host") || "";
const hostname = host.split(":")[0].toLowerCase().replace(/^www\./, "");
if (!hostname || hostname === "localhost" || hostname === "127.0.0.1") {
return null;
}
return hostname;
}
export async function POST(request: Request) {
let payload: ContactPayload;
try {
payload = (await request.json()) as ContactPayload;
} catch {
return Response.json({ message: "اطلاعات فرم معتبر نیست." }, { status: 400 });
}
const title = cleanString(payload.title, 160);
const name = cleanString(payload.name, 120);
const email = cleanString(payload.email, 254);
const cellNumber = cleanString(payload.cellNumber, 30);
const text = cleanString(payload.text, 4000);
if (!title || !name || !text) {
return Response.json(
{ message: "نام، موضوع و متن پیام الزامی هستند." },
{ status: 400 },
);
}
if (email && !/^\S+@\S+\.\S+$/.test(email)) {
return Response.json({ message: "آدرس ایمیل معتبر نیست." }, { status: 400 });
}
const domain = resolveDomain(request);
if (!domain) {
return Response.json(
{
message:
"دامنهٔ سایت برای محیط توسعه تنظیم نشده است. مقدار MESHKEE_WEBSITE_DOMAIN را تنظیم کنید.",
},
{ status: 503 },
);
}
try {
const tenantResponse = await fetch(
`${API_BASE_URL}/tenants/${encodeURIComponent(domain)}`,
{ cache: "no-store" },
);
if (!tenantResponse.ok) {
return Response.json(
{ message: "اتصال سایت به سرویس پیام‌ها برقرار نشد." },
{ status: 502 },
);
}
const contactResponse = await fetch(
`${API_BASE_URL}/tenants/${encodeURIComponent(domain)}/contact-submissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
name,
...(email ? { email } : {}),
...(cellNumber ? { cellNumber } : {}),
text,
}),
cache: "no-store",
},
);
const result = (await contactResponse.json().catch(() => null)) as
| { message?: string }
| null;
if (!contactResponse.ok) {
return Response.json(
{ message: result?.message || "ارسال پیام انجام نشد. لطفاً دوباره تلاش کنید." },
{ status: contactResponse.status >= 500 ? 502 : 400 },
);
}
return Response.json(
{ message: result?.message || "پیام شما با موفقیت ارسال شد." },
{ status: 201 },
);
} catch {
return Response.json(
{ message: "در حال حاضر ارتباط با سرویس پیام‌ها ممکن نیست. لطفاً کمی بعد تلاش کنید." },
{ status: 502 },
);
}
}
+173
View File
@@ -0,0 +1,173 @@
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
import RelatedArticles from "@/components/blog/RelatedArticles";
import DetailPageShell from "@/components/shared/DetailPageShell";
import { fallbackBlogById, fallbackBlogs } from "@/data/fallback-blogs";
import {
blogAuthor,
blogTitle,
formatBlogDate,
getBlogById,
getBlogs,
getRelatedBlogs,
type MeshkeeBlog,
normalizeArticleHtml,
} from "@/lib/meshkee";
import { blogPath, decodePathSegment, slugifyPathSegment } from "@/lib/paths";
export const dynamic = "force-dynamic";
type BlogDetailProps = {
params: Promise<{ id: string; titleSlug: string }>;
};
async function getArticle(id: string): Promise<{ post: MeshkeeBlog; isFallback: boolean } | null> {
const fallbackPost = fallbackBlogById(id);
try {
const cmsPost = await getBlogById(id);
if (cmsPost) return { post: cmsPost, isFallback: false };
const { total } = await getBlogs({ page: 1, pageSize: 1 });
return total === 0 && fallbackPost ? { post: fallbackPost, isFallback: true } : null;
} catch (error) {
if (fallbackPost) return { post: fallbackPost, isFallback: true };
throw error;
}
}
function articleDescription(post: MeshkeeBlog) {
return (
post.seoMetaDescription ||
post.abstract ||
`مقاله «${blogTitle(post)}» را در مجله هدیه مزون بخوانید؛ یادداشت‌ها و راهنماهای مد، پوشاک و استایل.`
);
}
export async function generateMetadata({ params }: BlogDetailProps): Promise<Metadata> {
const { id } = await params;
const result = await getArticle(id);
if (!result) return { title: "مقاله پیدا نشد", robots: { index: false, follow: false } };
const { post } = result;
const title = post.seoMetaTitle || blogTitle(post);
const description = articleDescription(post);
const canonical = blogPath(post);
return {
title,
description,
alternates: { canonical },
openGraph: {
title,
description,
url: canonical,
type: "article",
publishedTime: post.publishedAt || undefined,
images: post.titleImageUrl ? [{ url: post.titleImageUrl, alt: blogTitle(post) }] : undefined,
},
};
}
export default async function BlogDetailPage({ params }: BlogDetailProps) {
const { id, titleSlug } = await params;
const result = await getArticle(id);
if (!result) notFound();
const { post, isFallback } = result;
const title = blogTitle(post);
const canonicalSlug = slugifyPathSegment(title);
if (slugifyPathSegment(decodePathSegment(titleSlug)) !== canonicalSlug) {
permanentRedirect(blogPath(post));
}
const date = formatBlogDate(post);
const author = blogAuthor(post);
const related = isFallback
? fallbackBlogs.filter((item) => item.id !== post.id && item.categoryId === post.categoryId).slice(0, 5)
: await getRelatedBlogs(post);
const content = normalizeArticleHtml(post.mainTextHtml || "", title);
return (
<DetailPageShell
breadcrumbs={[
{ label: "خانه", href: "/" },
{ label: "مقالات", href: "/blog" },
{ label: title },
]}
>
{post.schema?.jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(post.schema.jsonLd) }}
/>
)}
{isFallback && (
<p className="mb-7 rounded-[15px] border border-primary/35 bg-cream/35 px-5 py-4 text-sm leading-7 text-ink-soft">
این مقاله برای نمایش قالب صفحه است و با انتشار اولین مقاله در CMS، محتوای واقعی
به‌صورت خودکار جایگزین آن می‌شود.
</p>
)}
<div className="grid min-w-0 gap-10 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start">
<article className="min-w-0">
<header>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-ink-soft">
{post.categoryName && <span className="text-primary">{post.categoryName}</span>}
{date && <time dateTime={post.publishedAt || post.createdAt || undefined}>{date}</time>}
{author && <span>نویسنده: {author}</span>}
</div>
<h1 className="mt-3 break-words text-2xl font-bold leading-[1.65] text-ink sm:text-3xl lg:text-4xl">
{title}
</h1>
{post.abstract && (
<p className="mt-4 max-w-3xl text-[15px] leading-8 text-ink-soft">{post.abstract}</p>
)}
</header>
{post.titleImageUrl && (
<div className="relative mt-8 aspect-[16/9] overflow-hidden rounded-[15px] bg-cream-deep">
<Image
src={post.titleImageUrl}
alt={`تصویر مقاله ${title}`}
fill
priority
sizes="(min-width: 1024px) 850px, 100vw"
className="object-cover"
/>
</div>
)}
{content && (
<div
className="cms-rich-text mt-8 min-w-0 max-w-full"
dangerouslySetInnerHTML={{ __html: content }}
/>
)}
<div className="mt-12 border-t border-border pt-6">
<Link
href="/blog"
className="inline-flex items-center gap-2 text-sm font-bold text-primary transition-colors hover:text-primary-dark"
>
<FontAwesomeIcon icon={faArrowRight} aria-hidden="true" />
بازگشت به فهرست مقالات
</Link>
</div>
</article>
<RelatedArticles articles={related} />
</div>
</DetailPageShell>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { fallbackBlogs } from "@/data/fallback-blogs";
import { blogPath } from "@/lib/paths";
export const metadata: Metadata = {
title: "انتقال به نشانی جدید مقاله",
robots: { index: false, follow: true },
};
export default async function LegacyBlogRedirect({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const article = fallbackBlogs.find((item) => item.slug === id);
if (!article) notFound();
permanentRedirect(blogPath(article));
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import PageHero from "@/components/shared/PageHero";
import BlogCard from "@/components/blog/BlogCard";
import { fallbackBlogs } from "@/data/fallback-blogs";
import { getBlogs } from "@/lib/meshkee";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "مقالات",
description:
"مقالات هدیه مزون درباره مد، طراحی لباس، انتخاب رنگ، ست‌کردن پوشاک و پشت‌صحنه شکل‌گیری کالکشن‌های مزون را بخوانید.",
alternates: { canonical: "/blog" },
};
export default async function BlogPage() {
const { items } = await getBlogs({ page: 1, pageSize: 100 }).catch(() => ({ items: [] }));
const posts = items.length > 0 ? items : fallbackBlogs;
return (
<>
<PageHero
eyebrow="مجله هدیه"
title="مقالات و یادداشت‌ها"
description="از پشت‌صحنه‌ی طراحی تا راهنمای انتخاب و ست کردن؛ یادداشت‌های مزون را اینجا بخوانید."
/>
<section className="mx-auto max-w-7xl px-5 py-14 sm:px-8">
{items.length === 0 && (
<p className="mb-6 rounded-[15px] border border-primary/35 bg-cream/35 px-5 py-4 text-sm leading-7 text-ink-soft">
مقاله‌های زیر نمونه‌ی نمایشی هستند و پس از انتشار محتوای مزون، خودکار با مقاله‌های
اصلی جایگزین می‌شوند.
</p>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<BlogCard key={post.id} post={post} />
))}
</div>
</section>
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLocationDot, faPhone, faEnvelope } from "@fortawesome/free-solid-svg-icons";
import PageHero from "@/components/shared/PageHero";
import ContactForm from "@/components/contact/ContactForm";
import { siteConfig } from "@/lib/site-config";
export const metadata: Metadata = {
title: "تماس با ما",
description:
"برای مشاوره، همکاری یا پرسش درباره محصولات با هدیه مزون تماس بگیرید؛ آدرس، شماره تماس و فرم ارسال پیام در دسترس شماست.",
alternates: { canonical: "/contact" },
};
const infoItems = [
{ icon: faLocationDot, title: "آدرس", value: siteConfig.address },
{ icon: faPhone, title: "شماره تماس", value: siteConfig.phoneDisplay, href: `tel:${siteConfig.phone}` },
{ icon: faEnvelope, title: "ایمیل", value: siteConfig.email, href: `mailto:${siteConfig.email}` },
];
export default function ContactPage() {
return (
<>
<PageHero
eyebrow="۲۴/۷ با ما در ارتباط باشید"
title="تماس با ما"
description="دوست داریم درباره‌ی پروژه، همکاری یا هر پرسشی که دارید از شما بشنویم."
/>
<section className="mx-auto max-w-7xl px-5 py-16 sm:px-8">
<div className="grid grid-cols-1 gap-14 lg:grid-cols-2">
<div>
<h2 className="font-bold text-2xl text-ink">اطلاعات تماس</h2>
<ul className="mt-6 space-y-6">
{infoItems.map((item) => (
<li key={item.title} className="flex items-start gap-4">
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-cream-deep text-white">
<FontAwesomeIcon icon={item.icon} />
</span>
<div>
<p className="text-sm text-ink-soft">{item.title}</p>
{item.href ? (
<a href={item.href} dir="ltr" className="text-[15px] text-ink hover:text-primary">
{item.value}
</a>
) : (
<p className="text-[15px] text-ink">{item.value}</p>
)}
</div>
</li>
))}
</ul>
<div className="mt-10 overflow-hidden rounded-3xl border border-border">
<iframe
title="موقعیت هدیه مزون روی نقشه"
src={`https://www.google.com/maps?q=${encodeURIComponent(siteConfig.address)}&output=embed`}
className="h-64 w-full"
loading="lazy"
/>
</div>
</div>
<div>
<h2 className="font-bold text-2xl text-ink">به ما پیام بدهید</h2>
<p className="mt-2 text-sm text-ink-soft">فرم زیر را پر کنید تا در اسرع وقت پاسخ‌گوی شما باشیم.</p>
<div className="mt-6">
<ContactForm />
</div>
</div>
</div>
</section>
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
import localFont from "next/font/local";
/**
* Iran Yekan — Persian body/UI face used across Meshkee sites.
* Self-hosted locally; cdn.meshkee.com font links are unreachable from this environment.
*/
export const iranYekan = localFont({
src: [
{ path: "../assets/fonts/iranyekan-light.woff", weight: "300", style: "normal" },
{ path: "../assets/fonts/iranyekan-regular.woff", weight: "400", style: "normal" },
{ path: "../assets/fonts/iranyekan-bold.woff", weight: "700", style: "normal" },
{ path: "../assets/fonts/iranyekan-extrabold.woff", weight: "800", style: "normal" },
],
variable: "--font-iranyekan",
display: "swap",
});
/**
* Valky — the exact Latin display face the source site uses (settings.php lists it as an
* "EN" font, and the live hero's "Hediyeh" / "MAISON" headings compute to
* font-family: Valky). Only use this for Latin/English text — it has no Persian glyphs.
*/
export const valky = localFont({
src: "../assets/fonts/valky-regular.ttf",
variable: "--font-valky",
display: "swap",
});
+412 -18
View File
@@ -1,26 +1,420 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
@theme {
/* Brand palette — sourced from the original site's live-rendered styles
(hero section background rgb(226,219,214); primary/secondary/tertiary from settings.php) */
--color-cream: #e2dbd6;
--color-cream-deep: #d5c9c0;
--color-primary: #b49076;
--color-primary-dark: #8f6f58;
--color-secondary: #8fa06c;
--color-tertiary: #2a9fc9;
--color-ink: #241f1a;
--color-ink-soft: #6c6259;
--color-slate: #5c6873;
--color-border: #d3c5bb;
--font-latin: var(--font-valky);
--font-sans: var(--font-iranyekan);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: #fff;
color: var(--color-ink);
font-family: var(--font-iranyekan), "Tahoma", sans-serif;
}
* {
border-color: var(--color-border);
}
::selection {
background: var(--color-primary);
color: #fff;
}
:focus-visible {
outline: 2px solid var(--color-primary-dark);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Latin-only display face (Valky) — never apply to Persian text, it has no Persian glyphs */
.font-latin {
font-family: var(--font-valky), "Georgia", serif;
}
/* Kashida-style extended dash used across category / label typography on the source site */
.kashida::after {
content: "";
display: inline-block;
width: 2.4em;
height: 1px;
background: currentColor;
opacity: 0.4;
vertical-align: middle;
margin: 0 0.35em;
}
.swiper-pagination-bullet {
background: #fff !important;
opacity: 0.55 !important;
}
.swiper-pagination-bullet-active {
opacity: 1 !important;
background: var(--color-primary) !important;
}
/* Category carousel: keep pagination below the cards and visible on its white background. */
.categories-swiper {
padding-bottom: 38px !important;
}
.categories-swiper .swiper-pagination {
bottom: 4px !important;
}
.categories-swiper .swiper-pagination-bullet {
width: 8px;
height: 8px;
margin-inline: 4px !important;
background: #d5c9c0 !important;
opacity: 1 !important;
}
.categories-swiper .swiper-pagination-bullet-active {
background: var(--color-primary) !important;
}
@media (min-width: 1024px) {
.categories-swiper {
padding-bottom: 0 !important;
}
.categories-swiper .swiper-pagination {
display: none;
}
}
/* Related products: five cards on desktop, responsive slides and dots only when needed. */
.related-portfolios-swiper {
padding-bottom: 42px !important;
}
.related-portfolios-swiper .swiper-pagination {
bottom: 2px !important;
}
.related-portfolios-swiper .swiper-pagination-bullet {
width: 8px;
height: 8px;
margin-inline: 4px !important;
background: var(--color-cream-deep) !important;
opacity: 1 !important;
}
.related-portfolios-swiper .swiper-pagination-bullet-active {
background: var(--color-primary) !important;
}
/* CMS article content */
.cms-rich-text {
color: var(--color-ink-soft);
font-size: 15px;
line-height: 2.15;
overflow-wrap: anywhere;
width: 100%;
}
.cms-rich-text > :first-child {
margin-top: 0;
}
.cms-rich-text > :last-child {
margin-bottom: 0;
}
.cms-rich-text p,
.cms-rich-text ul,
.cms-rich-text ol,
.cms-rich-text blockquote,
.cms-rich-text figure,
.cms-rich-text .cms-table-scroll {
margin-block: 1.35rem;
}
.cms-rich-text h2,
.cms-rich-text h3,
.cms-rich-text h4 {
color: var(--color-ink);
font-weight: 700;
line-height: 1.65;
margin-bottom: 0.75rem;
margin-top: 2.25rem;
}
.cms-rich-text h2 {
font-size: 1.5rem;
}
.cms-rich-text h3 {
font-size: 1.25rem;
}
.cms-rich-text h4 {
font-size: 1.1rem;
}
.cms-rich-text a {
color: var(--color-primary-dark);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--color-primary) 55%, transparent);
text-underline-offset: 4px;
}
.cms-rich-text a:hover {
color: var(--color-primary);
}
.cms-rich-text ul,
.cms-rich-text ol {
padding-inline-start: 1.4rem;
}
.cms-rich-text ul {
list-style: disc;
}
.cms-rich-text ol {
list-style: decimal;
}
.cms-rich-text li + li {
margin-top: 0.45rem;
}
.cms-rich-text blockquote {
background: color-mix(in srgb, var(--color-cream) 35%, white);
border-inline-start: 3px solid var(--color-primary);
border-radius: 0 12px 12px 0;
color: var(--color-ink);
padding: 1rem 1.25rem;
}
.cms-rich-text blockquote > :first-child {
margin-top: 0;
}
.cms-rich-text blockquote > :last-child {
margin-bottom: 0;
}
.cms-rich-text img {
border-radius: 15px;
display: block;
height: auto;
max-width: 100%;
}
.cms-rich-text pre,
.cms-rich-text code {
white-space: pre-wrap;
word-break: break-word;
}
.cms-table-scroll {
border: 1px solid var(--color-border);
border-radius: 15px;
max-width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-color: var(--color-primary) transparent;
width: 100%;
}
.cms-table-scroll:focus-visible {
outline: 2px solid var(--color-primary-dark);
outline-offset: 3px;
}
.cms-table-scroll table {
border-collapse: separate;
border-spacing: 0;
color: var(--color-ink-soft);
min-width: 640px;
table-layout: auto;
width: 100%;
}
.cms-table-scroll th,
.cms-table-scroll td {
border-bottom: 1px solid var(--color-border);
padding: 0.85rem 1rem;
text-align: right;
vertical-align: middle;
white-space: nowrap;
}
.cms-table-scroll th {
background: var(--color-cream);
color: var(--color-ink);
font-weight: 700;
}
.cms-table-scroll tbody tr:nth-child(even) td {
background: color-mix(in srgb, var(--color-cream) 24%, white);
}
.cms-table-scroll tbody tr:hover td {
background: color-mix(in srgb, var(--color-cream) 48%, white);
}
.cms-table-scroll tr:last-child td {
border-bottom: 0;
}
@media (max-width: 639px) {
.cms-rich-text h2 {
font-size: 1.25rem;
}
.cms-rich-text h3 {
font-size: 1.125rem;
}
.cms-rich-text h4 {
font-size: 1rem;
}
}
/* Meshkee credit — light footer treatment */
.meshkee-credit {
align-items: center;
color: var(--color-ink-soft);
display: inline-flex;
gap: 10px;
padding: 6px 10px;
position: relative;
text-decoration: none;
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.meshkee-credit:hover {
color: var(--color-ink);
text-decoration: none;
}
.meshkee-credit__frame {
inset: 0;
pointer-events: none;
position: absolute;
}
.meshkee-credit__line {
background: var(--color-primary);
position: absolute;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.meshkee-credit__line--l {
height: 100%;
left: 0;
top: 0;
transform: scaleY(0);
transform-origin: center top;
transition-delay: 0s;
width: 1px;
}
.meshkee-credit__line--b {
bottom: 0;
height: 1px;
left: 0;
transform: scaleX(0);
transform-origin: left center;
transition-delay: 0.14s;
width: 100%;
}
.meshkee-credit__line--r {
height: 100%;
right: 0;
top: 0;
transform: scaleY(0);
transform-origin: center bottom;
transition-delay: 0.28s;
width: 1px;
}
.meshkee-credit__line--t {
height: 1px;
left: 0;
top: 0;
transform: scaleX(0);
transform-origin: right center;
transition-delay: 0.42s;
width: 100%;
}
.meshkee-credit:hover .meshkee-credit__line--l,
.meshkee-credit:hover .meshkee-credit__line--r {
transform: scaleY(1);
}
.meshkee-credit:hover .meshkee-credit__line--b,
.meshkee-credit:hover .meshkee-credit__line--t {
transform: scaleX(1);
}
.meshkee-credit:not(:hover) .meshkee-credit__line--l {
transition-delay: 0.42s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--b {
transition-delay: 0.28s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--r {
transition-delay: 0.14s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--t {
transition-delay: 0s;
}
.meshkee-credit__logo {
display: block;
flex-shrink: 0;
height: 42px;
width: auto;
aspect-ratio: 1 / 1;
object-fit: contain;
opacity: 0.7;
position: relative;
filter: invert(1);
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.meshkee-credit:hover .meshkee-credit__logo {
opacity: 1;
}
.meshkee-credit__text {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.25;
min-height: 42px;
position: relative;
}
.meshkee-credit__label {
font-size: 11px;
}
.meshkee-credit__name {
color: var(--color-ink);
font-size: 13px;
font-weight: 600;
transition: color 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.meshkee-credit:hover .meshkee-credit__name {
color: var(--color-primary-dark);
}
+21 -15
View File
@@ -1,29 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { config as faConfig } from "@fortawesome/fontawesome-svg-core";
import "@fortawesome/fontawesome-svg-core/styles.css";
import { iranYekan, valky } from "./fonts";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
faConfig.autoAddCss = false;
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL("https://hediehmezon.ir"),
title: {
default: "هدیه مزون | محصولات",
template: "%s | هدیه مزون",
},
description:
"هدیه مزون؛ خانه‌ی طراحی و محصولات مد و پوشاک. مجموعه‌ای از طرح‌ها، همکاری‌ها و یادداشت‌های ما را ببینید.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
lang="fa"
dir="rtl"
className={`${iranYekan.variable} ${valky.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col bg-white text-ink">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);
}
@@ -0,0 +1,35 @@
const slots = [
{
key: "slider",
label: "Homepage slider",
kind: "list",
pageKey: "home",
aspectRatio: "24:41",
itemCount: null,
recommendedWidth: 480,
},
{
key: "categories",
label: "Homepage categories",
kind: "list",
pageKey: "home",
aspectRatio: "272:350",
itemCount: null,
recommendedWidth: 272,
},
{
key: "two-banner-below-slider",
label: "Two banners below slider",
kind: "list",
pageKey: "home",
aspectRatio: "200:169",
itemCount: 2,
recommendedWidth: 700,
},
] as const;
export const dynamic = "force-static";
export async function GET() {
return Response.json({ slots });
}
+34 -65
View File
@@ -1,69 +1,38 @@
import Image from "next/image";
import type { Metadata } from "next";
import Hero from "@/components/home/Hero";
import FeatureStrip from "@/components/home/FeatureStrip";
import CategoryCircles from "@/components/home/CategoryCircles";
import EditorialBanners from "@/components/home/EditorialBanners";
import Newsletter from "@/components/home/Newsletter";
import { getHomeStaticImageSlots, getStaticImageSlot } from "@/lib/meshkee";
export const metadata: Metadata = {
title: "خانه مد و پوشاک | هدیه مزون",
description:
"هدیه مزون؛ مجموعه‌ای از پوشاک، اکسسوری و طراحی‌های زنانه با الهام از طبیعت، رنگ‌های آرام و سبک زندگی امروز.",
alternates: { canonical: "/" },
openGraph: {
title: "هدیه مزون؛ خانه مد و پوشاک",
description:
"محصولات و طراحی‌های هدیه مزون را ببینید و تازه‌ترین نوشته‌های مجله مد و استایل را بخوانید.",
url: "/",
images: [{ url: "/images/hero/hero-1.jpg", alt: "هدیه مزون" }],
},
};
export default async function Home() {
const staticImageSlots = await getHomeStaticImageSlots();
const sliderSlot = getStaticImageSlot(staticImageSlots, "slider");
const categoriesSlot = getStaticImageSlot(staticImageSlots, "categories");
const bannersSlot = getStaticImageSlot(staticImageSlots, "two-banner-below-slider");
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<>
<Hero slot={sliderSlot} />
<FeatureStrip />
<CategoryCircles slot={categoriesSlot} />
<EditorialBanners slot={bannersSlot} />
<Newsletter />
</>
);
}
@@ -0,0 +1,220 @@
import Link from "next/link";
import { notFound, permanentRedirect } from "next/navigation";
import type { Metadata } from "next";
import { portfolioItems, type PortfolioItem } from "@/data/portfolio";
import PortfolioGallery from "@/components/portfolio/PortfolioGallery";
import RelatedPortfolioCarousel from "@/components/portfolio/RelatedPortfolioCarousel";
import type { PortfolioCardItem } from "@/components/portfolio/PortfolioCard";
import DetailPageShell from "@/components/shared/DetailPageShell";
import {
getPortfolioById,
getPortfolios,
normalizeRichTextHtml,
type MeshkeePortfolio,
} from "@/lib/meshkee";
import { decodePathSegment, portfolioPath, slugifyPathSegment } from "@/lib/paths";
type PortfolioDetailProps = {
params: Promise<{ id: string; titleFaSlug: string }>;
};
type PortfolioResult =
| { source: "cms"; item: MeshkeePortfolio }
| { source: "fallback"; item: PortfolioItem };
export function generateStaticParams() {
return portfolioItems.map((item) => ({
id: String(item.id),
titleFaSlug: slugifyPathSegment(item.title),
}));
}
async function getPortfolio(id: string): Promise<PortfolioResult | null> {
const fallback = portfolioItems.find((item) => String(item.id) === id);
try {
const cms = await getPortfolioById(id);
if (cms) return { source: "cms", item: cms };
} catch (error) {
if (!fallback) throw error;
}
return fallback ? { source: "fallback", item: fallback } : null;
}
function itemTitle(item: MeshkeePortfolio | PortfolioItem) {
return "titleFa" in item && item.titleFa ? item.titleFa : item.title;
}
function itemDescription(result: PortfolioResult) {
if (result.source === "cms") {
return (
result.item.seoMetaDescription ||
result.item.abstract ||
`تصاویر و توضیحات نمونه‌کار «${itemTitle(result.item)}» از هدیه مزون را مشاهده کنید.`
);
}
return result.item.description;
}
function itemImages(result: PortfolioResult) {
if (result.source === "fallback") return [result.item.image, result.item.hoverImage];
const item = result.item;
return [
item.titleImageUrl,
item.thumbnailUrl,
item.image,
...(item.gallery || []).map((image) => image.url),
...(item.images || []).map((image) => image.url),
].filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index);
}
function portfolioCardItem(item: MeshkeePortfolio): PortfolioCardItem | null {
const images = [
item.titleImageUrl,
item.thumbnailUrl,
item.image,
...(item.gallery || []).map((image) => image.url),
...(item.images || []).map((image) => image.url),
].filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index);
if (!images[0]) return null;
return {
id: item.id,
title: itemTitle(item),
titleFa: item.titleFa,
titleEn: item.titleEn,
category: item.categoryName,
image: images[0],
hoverImage: images[1],
};
}
async function relatedPortfolioItems(result: PortfolioResult): Promise<PortfolioCardItem[]> {
if (result.source === "fallback") {
return portfolioItems
.filter((other) => other.id !== result.item.id && other.category === result.item.category)
.concat(portfolioItems.filter((other) => other.id !== result.item.id && other.category !== result.item.category));
}
try {
const primary = await getPortfolios({ pageSize: 20, categoryId: result.item.categoryId || undefined });
let candidates = primary.items;
if (result.item.categoryId && candidates.filter((item) => item.id !== result.item.id).length < 6) {
const all = await getPortfolios({ pageSize: 20 });
candidates = candidates.concat(all.items);
}
return candidates
.filter((item, index, items) => item.id !== result.item.id && items.findIndex((other) => other.id === item.id) === index)
.map(portfolioCardItem)
.filter((item): item is PortfolioCardItem => item !== null);
} catch {
return [];
}
}
export async function generateMetadata({ params }: PortfolioDetailProps): Promise<Metadata> {
const { id } = await params;
const result = await getPortfolio(id);
if (!result) return { title: "نمونه‌کار پیدا نشد", robots: { index: false, follow: false } };
const title = result.source === "cms" && result.item.seoMetaTitle
? result.item.seoMetaTitle
: itemTitle(result.item);
const description = itemDescription(result);
const canonical = portfolioPath(result.item);
const image = itemImages(result)[0];
return {
title,
description,
alternates: { canonical },
openGraph: {
title,
description,
url: canonical,
images: image ? [{ url: image, alt: itemTitle(result.item) }] : undefined,
},
};
}
export default async function PortfolioDetailPage({ params }: PortfolioDetailProps) {
const { id, titleFaSlug } = await params;
const result = await getPortfolio(id);
if (!result) notFound();
const { item } = result;
const title = itemTitle(item);
if (slugifyPathSegment(decodePathSegment(titleFaSlug)) !== slugifyPathSegment(title)) {
permanentRedirect(portfolioPath(item));
}
const images = itemImages(result);
const category = result.source === "cms"
? result.item.categoryName || ""
: result.item.category;
const titleEn = item.titleEn;
const content = result.source === "cms" && result.item.mainTextHtml
? normalizeRichTextHtml(result.item.mainTextHtml, `تصویر نمونه‌کار ${title}`, "جدول اطلاعات نمونه‌کار")
: "";
const related = await relatedPortfolioItems(result);
return (
<DetailPageShell
breadcrumbs={[
{ label: "خانه", href: "/" },
{ label: "نمونه‌کارها", href: "/portfolios" },
{ label: title },
]}
>
<article>
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1.08fr)_minmax(340px,0.92fr)] lg:items-start lg:gap-14 xl:gap-20">
<PortfolioGallery images={images} title={title} />
<div className="lg:sticky lg:top-28 lg:pt-6">
{category && <p className="text-sm text-primary">{category}</p>}
<h1 className="mt-2 text-3xl font-bold leading-relaxed text-ink sm:text-4xl">{title}</h1>
{titleEn && (
<p dir="ltr" className="mt-2 text-right text-base text-ink-soft">
{titleEn}
</p>
)}
<p className="mt-6 max-w-lg text-[15px] leading-8 text-ink-soft">{itemDescription(result)}</p>
<Link
href="/contact"
className="mt-8 inline-block rounded-full bg-primary px-8 py-3 text-sm text-white transition-colors hover:bg-primary-dark"
>
تماس برای اطلاعات بیشتر
</Link>
</div>
</div>
{content && (
<section className="mt-14" aria-labelledby="portfolio-description-title">
<h2 id="portfolio-description-title" className="text-2xl font-bold text-ink">
توضیحات نمونه‌کار
</h2>
<div
className="cms-rich-text mt-6 min-w-0 max-w-full"
dangerouslySetInnerHTML={{ __html: content }}
/>
</section>
)}
{related.length > 0 && (
<section className="mt-24" aria-labelledby="related-portfolios-title">
<h2 id="related-portfolios-title" className="text-2xl font-bold text-ink">
محصولات دیگر
</h2>
<div className="mt-8">
<RelatedPortfolioCarousel items={related} />
</div>
</section>
)}
</article>
</DetailPageShell>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import PageHero from "@/components/shared/PageHero";
import PortfolioGrid from "@/components/portfolio/PortfolioGrid";
export const metadata: Metadata = {
title: "محصولات و نمونه‌کارها",
description:
"مجموعه محصولات و نمونه‌کارهای هدیه مزون شامل پالتو، شلوار، هودی، شال، روسری و اکسسوری‌های منتخب را مشاهده کنید.",
alternates: { canonical: "/portfolios" },
};
type PortfoliosPageProps = {
searchParams: Promise<{ category?: string | string[] }>;
};
export default async function PortfoliosPage({ searchParams }: PortfoliosPageProps) {
const query = await searchParams;
const category = typeof query.category === "string" ? query.category : undefined;
return (
<>
<PageHero
eyebrow="نمونه‌کارها"
title="محصولات هدیه مزون"
description="مجموعه‌ای از طرح‌ها و نمونه‌کارهای مزون؛ برای اطلاعات بیشتر با ما تماس بگیرید."
/>
<section className="mx-auto max-w-7xl px-5 py-14 sm:px-8" aria-label="فهرست نمونه‌کارها">
<PortfolioGrid initialCategory={category} />
</section>
</>
);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
import Image from "next/image";
import Link from "next/link";
import { blogTitle, formatBlogDate, type MeshkeeBlog } from "@/lib/meshkee";
import { blogPath } from "@/lib/paths";
// Mirrors the source theme's `.modern-blog__post`: rounded-15 image (84% ratio) with a bottom
// gradient, and the category / date meta + white title overlaid at the bottom.
export default function BlogCard({ post }: { post: MeshkeeBlog }) {
const title = blogTitle(post);
const date = formatBlogDate(post);
return (
<Link
href={blogPath(post)}
title={title}
className="group relative block overflow-hidden rounded-[15px] bg-cream-deep"
style={{ paddingBottom: "84%" }}
>
{post.titleImageUrl && (
<Image
src={post.titleImageUrl}
alt={title}
fill
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 90vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/65 via-black/10 to-transparent" />
<div className="absolute inset-x-6 bottom-6 z-10 text-white">
<ul className="flex items-center gap-2 pb-2 text-xs sm:text-sm">
{post.categoryName && <li>{post.categoryName}</li>}
{post.categoryName && date && <li aria-hidden="true">/</li>}
{date && <li>{date}</li>}
</ul>
<h2 className="text-lg font-bold leading-8 sm:text-2xl sm:leading-[38px]">{title}</h2>
</div>
</Link>
);
}
+60
View File
@@ -0,0 +1,60 @@
import Image from "next/image";
import Link from "next/link";
import { blogTitle, formatBlogDate, type MeshkeeBlog } from "@/lib/meshkee";
import { blogPath } from "@/lib/paths";
export default function RelatedArticles({ articles }: { articles: MeshkeeBlog[] }) {
if (articles.length === 0) return null;
return (
<aside aria-label="مقالات مشابه" className="min-w-0 lg:sticky lg:top-24">
<details open className="group rounded-[15px] border border-border bg-white">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 font-bold text-ink marker:content-none [&::-webkit-details-marker]:hidden">
<span>مقالات مشابه</span>
<span
aria-hidden="true"
className="text-xl font-normal text-primary transition-transform group-open:rotate-45"
>
+
</span>
</summary>
<div className="border-t border-border px-5 py-1">
<ul className="divide-y divide-border">
{articles.map((article) => {
const title = blogTitle(article);
const date = formatBlogDate(article);
return (
<li key={article.id} className="py-4">
<Link href={blogPath(article)} className="group/item flex gap-3">
{article.titleImageUrl && (
<span className="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-cream-deep">
<Image
src={article.titleImageUrl}
alt={title}
fill
sizes="80px"
className="object-cover transition-transform duration-300 group-hover/item:scale-105"
/>
</span>
)}
<span className="min-w-0">
{article.categoryName && (
<span className="block text-xs text-primary">{article.categoryName}</span>
)}
<span className="mt-1 block text-sm font-bold leading-6 text-ink transition-colors group-hover/item:text-primary">
{title}
</span>
{date && <time className="mt-1 block text-xs text-ink-soft">{date}</time>}
</span>
</Link>
</li>
);
})}
</ul>
</div>
</details>
</aside>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useState, type FormEvent } from "react";
type FormStatus = "idle" | "submitting" | "success" | "error";
const fieldClassName =
"w-full rounded-2xl border border-ink/15 bg-white px-5 py-3.5 text-sm text-ink outline-none transition-colors placeholder:text-ink-soft/70 focus:border-primary disabled:cursor-not-allowed disabled:bg-cream/50";
export default function ContactForm() {
const [status, setStatus] = useState<FormStatus>("idle");
const [statusMessage, setStatusMessage] = useState("");
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
setStatus("submitting");
setStatusMessage("");
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.get("name"),
cellNumber: formData.get("cellNumber"),
email: formData.get("email"),
title: formData.get("title"),
text: formData.get("text"),
}),
});
const result = (await response.json().catch(() => null)) as
| { message?: string }
| null;
if (!response.ok) {
throw new Error(result?.message || "ارسال پیام انجام نشد.");
}
form.reset();
setStatus("success");
setStatusMessage(result?.message || "پیام شما با موفقیت ارسال شد.");
} catch (error) {
setStatus("error");
setStatusMessage(
error instanceof Error
? error.message
: "ارسال پیام انجام نشد. لطفاً دوباره تلاش کنید.",
);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5" aria-busy={status === "submitting"}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="space-y-2 text-sm font-medium text-ink">
<span>نام و نام خانوادگی</span>
<input
name="name"
type="text"
autoComplete="name"
required
maxLength={120}
placeholder="نام شما"
disabled={status === "submitting"}
className={fieldClassName}
/>
</label>
<label className="space-y-2 text-sm font-medium text-ink">
<span>شماره تماس</span>
<input
name="cellNumber"
type="tel"
inputMode="tel"
autoComplete="tel"
required
maxLength={30}
placeholder="۰۹۱۲ ۳۴۵ ۶۷۸۹"
disabled={status === "submitting"}
className={fieldClassName}
dir="ltr"
/>
</label>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label className="space-y-2 text-sm font-medium text-ink">
<span>ایمیل <span className="font-normal text-ink-soft">(اختیاری)</span></span>
<input
name="email"
type="email"
inputMode="email"
autoComplete="email"
maxLength={254}
placeholder="name@example.com"
disabled={status === "submitting"}
className={fieldClassName}
dir="ltr"
/>
</label>
<label className="space-y-2 text-sm font-medium text-ink">
<span>موضوع پیام</span>
<input
name="title"
type="text"
required
maxLength={160}
placeholder="موضوع درخواست شما"
disabled={status === "submitting"}
className={fieldClassName}
/>
</label>
</div>
<label className="block space-y-2 text-sm font-medium text-ink">
<span>متن پیام</span>
<textarea
name="text"
required
rows={6}
maxLength={4000}
placeholder="پیام خود را اینجا بنویسید..."
disabled={status === "submitting"}
className={`${fieldClassName} resize-y`}
/>
</label>
{statusMessage ? (
<p
role={status === "error" ? "alert" : "status"}
aria-live="polite"
className={`rounded-2xl border px-4 py-3 text-sm ${
status === "success"
? "border-secondary/30 bg-secondary/10 text-secondary"
: "border-red-200 bg-red-50 text-red-700"
}`}
>
{statusMessage}
</p>
) : null}
<button
type="submit"
disabled={status === "submitting"}
className="inline-flex min-w-32 items-center justify-center rounded-full bg-primary px-8 py-3.5 text-sm text-white transition-colors hover:bg-primary-dark disabled:cursor-wait disabled:opacity-60"
>
{status === "submitting" ? "در حال ارسال..." : "ارسال پیام"}
</button>
</form>
);
}
+138
View File
@@ -0,0 +1,138 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import { A11y, Keyboard, Pagination } from "swiper/modules";
import type { MeshkeeStaticImage, MeshkeeStaticImageSlot } from "@/lib/meshkee";
import "swiper/css";
import "swiper/css/pagination";
type CategoryItem = MeshkeeStaticImage & {
displayTitle: string;
count?: string;
};
const fallbackCategories: CategoryItem[] = [
{
id: "fallback-trousers",
displayTitle: "شلــــــــــوار",
titleFa: "شلوار",
count: "265",
url: "/images/categories/trousers.png",
linkUrl: "/portfolios?category=%D8%B4%D9%84%D9%88%D8%A7%D8%B1",
},
{
id: "fallback-sunglasses",
displayTitle: "عینک آفتابی",
titleFa: "عینک آفتابی",
count: "120",
url: "/images/categories/sunglasses.png",
linkUrl: "/portfolios?category=%D8%B9%DB%8C%D9%86%DA%A9%20%D8%A2%D9%81%D8%AA%D8%A7%D8%A8%DB%8C",
},
{
id: "fallback-coat",
displayTitle: "پالتــــــــــو",
titleFa: "پالتو",
count: "94",
url: "/images/categories/coat.png",
linkUrl: "/portfolios?category=%D9%BE%D8%A7%D9%84%D8%AA%D9%88",
},
{
id: "fallback-hoodie",
displayTitle: "هـــــــــــودی",
titleFa: "هودی",
count: "24",
url: "/images/categories/hoodie.png",
linkUrl: "/portfolios?category=%D9%87%D9%88%D8%AF%DB%8C",
},
{
id: "fallback-scarf",
displayTitle: "شال و روسری",
titleFa: "شال و روسری",
count: "186",
url: "/images/categories/scarf.png",
linkUrl: "/portfolios?category=%D8%B4%D8%A7%D9%84%20%D9%88%20%D8%B1%D9%88%D8%B3%D8%B1%DB%8C",
},
];
function cssAspectRatio(value: string | null | undefined, fallback: string) {
const match = value?.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
if (!match || Number(match[1]) <= 0 || Number(match[2]) <= 0) return fallback;
return `${match[1]} / ${match[2]}`;
}
function CategoryCard({ item, aspectRatio }: { item: CategoryItem; aspectRatio: string }) {
const content = (
<>
<div
className="relative mx-auto w-full max-w-[272px]"
style={{ aspectRatio }}
>
<img
src={item.url}
alt={item.titleFa || item.titleEn || "دسته‌بندی محصولات"}
loading="lazy"
className="absolute inset-0 h-full w-full object-contain"
/>
</div>
<p className="mt-3 text-center">
{item.displayTitle && (
<span className="block text-base font-bold text-ink transition-colors group-hover:text-primary sm:text-lg">
{item.displayTitle}
</span>
)}
{item.count && (
<span className="block text-sm text-[#999] sm:text-base">{item.count} محصول</span>
)}
</p>
</>
);
return item.linkUrl ? (
<a href={item.linkUrl} className="group block">
{content}
</a>
) : (
<div className="group block">{content}</div>
);
}
export default function CategoryCircles({ slot }: { slot?: MeshkeeStaticImageSlot }) {
const categories: CategoryItem[] = slot?.images.length
? slot.images.map((image) => ({
...image,
displayTitle: image.titleFa?.trim() || "",
}))
: fallbackCategories;
const aspectRatio = cssAspectRatio(slot?.aspectRatio, "272 / 350");
return (
<section className="bg-white" aria-label="دسته‌بندی محصولات">
<div className="mx-auto max-w-[1385px] px-5 sm:px-8">
<div aria-hidden="true" className="h-px bg-[#e6e6e6]" />
<div className="pt-10 sm:pt-12 lg:pt-14">
<Swiper
modules={[A11y, Keyboard, Pagination]}
slidesPerView={2}
spaceBetween={16}
keyboard={{ enabled: true }}
pagination={{ clickable: true }}
grabCursor
watchOverflow
breakpoints={{
640: { slidesPerView: 3, spaceBetween: 24 },
1024: { slidesPerView: 5, spaceBetween: 36 },
}}
className="categories-swiper"
>
{categories.map((category, index) => (
<SwiperSlide key={category.id || `${category.url}-${index}`} className="h-auto">
<CategoryCard item={category} aspectRatio={aspectRatio} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</section>
);
}
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable @next/next/no-img-element */
import type { MeshkeeStaticImage, MeshkeeStaticImageSlot } from "@/lib/meshkee";
const fallbackBanners: MeshkeeStaticImage[] = [
{
id: "fallback-bags",
titleFa: "کیف",
subtext: "شیک و مجلسی، فقط برای شما",
url: "/images/banners/banner-bags.jpg",
linkUrl: "/portfolios",
},
{
id: "fallback-clothing",
titleFa: "لباس",
subtext: "پوشاک، کفش، کیف و لوازم جانبی، اقلام ضروری برای این فصل",
url: "/images/banners/banner-clothing.jpg",
linkUrl: "/portfolios",
},
];
function cssAspectRatio(value: string | null | undefined, fallback: string) {
const match = value?.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
if (!match || Number(match[1]) <= 0 || Number(match[2]) <= 0) return fallback;
return `${match[1]} / ${match[2]}`;
}
function BannerCard({ banner, aspectRatio }: { banner: MeshkeeStaticImage; aspectRatio: string }) {
const content = (
<>
<div className="relative overflow-hidden rounded-[15px]" style={{ aspectRatio }}>
<img
src={banner.url}
alt={banner.titleFa || banner.titleEn || "بنر هدیه مزون"}
loading="lazy"
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/25" />
</div>
<div className="absolute inset-x-5 top-[53%] z-10 -translate-y-1/2 text-center text-white">
{banner.titleEn && (
<span className="font-latin mb-2 block text-sm tracking-[0.18em] sm:text-base lg:text-lg">
{banner.titleEn}
</span>
)}
{banner.titleFa && (
<h2 className="text-[39px] font-bold leading-tight lg:text-[calc(7vw-15px)] min-[1440px]:!text-[85px]">
{banner.titleFa}
</h2>
)}
{banner.subtext && (
<p className="mx-auto max-w-[370px] pt-4 lg:pt-[30px]">{banner.subtext}</p>
)}
{banner.linkUrl && (
<span className="relative mt-9 inline-block pb-[15px] text-[13px] font-medium lg:mt-10 lg:text-xl min-[1400px]:mt-[70px]">
مشاهده محصولات
<span className="absolute bottom-0 left-1/2 h-px w-5 -translate-x-1/2 bg-white lg:w-[34px]" />
</span>
)}
</div>
</>
);
return banner.linkUrl ? (
<a href={banner.linkUrl} className="group relative block">
{content}
</a>
) : (
<div className="group relative block">{content}</div>
);
}
export default function EditorialBanners({ slot }: { slot?: MeshkeeStaticImageSlot }) {
const banners = slot?.images.length ? slot.images : fallbackBanners;
const aspectRatio = cssAspectRatio(slot?.aspectRatio, "200 / 169");
return (
<section className="bg-white py-10 lg:py-20">
<div className="mx-auto max-w-[1385px] px-5 sm:px-8">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 lg:gap-5">
{banners.map((banner, index) => (
<BannerCard
key={banner.id || `${banner.url}-${index}`}
banner={banner}
aspectRatio={aspectRatio}
/>
))}
</div>
</div>
</section>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faHeadset, faShapes, faHandshake } from "@fortawesome/free-solid-svg-icons";
const features: { icon: IconDefinition; title: string; text: string }[] = [
{
icon: faHeadset,
title: "پشتیبانی همه‌روزه",
text: "در هر ساعتی از شبانه‌روز در خدمت شما هستیم",
},
{
icon: faShapes,
title: "تنوع بالای محصولات",
text: "مجموعه‌ای گسترده از طرح‌ها و محصولات مزون",
},
{
icon: faHandshake,
title: "مشاوره تخصصی",
text: "همراهی و مشاوره‌ی طراحی از ابتدا تا اجرای نهایی",
},
];
// Mirrors `.features--modern`: items spread across one row (first flush start, last flush end),
// primary-colored icon, and no vertical dividers. The following category section owns the
// shared divider so it remains visible at every breakpoint.
export default function FeatureStrip() {
return (
<section className="bg-white">
<div className="mx-auto max-w-[1385px] px-5 sm:px-8">
<div className="flex flex-col gap-8 py-12 lg:flex-row lg:justify-between lg:gap-0 lg:px-[50px] lg:pb-[60px] lg:pt-[85px]">
{features.map((f) => (
<div key={f.title} className="flex items-center">
<div className="w-[50px] min-w-[50px] lg:w-[70px] lg:min-w-[70px]">
<FontAwesomeIcon icon={f.icon} className="text-[26px] text-primary lg:text-4xl" />
</div>
<div>
<h6 className="pb-1 text-lg font-bold text-ink">{f.title}</h6>
<p className="text-xs text-ink-soft lg:text-base">{f.text}</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}
+98
View File
@@ -0,0 +1,98 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Pagination } from "swiper/modules";
import type { MeshkeeStaticImage, MeshkeeStaticImageSlot } from "@/lib/meshkee";
import "swiper/css";
import "swiper/css/pagination";
const fallbackSlides: MeshkeeStaticImage[] = [
{ id: "fallback-hero-1", url: "/images/hero/hero-1.jpg" },
{ id: "fallback-hero-2", url: "/images/hero/hero-2.jpg" },
];
function cssAspectRatio(value: string | null | undefined, fallback: string) {
const match = value?.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
if (!match || Number(match[1]) <= 0 || Number(match[2]) <= 0) return fallback;
return `${match[1]} / ${match[2]}`;
}
function HeroArtwork({
slide,
aspectRatio,
eager,
}: {
slide: MeshkeeStaticImage;
aspectRatio: string;
eager: boolean;
}) {
const artwork = (
<>
<div
aria-hidden="true"
className="absolute -inset-3 rounded-[1000px] border border-[#c7bdb6] sm:-inset-4 lg:-inset-5"
/>
<div
className="relative z-20 overflow-hidden rounded-[1000px]"
style={{ aspectRatio }}
>
<img
src={slide.url}
alt={slide.titleFa || slide.titleEn || "اسلایدر هدیه مزون"}
loading={eager ? "eager" : "lazy"}
fetchPriority={eager ? "high" : "auto"}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
<span className="font-latin pointer-events-none absolute inset-x-0 top-[64%] z-40 -translate-y-1/2 text-center text-lg tracking-[0.35em] text-white sm:text-2xl lg:text-3xl">
MAISON
</span>
</>
);
const className = "relative block w-[42vw] sm:w-[300px] lg:w-[340px] xl:w-[380px]";
return slide.linkUrl ? (
<a href={slide.linkUrl} className={className}>
{artwork}
</a>
) : (
<div className={className}>{artwork}</div>
);
}
export default function Hero({ slot }: { slot?: MeshkeeStaticImageSlot }) {
const slides = slot?.images.length ? slot.images : fallbackSlides;
const aspectRatio = cssAspectRatio(slot?.aspectRatio, "480 / 820");
return (
<section className="relative overflow-hidden bg-cream">
<h1
aria-label="هدیه مزون"
className="font-latin pointer-events-none absolute inset-x-0 top-[72%] z-30 -translate-y-1/2 select-none text-center text-[72px] leading-none text-primary sm:top-[74%] sm:text-[150px] lg:text-[220px] xl:text-[240px]"
>
Hediyeh
</h1>
<Swiper
modules={[Autoplay, Pagination]}
autoplay={{ delay: 6000, disableOnInteraction: false }}
pagination={{ clickable: true }}
loop={slides.length > 1}
className="hero-swiper"
>
{slides.map((slide, index) => (
<SwiperSlide key={slide.id || `${slide.url}-${index}`}>
<div className="relative flex justify-center pb-[54px] pt-4 sm:pb-[60px] sm:pt-5 lg:pb-[64px] lg:pt-6">
<HeroArtwork
slide={slide}
aspectRatio={aspectRatio}
eager={index === 0}
/>
</div>
</SwiperSlide>
))}
</Swiper>
</section>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useState, type FormEvent } from "react";
export default function Newsletter() {
const [status, setStatus] = useState<"idle" | "done">("idle");
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("done");
}
return (
<section className="bg-[#fafafa] px-5 py-16 text-center sm:px-8">
<h2 className="font-bold text-3xl sm:text-4xl">
<span className="text-primary">عضو</span> خبرنامه ما شوید
</h2>
<p className="mx-auto mt-4 max-w-lg text-[15px] leading-8 text-ink-soft">
اولین کسی باشید که از آخرین محصولات و یادداشت‌های ما باخبر می‌شوید. نگران نباشید، ما
هرزنامه نداریم!
</p>
{status === "done" ? (
<p className="mt-8 text-sm text-primary">عضویت شما با موفقیت ثبت شد. سپاس از همراهی شما!</p>
) : (
<form onSubmit={handleSubmit} className="mx-auto mt-8 flex max-w-md items-center gap-2">
<input
type="tel"
required
placeholder="شماره همراه"
className="w-full rounded-full border border-ink/15 bg-white px-5 py-3 text-sm outline-none placeholder:text-ink-soft focus:border-primary"
/>
<button
type="submit"
className="shrink-0 rounded-full bg-primary px-6 py-3 text-sm text-white transition-colors hover:bg-primary-dark"
>
عضویت
</button>
</form>
)}
<p className="mt-5 text-xs text-ink-soft">
ما متعهد به حفظ حریم شما هستیم. اطلاعات شما بدون رضایت صریح‌تان جایی منتشر نمی‌شود.
</p>
</section>
);
}
+142
View File
@@ -0,0 +1,142 @@
import Image from "next/image";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faLocationDot,
faPhone,
faEnvelope,
} from "@fortawesome/free-solid-svg-icons";
import {
faInstagram,
faTelegram,
faFacebookF,
} from "@fortawesome/free-brands-svg-icons";
import { siteConfig, footerPageLinks, footerUsefulLinks } from "@/lib/site-config";
const socials = [
{ href: siteConfig.socials.instagram, icon: faInstagram, label: "اینستاگرام" },
{ href: siteConfig.socials.telegram, icon: faTelegram, label: "تلگرام" },
{ href: siteConfig.socials.facebook, icon: faFacebookF, label: "فیسبوک" },
];
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="border-t border-[#dedede] bg-white">
<div className="mx-auto max-w-7xl px-5 pt-16 pb-6 sm:px-8">
<div className="grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
{/* Brand + address + socials */}
<div>
<Image
src="/images/brand/logo.png"
alt={siteConfig.name}
width={112}
height={55}
className="h-9 w-auto"
/>
<ul className="mt-5 space-y-3 text-sm text-ink-soft">
<li className="flex items-start gap-2">
<FontAwesomeIcon icon={faLocationDot} className="mt-1 text-primary" />
<span>{siteConfig.address}</span>
</li>
<li className="flex items-center gap-2">
<FontAwesomeIcon icon={faPhone} className="text-primary" />
<a href={`tel:${siteConfig.phone}`} className="hover:text-primary" dir="ltr">
{siteConfig.phoneDisplay}
</a>
</li>
<li className="flex items-center gap-2">
<FontAwesomeIcon icon={faEnvelope} className="text-primary" />
<a href={`mailto:${siteConfig.email}`} className="hover:text-primary" dir="ltr">
{siteConfig.email}
</a>
</li>
</ul>
<ul className="mt-5 flex items-center gap-3">
{socials.map((s) => (
<li key={s.label}>
<a
href={s.href}
target="_blank"
rel="noreferrer"
aria-label={s.label}
className="grid h-9 w-9 place-items-center rounded-full border border-ink/15 text-ink-soft transition-colors hover:border-primary hover:text-primary"
>
<FontAwesomeIcon icon={s.icon} className="text-sm" />
</a>
</li>
))}
</ul>
</div>
{/* Page links */}
<div>
<h3 className="font-bold text-lg text-ink">لینک صفحات</h3>
<ul className="mt-5 space-y-3 text-sm text-ink-soft">
{footerPageLinks.map((l) => (
<li key={l.href}>
<Link href={l.href} className="hover:text-primary">
{l.label}
</Link>
</li>
))}
</ul>
</div>
{/* Useful links */}
<div>
<h3 className="font-bold text-lg text-ink">لینک‌های مفید</h3>
<ul className="mt-5 space-y-3 text-sm text-ink-soft">
{footerUsefulLinks.map((l) => (
<li key={l.href}>
<Link href={l.href} className="hover:text-primary">
{l.label}
</Link>
</li>
))}
</ul>
</div>
{/* Brand blurb */}
<div>
<h3 className="font-bold text-lg text-ink">{siteConfig.name}،</h3>
<p className="mt-1 text-sm text-ink-soft">{siteConfig.tagline}</p>
<p className="mt-4 text-sm leading-7 text-ink-soft">{siteConfig.description}</p>
</div>
</div>
<div className="mt-14 flex flex-col gap-4 border-t border-ink/10 pt-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs tracking-wide text-ink-soft">
© {year} تمامی حقوق محفوظ است
</p>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit"
>
<span className="meshkee-credit__frame" aria-hidden="true">
<span className="meshkee-credit__line meshkee-credit__line--l" />
<span className="meshkee-credit__line meshkee-credit__line--t" />
<span className="meshkee-credit__line meshkee-credit__line--r" />
<span className="meshkee-credit__line meshkee-credit__line--b" />
</span>
<Image
src="/images/brand/meshkee-logo-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 Web Builder Team</span>
</span>
</a>
</div>
</div>
</footer>
);
}
+209
View File
@@ -0,0 +1,209 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBars, faChevronDown, faUser, faXmark } from "@fortawesome/free-solid-svg-icons";
import {
businessDashboardUrl,
customerDashboardUrl,
navLinks,
} from "@/lib/site-config";
type HeaderSession = {
name: string;
isAdmin: boolean;
};
export default function Header() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [session, setSession] = useState<HeaderSession | null>(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const controller = new AbortController();
fetch("/api/auth/session", {
credentials: "same-origin",
signal: controller.signal,
cache: "no-store",
})
.then((response) => (response.ok ? response.json() : { user: null }))
.then((data: { user?: HeaderSession | null }) => setSession(data.user || null))
.catch(() => undefined);
return () => controller.abort();
}, []);
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
return (
<header
className={`sticky top-0 z-50 transition-colors ${
pathname === "/" && !scrolled ? "bg-cream" : "bg-white shadow-[0_1px_0_0_#e6e6e6]"
}`}
>
<div className="mx-auto flex h-16 max-w-7xl flex-row-reverse items-center justify-between px-5 sm:px-8 lg:h-20 lg:flex-row">
<Link href="/" className="flex items-center gap-2" aria-label="هدیه مزون - صفحه اصلی">
<Image
src="/images/brand/logo.png"
alt="هدیه مزون"
width={112}
height={55}
className="h-8 w-auto lg:h-9"
priority
/>
</Link>
<nav className="hidden items-center gap-9 lg:flex" aria-label="پیمایش اصلی">
{navLinks.map((link) => {
const active = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`text-[15px] transition-colors hover:text-primary ${
active ? "text-primary" : "text-ink"
}`}
>
{link.label}
</Link>
);
})}
</nav>
<div dir="ltr" className="flex items-center gap-3 lg:flex-row-reverse lg:gap-4">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="grid h-10 w-10 place-items-center rounded-full border border-ink/15 lg:hidden"
aria-label={open ? "بستن منو" : "باز کردن منو"}
aria-expanded={open}
aria-controls="mobile-navigation"
>
<FontAwesomeIcon icon={open ? faXmark : faBars} className="text-base" />
</button>
{session ? (
<details dir="rtl" className="group relative">
<summary className="flex h-10 cursor-pointer list-none items-center gap-2 rounded-full border border-ink/15 px-3 text-sm text-ink transition-colors hover:border-primary hover:text-primary marker:content-none [&::-webkit-details-marker]:hidden">
<FontAwesomeIcon icon={faUser} aria-hidden="true" className="text-sm" />
<span>{session.name} عزیز</span>
<FontAwesomeIcon
icon={faChevronDown}
aria-hidden="true"
className="text-[10px] transition-transform group-open:rotate-180"
/>
</summary>
<div className="absolute left-0 top-[calc(100%+10px)] z-50 min-w-44 overflow-hidden rounded-xl border border-border bg-white py-2 shadow-lg shadow-black/5">
<a
href={customerDashboardUrl}
className="block px-4 py-2.5 text-sm text-ink transition-colors hover:bg-cream hover:text-primary"
>
پروفایل من
</a>
{session.isAdmin && (
<a
href={businessDashboardUrl}
className="block px-4 py-2.5 text-sm text-ink transition-colors hover:bg-cream hover:text-primary"
>
مدیریت وبسایت
</a>
)}
<form action="/api/auth/logout" method="post" className="border-t border-border">
<button
type="submit"
className="w-full px-4 py-2.5 text-right text-sm text-ink transition-colors hover:bg-cream hover:text-primary"
>
خروج
</button>
</form>
</div>
</details>
) : (
<a
href={`${customerDashboardUrl}/login?redirect=%2F`}
className="grid h-10 w-10 place-items-center rounded-full border border-ink/15 text-ink transition-colors hover:border-primary hover:text-primary"
aria-label="ورود یا حساب کاربری"
title="ورود یا حساب کاربری"
>
<FontAwesomeIcon icon={faUser} aria-hidden="true" className="text-sm" />
</a>
)}
<Link
dir="rtl"
href="/contact"
className="hidden rounded-full border border-ink/15 px-5 py-2 text-sm transition-colors hover:border-primary hover:text-primary lg:inline-block"
>
تماس با ما
</Link>
</div>
</div>
<button
type="button"
aria-label="بستن منوی موبایل"
aria-hidden={!open}
tabIndex={open ? 0 : -1}
onClick={() => setOpen(false)}
className={`fixed inset-x-0 bottom-0 top-16 z-40 bg-black/15 backdrop-blur-sm transition-opacity duration-300 lg:hidden ${
open ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
}`}
/>
<div
id="mobile-navigation"
aria-hidden={!open}
inert={!open}
className={`fixed bottom-0 right-0 top-16 z-50 w-[min(82vw,320px)] border-l border-border bg-white shadow-[-12px_0_30px_rgba(36,31,26,0.08)] transition-transform duration-300 ease-out lg:hidden ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<nav
className="h-full overflow-y-auto bg-white px-5 py-6"
aria-label="پیمایش موبایل"
>
<ul className="flex flex-col gap-1">
{navLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={() => setOpen(false)}
className={`block rounded-lg px-3 py-3 text-[15px] ${
pathname === link.href ? "bg-cream-deep/50 text-primary" : "text-ink"
}`}
>
{link.label}
</Link>
</li>
))}
</ul>
</nav>
</div>
</header>
);
}
@@ -0,0 +1,78 @@
import Image from "next/image";
import Link from "next/link";
import { portfolioPath } from "@/lib/paths";
export type PortfolioCardItem = {
id: string | number;
title: string;
titleFa?: string | null;
titleEn?: string | null;
category?: string | null;
image: string;
hoverImage?: string | null;
};
// Mirrors the source theme's `.product-grid-item`: portrait image (122.6% ratio, square
// corners) that cross-fades to a second photo on hover, a hover action button, then the
// title, Latin name and category tag. No price / cart — this site is not a store.
export default function PortfolioCard({ item }: { item: PortfolioCardItem }) {
const isRemoteImage = item.image.startsWith("http");
const hasHoverImage = Boolean(item.hoverImage && item.hoverImage !== item.image);
return (
<div className="group relative">
<Link
href={portfolioPath(item)}
title={item.title}
className="relative block overflow-hidden bg-cream-deep"
style={{ paddingBottom: "122.6%" }}
>
<Image
src={item.image}
alt={item.title}
fill
unoptimized={isRemoteImage}
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 45vw, 90vw"
className="object-cover transition-opacity duration-300"
/>
{hasHoverImage && item.hoverImage && (
<Image
src={item.hoverImage}
alt=""
aria-hidden="true"
fill
unoptimized={item.hoverImage.startsWith("http")}
sizes="(min-width: 1024px) 20vw, (min-width: 640px) 32vw, 50vw"
className="object-cover opacity-0 transition-opacity duration-300 group-hover:opacity-100"
/>
)}
<span className="pointer-events-none absolute inset-x-3.5 bottom-4 hidden justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100 lg:flex">
<span className="w-1/2 bg-white px-3 py-2.5 text-center text-[13px] text-ink">
مشاهده محصول
</span>
</span>
</Link>
<div className="pt-5">
<Link href={portfolioPath(item)} title={item.title}>
<h2 className="line-clamp-1 text-lg font-bold text-ink transition-colors group-hover:text-primary">
{item.title}
</h2>
{item.titleEn && (
<p dir="ltr" className="mt-1 line-clamp-1 text-right text-[13px] text-ink-soft">
{item.titleEn}
</p>
)}
</Link>
{item.category && (
<Link
href={`/portfolios?category=${encodeURIComponent(item.category)}`}
className="mt-3 inline-block text-xs text-ink-soft/80 hover:text-primary"
>
{item.category}
</Link>
)}
</div>
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { A11y, Keyboard } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import type { Swiper as SwiperInstance } from "swiper";
import "swiper/css";
type PortfolioGalleryProps = {
images: string[];
title: string;
};
function imageAlt(title: string, index: number) {
return index === 0 ? `تصویر اصلی ${title}` : `${title}، تصویر ${index + 1}`;
}
export default function PortfolioGallery({ images, title }: PortfolioGalleryProps) {
const [activeIndex, setActiveIndex] = useState(0);
const [lightboxOpen, setLightboxOpen] = useState(false);
const swiperRef = useRef<SwiperInstance | null>(null);
useEffect(() => {
if (!lightboxOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setLightboxOpen(false);
if (event.key === "ArrowLeft" && images.length > 1) {
setActiveIndex((current) => (current + 1) % images.length);
}
if (event.key === "ArrowRight" && images.length > 1) {
setActiveIndex((current) => (current - 1 + images.length) % images.length);
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", onKeyDown);
};
}, [images.length, lightboxOpen]);
const updateZoomOrigin = (event: PointerEvent<HTMLButtonElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
event.currentTarget.style.setProperty("--zoom-x", `${x}%`);
event.currentTarget.style.setProperty("--zoom-y", `${y}%`);
};
const goTo = (index: number) => {
setActiveIndex(index);
swiperRef.current?.slideTo(index);
};
if (images.length === 0) return null;
return (
<section aria-label={`گالری تصاویر ${title}`} className="min-w-0">
<div className="relative overflow-hidden rounded-[28px] border border-border bg-cream-deep">
<Swiper
modules={[A11y, Keyboard]}
keyboard={{ enabled: true }}
onSwiper={(swiper) => {
swiperRef.current = swiper;
}}
onSlideChange={(swiper) => setActiveIndex(swiper.activeIndex)}
slidesPerView={1}
className="portfolio-gallery-swiper"
aria-label={`تصاویر ${title}`}
>
{images.map((src, index) => (
<SwiperSlide key={`${src}-${index}`}>
<button
type="button"
onClick={() => setLightboxOpen(true)}
onPointerMove={updateZoomOrigin}
className="group relative block aspect-[4/5] w-full cursor-zoom-in overflow-hidden"
style={{ "--zoom-x": "50%", "--zoom-y": "50%" } as CSSProperties}
aria-label={`بزرگ‌نمایی ${imageAlt(title, index)}`}
>
<Image
src={src}
alt={imageAlt(title, index)}
fill
priority={index === 0}
unoptimized={src.startsWith("http")}
sizes="(min-width: 1280px) 560px, (min-width: 1024px) 48vw, 100vw"
className="object-cover transition-transform duration-500 ease-out [transform-origin:var(--zoom-x)_var(--zoom-y)] group-hover:scale-[1.45]"
/>
<span className="absolute bottom-4 left-4 rounded-full bg-white/90 px-4 py-2 text-xs text-ink shadow-sm backdrop-blur-sm">
برای بزرگ‌نمایی کلیک کنید
</span>
</button>
</SwiperSlide>
))}
</Swiper>
{images.length > 1 && (
<div className="pointer-events-none absolute inset-x-4 top-1/2 z-10 flex -translate-y-1/2 justify-between" dir="ltr">
<button
type="button"
onClick={() => swiperRef.current?.slidePrev()}
className="pointer-events-auto grid size-10 place-items-center rounded-full border border-border bg-white/90 text-xl text-ink transition hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
aria-label="تصویر قبلی"
>
‹
</button>
<button
type="button"
onClick={() => swiperRef.current?.slideNext()}
className="pointer-events-auto grid size-10 place-items-center rounded-full border border-border bg-white/90 text-xl text-ink transition hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
aria-label="تصویر بعدی"
>
›
</button>
</div>
)}
</div>
{images.length > 1 && (
<div className="mt-4 flex gap-3 overflow-x-auto pb-2" role="list" aria-label="تصاویر کوچک گالری">
{images.map((src, index) => (
<button
key={`${src}-thumb-${index}`}
type="button"
role="listitem"
onClick={() => goTo(index)}
aria-label={`نمایش ${imageAlt(title, index)}`}
aria-current={activeIndex === index ? "true" : undefined}
className={`relative aspect-[4/5] w-[76px] shrink-0 overflow-hidden rounded-xl border-2 bg-cream-deep transition sm:w-[88px] ${
activeIndex === index
? "border-primary"
: "border-transparent opacity-65 hover:opacity-100"
}`}
>
<Image
src={src}
alt=""
fill
unoptimized={src.startsWith("http")}
sizes="88px"
className="object-cover"
/>
</button>
))}
</div>
)}
{lightboxOpen && (
<div
className="fixed inset-0 z-[100] grid place-items-center bg-black/85 p-4 backdrop-blur-sm sm:p-8"
role="dialog"
aria-modal="true"
aria-label={`نمایش بزرگ تصاویر ${title}`}
onClick={() => setLightboxOpen(false)}
>
<button
type="button"
onClick={() => setLightboxOpen(false)}
className="absolute left-4 top-4 z-10 grid size-11 place-items-center rounded-full border border-white/40 bg-black/30 text-2xl text-white transition hover:bg-black/60 sm:left-8 sm:top-8"
aria-label="بستن نمایش بزرگ"
>
×
</button>
<div className="relative h-[82vh] w-full max-w-5xl" onClick={(event) => event.stopPropagation()}>
<Image
src={images[activeIndex]}
alt={imageAlt(title, activeIndex)}
fill
unoptimized={images[activeIndex].startsWith("http")}
sizes="100vw"
className="object-contain"
/>
{images.length > 1 && (
<div className="absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between" dir="ltr">
<button
type="button"
onClick={() => setActiveIndex((current) => (current - 1 + images.length) % images.length)}
className="grid size-11 place-items-center rounded-full border border-white/40 bg-black/30 text-2xl text-white transition hover:bg-black/60"
aria-label="تصویر قبلی"
>
‹
</button>
<button
type="button"
onClick={() => setActiveIndex((current) => (current + 1) % images.length)}
className="grid size-11 place-items-center rounded-full border border-white/40 bg-black/30 text-2xl text-white transition hover:bg-black/60"
aria-label="تصویر بعدی"
>
›
</button>
</div>
)}
</div>
</div>
)}
</section>
);
}
@@ -0,0 +1,64 @@
"use client";
import { useMemo, useState } from "react";
import { portfolioCategories, portfolioItems } from "@/data/portfolio";
import PortfolioCard from "./PortfolioCard";
const ALL = "همه";
export default function PortfolioGrid({ initialCategory }: { initialCategory?: string }) {
const initial = portfolioCategories.some((c) => c.name === initialCategory)
? (initialCategory as string)
: ALL;
const [active, setActive] = useState(initial);
const items = useMemo(
() => (active === ALL ? portfolioItems : portfolioItems.filter((p) => p.category === active)),
[active]
);
const linkClass = (on: boolean) =>
`flex w-full items-center justify-between py-2 text-[15px] transition-colors ${
on ? "font-bold text-primary" : "text-ink-soft hover:text-primary"
}`;
return (
<div className="grid grid-cols-1 gap-10 lg:grid-cols-4">
<aside className="lg:col-span-1">
<h2 className="border-b border-border pb-3 text-lg font-bold text-ink">دسته‌بندی‌ها</h2>
<ul className="mt-3">
<li>
<button type="button" onClick={() => setActive(ALL)} className={linkClass(active === ALL)}>
<span>همه محصولات</span>
<span className="text-xs">{portfolioItems.length}</span>
</button>
</li>
{portfolioCategories.map((cat) => (
<li key={cat.name}>
<button
type="button"
onClick={() => setActive(cat.name)}
className={linkClass(active === cat.name)}
>
<span>{cat.name}</span>
<span className="text-xs">{cat.count} محصول</span>
</button>
</li>
))}
</ul>
</aside>
<div className="lg:col-span-3">
{items.length === 0 ? (
<p className="py-16 text-center text-ink-soft">محصولی در این دسته یافت نشد.</p>
) : (
<div className="grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<PortfolioCard key={item.id} item={item} />
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { A11y, Keyboard, Pagination } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import PortfolioCard, { type PortfolioCardItem } from "./PortfolioCard";
import "swiper/css";
import "swiper/css/pagination";
export default function RelatedPortfolioCarousel({ items }: { items: PortfolioCardItem[] }) {
if (items.length === 0) return null;
return (
<Swiper
modules={[A11y, Keyboard, Pagination]}
slidesPerView={2}
spaceBetween={16}
keyboard={{ enabled: true }}
pagination={{ clickable: true }}
grabCursor={items.length > 2}
watchOverflow
breakpoints={{
640: { slidesPerView: 3, spaceBetween: 22 },
1024: { slidesPerView: 5, spaceBetween: 24 },
}}
className="related-portfolios-swiper"
aria-label="محصولات دیگر"
>
{items.map((item) => (
<SwiperSlide key={item.id} className="h-auto">
<PortfolioCard item={item} />
</SwiperSlide>
))}
</Swiper>
);
}
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
import type { ReactNode } from "react";
export type BreadcrumbItem = {
label: string;
href?: string;
};
export default function DetailPageShell({
breadcrumbs,
children,
}: {
breadcrumbs: BreadcrumbItem[];
children: ReactNode;
}) {
return (
<div className="mx-auto w-full max-w-7xl px-5 py-10 sm:px-8 sm:py-14">
<nav aria-label="مسیر صفحه" className="mb-8 min-w-0">
<ol className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm text-ink-soft">
{breadcrumbs.map((item, index) => {
const isCurrent = index === breadcrumbs.length - 1;
return (
<li key={`${item.label}-${index}`} className="flex min-w-0 items-center gap-2">
{index > 0 && <span aria-hidden="true">/</span>}
{item.href && !isCurrent ? (
<Link href={item.href} className="transition-colors hover:text-primary">
{item.label}
</Link>
) : (
<span aria-current={isCurrent ? "page" : undefined} className="break-words text-ink">
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
{children}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import WavePattern from "./WavePattern";
type PageHeroProps = {
eyebrow?: string;
title: string;
description?: string;
};
export default function PageHero({ eyebrow, title, description }: PageHeroProps) {
return (
<section className="relative overflow-hidden bg-cream">
<div className="pointer-events-none absolute inset-0 [transform:scaleX(-1)]">
<WavePattern className="h-full w-full" />
</div>
<div className="relative mx-auto max-w-7xl px-5 py-8 sm:px-8 sm:py-10">
{eyebrow && (
<p className="text-sm tracking-wide text-primary">{eyebrow}</p>
)}
<h1 className="mt-2 text-[22px] font-bold text-ink sm:text-[25px]">{title}</h1>
{description && (
<p className="mt-4 max-w-xl text-[15px] leading-8 text-ink-soft">{description}</p>
)}
</div>
</section>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Decorative wavy-line texture, redrawn in SVG in the spirit of the source
* theme's subheader banners (assets/files/*.jpg — cream ground with fine
* flowing lines). Rebuilt as vector so it stays crisp and RTL-agnostic
* instead of shipping the raster banners (which had English copy baked in).
*/
export default function WavePattern({ className = "" }: { className?: string }) {
const rows = 16;
const width = 900;
const height = 260;
const gap = height / (rows - 1);
const paths = Array.from({ length: rows }, (_, i) => {
const y = i * gap;
const wobble = 26 + (i % 3) * 6;
const d = `M0,${y} C ${width * 0.18},${y - wobble} ${width * 0.32},${y + wobble} ${width * 0.5},${y} S ${width * 0.82},${y - wobble} ${width},${y}`;
return <path key={i} d={d} />;
});
return (
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className={className}
aria-hidden="true"
>
<defs>
<linearGradient id="wave-fade" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stopColor="white" stopOpacity="0" />
<stop offset="55%" stopColor="white" stopOpacity="0.7" />
<stop offset="100%" stopColor="white" stopOpacity="1" />
</linearGradient>
<mask id="wave-mask">
<rect width={width} height={height} fill="url(#wave-fade)" />
</mask>
</defs>
<g
fill="none"
stroke="var(--color-primary)"
strokeWidth="1"
strokeOpacity="0.35"
mask="url(#wave-mask)"
>
{paths}
</g>
</svg>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { MeshkeeBlog } from "@/lib/meshkee";
export const fallbackBlogs: MeshkeeBlog[] = [
{
id: "fallback-style-guide",
businessId: 50,
title: "راهنمای انتخاب لباس برای استایل روزمره",
titleFa: "راهنمای انتخاب لباس برای استایل روزمره",
slug: "sample-daily-style-guide",
abstract:
"این مقاله‌ی تستی برای نمایش ساختار صفحه است و پس از انتشار مقاله‌های مزون، خودکار با محتوای CMS جایگزین می‌شود.",
mainTextHtml: `
<p>انتخاب لباس روزمره زمانی ساده‌تر می‌شود که راحتی، رنگ و فرم را در کنار هم ببینیم. در این راهنما چند نکته‌ی کاربردی برای ساختن یک استایل هماهنگ مرور می‌کنیم.</p>
<h2>از رنگ‌های پایه شروع کنید</h2>
<p>رنگ‌های خنثی پایه‌ای منعطف برای ترکیب‌های مختلف می‌سازند. سپس می‌توانید با یک اکسسوری یا لایه‌ی رنگی، شخصیت بیشتری به ظاهر خود بدهید.</p>
<blockquote><p>لباسی را انتخاب کنید که علاوه بر زیبایی، با ریتم زندگی روزانه‌ی شما هماهنگ باشد.</p></blockquote>
<h3>چک‌لیست انتخاب لباس</h3>
<ul>
<li>تناسب فرم لباس با موقعیت استفاده</li>
<li>هماهنگی رنگ‌ها با یکدیگر</li>
<li>راحتی پارچه در استفاده طولانی</li>
</ul>
<h4>مقایسه‌ی پیشنهادی</h4>
<table>
<thead><tr><th>موقعیت</th><th>پارچه</th><th>رنگ پایه</th><th>لایه دوم</th><th>اکسسوری پیشنهادی</th></tr></thead>
<tbody>
<tr><td>روزمره</td><td>نخ و پنبه</td><td>کرم</td><td>کت سبک</td><td>شال ساده</td></tr>
<tr><td>محیط کار</td><td>ترکیبی</td><td>سرمه‌ای</td><td>مانتوی ساختارمند</td><td>کیف مینیمال</td></tr>
<tr><td>دورهمی</td><td>ساتن مات</td><td>مشکی</td><td>رویه آزاد</td><td>زیورآلات ظریف</td></tr>
</tbody>
</table>
<p>برای دیدن تازه‌ترین نوشته‌ها همیشه می‌توانید به <a href="/blog">فهرست مقالات</a> برگردید.</p>
`,
status: "published",
type: "article",
categoryId: "fallback-style",
categoryName: "راهنمای استایل",
tags: ["استایل", "راهنما"],
titleImageUrl: "/images/interior/interior-wide.jpg",
publishedAt: "2026-09-29T10:00:00.000Z",
createdAt: "2026-09-29T10:00:00.000Z",
updatedAt: "2026-09-29T10:00:00.000Z",
author: { firstName: "تحریریه", lastName: "مزون هدیه" },
},
{
id: "fallback-color-guide",
businessId: 50,
title: "ترکیب رنگ‌های خنثی در پوشش",
titleFa: "ترکیب رنگ‌های خنثی در پوشش",
slug: "sample-neutral-colors",
abstract: "چند ایده ساده برای ساختن ترکیب‌های آرام و هماهنگ با رنگ‌های خنثی.",
mainTextHtml: "<p>این نوشته نمونه‌ای کوتاه برای نمایش مقالات مشابه است.</p>",
status: "published",
type: "article",
categoryId: "fallback-style",
categoryName: "راهنمای استایل",
titleImageUrl: "/images/interior/interior-detail.jpg",
publishedAt: "2026-09-25T10:00:00.000Z",
createdAt: "2026-09-25T10:00:00.000Z",
updatedAt: "2026-09-25T10:00:00.000Z",
author: { firstName: "تحریریه", lastName: "مزون هدیه" },
},
{
id: "fallback-fabric-guide",
businessId: 50,
title: "چطور پارچه مناسب فصل را انتخاب کنیم",
titleFa: "چطور پارچه مناسب فصل را انتخاب کنیم",
slug: "sample-seasonal-fabrics",
abstract: "مروری کوتاه بر بافت‌ها و انتخاب پارچه متناسب با فصل.",
mainTextHtml: "<p>این نوشته نمونه‌ای کوتاه برای نمایش مقالات مشابه است.</p>",
status: "published",
type: "article",
categoryId: "fallback-style",
categoryName: "راهنمای استایل",
titleImageUrl: "/images/interior/interior-wide-2.jpg",
publishedAt: "2026-09-20T10:00:00.000Z",
createdAt: "2026-09-20T10:00:00.000Z",
updatedAt: "2026-09-20T10:00:00.000Z",
author: { firstName: "تحریریه", lastName: "مزون هدیه" },
},
];
export function fallbackBlogById(id: string) {
return fallbackBlogs.find((blog) => String(blog.id) === id) || null;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Placeholder product data. Products are registered in Meshkee but this site has no
* price/cart/store, so they currently use local presentation data under the canonical
* `/portfolios/{id}/{titleFaSlug}` path. The UI presents them as products, while their CMS
* source is the portfolio module. Swap this module for real portfolio list data once populated (see
* context.md).
*/
export type PortfolioItem = {
id: string;
slug: string;
title: string;
titleEn: string;
category: string;
description: string;
image: string;
hoverImage: string;
};
export const portfolioCategories = [
{ name: "شلوار", count: 265 },
{ name: "عینک آفتابی", count: 120 },
{ name: "پالتو", count: 94 },
{ name: "هودی", count: 24 },
{ name: "شال و روسری", count: 186 },
] as const;
export const portfolioItems: PortfolioItem[] = [
{
id: "1",
slug: "cream-wool-coat",
title: "پالتو پشمی کرم",
titleEn: "Cream Wool Coat",
category: "پالتو",
description: "پالتوی تک‌دکمه با برشی مینیمال و پارچه‌ای گرم برای روزهای رو به سرد شدن.",
image: "/images/categories/coat.png",
hoverImage: "/images/portfolio/coat-alt.png",
},
{
id: "2",
slug: "classic-pleated-trousers",
title: "شلوار کلاسیک چین‌دار",
titleEn: "Classic Pleated Trousers",
category: "شلوار",
description: "خطوطی ساده و برشی راحت، مناسب استایل روزمره و رسمی.",
image: "/images/categories/trousers.png",
hoverImage: "/images/portfolio/trousers-alt.png",
},
{
id: "3",
slug: "olive-sunglasses",
title: "عینک آفتابی فریم زیتونی",
titleEn: "Olive Frame Sunglasses",
category: "عینک آفتابی",
description: "فریمی سبک با شیشه‌های دودی، برای نور تند تابستان.",
image: "/images/categories/sunglasses.png",
hoverImage: "/images/portfolio/sunglasses-alt.png",
},
{
id: "4",
slug: "everyday-hoodie",
title: "هودی روزمره",
titleEn: "Everyday Hoodie",
category: "هودی",
description: "پارچه‌ای نرم و برشی آزاد برای راحتی در طول روز.",
image: "/images/categories/hoodie.png",
hoverImage: "/images/portfolio/hoodie-alt.png",
},
{
id: "5",
slug: "silk-scarf",
title: "شال ابریشمی",
titleEn: "Silk Scarf",
category: "شال و روسری",
description: "بافتی ظریف با طرح‌هایی الهام‌گرفته از طبیعت.",
image: "/images/categories/scarf.png",
hoverImage: "/images/portfolio/scarf-alt.png",
},
{
id: "6",
slug: "beige-coat",
title: "پالتو بژ کلاسیک",
titleEn: "Classic Beige Coat",
category: "پالتو",
description: "طرحی همیشه‌مانا با یقه‌ی برگردان و آستین‌های ساده.",
image: "/images/portfolio/coat-alt.png",
hoverImage: "/images/categories/coat.png",
},
{
id: "7",
slug: "wide-leg-trousers",
title: "شلوار پاچه‌گشاد",
titleEn: "Wide Leg Trousers",
category: "شلوار",
description: "پارچه‌ای سبک با فیت راحت و ریزش زیبا.",
image: "/images/portfolio/trousers-alt.png",
hoverImage: "/images/categories/trousers.png",
},
{
id: "8",
slug: "sand-hoodie",
title: "هودی شنی",
titleEn: "Sand Hoodie",
category: "هودی",
description: "هودی جیب‌کانگورویی در رنگ‌های خنثی و پاستیلی.",
image: "/images/portfolio/hoodie-alt.png",
hoverImage: "/images/categories/hoodie.png",
},
{
id: "9",
slug: "satin-scarf",
title: "روسری ساتن",
titleEn: "Satin Scarf",
category: "شال و روسری",
description: "روسری‌ای سبک و براق برای ست‌های روزمره و مجلسی.",
image: "/images/portfolio/scarf-alt.png",
hoverImage: "/images/categories/scarf.png",
},
];
+111
View File
@@ -0,0 +1,111 @@
import "server-only";
type CookieReader = {
get(name: string): { value: string } | undefined;
getAll(): Array<{ name: string; value: string }>;
};
export const ACCESS_COOKIE = "meshkee_customer_access_token";
export const REFRESH_COOKIE = "meshkee_customer_refresh_token";
export const WEBSITE_DOMAIN = (process.env.MESHKEE_WEBSITE_DOMAIN || "hediehmezon.ir")
.trim()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.split("/")[0];
export const CUSTOMER_DASHBOARD_URL = `https://customer.${WEBSITE_DOMAIN}`;
export const BUSINESS_DASHBOARD_URL = `https://business.${WEBSITE_DOMAIN}`;
function decodeCookieValue(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function readChunkedCookie(cookies: CookieReader, baseName: string) {
const direct = cookies.get(baseName)?.value;
if (direct) return decodeCookieValue(direct);
const declaredCount = Number.parseInt(cookies.get(`${baseName}_n`)?.value || "", 10);
const chunkCount = Number.isFinite(declaredCount) && declaredCount > 0
? declaredCount
: cookies.getAll().filter(({ name }) => new RegExp(`^${baseName}_\\d+$`).test(name)).length;
if (chunkCount === 0) return null;
const chunks = Array.from({ length: chunkCount }, (_, index) => cookies.get(`${baseName}_${index}`)?.value);
if (chunks.some((chunk) => chunk === undefined)) return null;
return decodeCookieValue(chunks.join(""));
}
export function sharedAuthCookieNames(cookies: CookieReader) {
return cookies
.getAll()
.map(({ name }) => name)
.filter(
(name) =>
name === ACCESS_COOKIE ||
name === REFRESH_COOKIE ||
name.startsWith(`${ACCESS_COOKIE}_`) ||
name.startsWith(`${REFRESH_COOKIE}_`),
);
}
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stringValues(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.flatMap(stringValues);
if (isRecord(value)) return Object.values(value).flatMap(stringValues);
return [];
}
function decodeJwtPayload(token: string): UnknownRecord {
try {
const encoded = token.split(".")[1];
if (!encoded) return {};
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const parsed: unknown = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
export type HeaderSession = {
name: string;
isAdmin: boolean;
};
export function normalizeHeaderSession(payload: unknown, token: string): HeaderSession | null {
if (!isRecord(payload)) return null;
const rawUser = isRecord(payload.user) ? payload.user : payload;
const firstName = typeof rawUser.firstName === "string" ? rawUser.firstName.trim() : "";
const displayName = typeof rawUser.name === "string" ? rawUser.name.trim() : "";
const name = firstName || displayName || "کاربر";
const claims = decodeJwtPayload(token);
const explicitAdmin = [
rawUser.isAdmin,
rawUser.isBusinessAdmin,
rawUser.isSuperAdmin,
claims.isAdmin,
claims.isBusinessAdmin,
claims.isSuperAdmin,
].some((value) => value === true);
const roleText = [rawUser.role, rawUser.roles, rawUser.userType, claims.role, claims.roles, claims.userType]
.flatMap(stringValues)
.join(" ")
.toLowerCase();
return {
name,
isAdmin: explicitAdmin || /(^|[\s_-])(admin|owner|business)([\s_-]|$)/.test(roleText),
};
}
+334
View File
@@ -0,0 +1,334 @@
import { cache } from "react";
const API_BASE_URL = "https://api.meshkee.com/api/v1";
const DEFAULT_WEBSITE_DOMAIN = "hediehmezon.ir";
const CONTENT_REVALIDATE_SECONDS = 300;
export type MeshkeeAuthor = {
firstName?: string | null;
lastName?: string | null;
};
export type MeshkeeSchema = {
jsonLd?: Record<string, unknown> | null;
};
export type MeshkeeBlog = {
id: string;
businessId: string | number;
title: string;
titleFa?: string | null;
titleEn?: string | null;
slug: string;
abstract?: string | null;
mainTextHtml?: string | null;
status: string;
type: "news" | "article" | "blog";
categoryId?: string | null;
categoryName?: string | null;
tags?: string[];
titleImageUrl?: string | null;
publishedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
author?: MeshkeeAuthor | null;
seoMetaTitle?: string | null;
seoMetaDescription?: string | null;
schema?: MeshkeeSchema | null;
};
export type MeshkeePortfolio = {
id: string;
businessId: string | number;
title: string;
titleFa?: string | null;
titleEn?: string | null;
abstract?: string | null;
mainTextHtml?: string | null;
status: string;
categoryId?: string | null;
categoryName?: string | null;
seoMetaTitle?: string | null;
seoMetaDescription?: string | null;
titleImageUrl?: string | null;
thumbnailUrl?: string | null;
image?: string | null;
gallery?: Array<{ mediaId?: string; url: string }>;
images?: Array<{ mediaId?: string; url: string }>;
projectUrl?: string | null;
};
type Tenant = {
id: string | number;
domain: string;
enabledModules: string[];
};
type BlogListResponse = {
items: MeshkeeBlog[];
total: number;
page: number;
pageSize: number;
};
type BlogDetailResponse = {
blog: MeshkeeBlog;
};
type PortfolioDetailResponse = {
portfolio: MeshkeePortfolio;
};
export type PortfolioListResponse = {
items: MeshkeePortfolio[];
total: number;
page: number;
pageSize: number;
};
export type MeshkeeStaticImage = {
id: string;
url: string;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
width?: number | null;
height?: number | null;
sortOrder?: number | null;
};
export type MeshkeeStaticImageSlot = {
id: string;
key: string;
kind: "single" | "list";
label: string;
aspectRatio?: string | null;
recommendedWidth?: number | null;
itemCount?: number | null;
pageKey: string;
images: MeshkeeStaticImage[];
};
type StaticImageSlotsResponse = {
items: MeshkeeStaticImageSlot[];
};
function websiteDomain() {
return (process.env.MESHKEE_WEBSITE_DOMAIN || DEFAULT_WEBSITE_DOMAIN)
.trim()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.split("/")[0];
}
async function apiGet<T>(path: string, tags: string[]): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: { Accept: "application/json" },
next: { revalidate: CONTENT_REVALIDATE_SECONDS, tags },
});
if (!response.ok) {
throw new Error(`Meshkee API request failed (${response.status}): ${path}`);
}
return response.json() as Promise<T>;
}
export const getTenant = cache(async (): Promise<Tenant> => {
const domain = websiteDomain();
return apiGet<Tenant>(`/tenants/${encodeURIComponent(domain)}`, ["tenant"]);
});
async function tenantPath(path: string) {
const tenant = await getTenant();
return `/tenants/${encodeURIComponent(tenant.domain)}${path}`;
}
export const getBlogs = cache(
async ({
page = 1,
pageSize = 12,
categoryId,
}: {
page?: number;
pageSize?: number;
categoryId?: string;
} = {}): Promise<BlogListResponse> => {
const path = await tenantPath("/blogs");
const url = new URL(`${API_BASE_URL}${path}`);
url.searchParams.set("page", String(page));
url.searchParams.set("pageSize", String(pageSize));
if (categoryId) url.searchParams.set("categoryId", categoryId);
const response = await fetch(url, {
headers: { Accept: "application/json" },
next: { revalidate: CONTENT_REVALIDATE_SECONDS, tags: ["blogs"] },
});
if (!response.ok) {
throw new Error(`Meshkee API request failed (${response.status}): ${url.pathname}`);
}
return response.json() as Promise<BlogListResponse>;
},
);
export const getBlogById = cache(async (id: string): Promise<MeshkeeBlog | null> => {
const path = await tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`);
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: { Accept: "application/json" },
next: { revalidate: CONTENT_REVALIDATE_SECONDS, tags: ["blogs", `blog:${id}`] },
});
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Meshkee API request failed (${response.status}): ${path}`);
}
const data = (await response.json()) as BlogDetailResponse;
return data.blog?.status === "published" ? data.blog : null;
});
export const getPortfolioById = cache(async (id: string): Promise<MeshkeePortfolio | null> => {
const path = await tenantPath(`/portfolios/by-id/${encodeURIComponent(id)}`);
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: { Accept: "application/json" },
next: { revalidate: CONTENT_REVALIDATE_SECONDS, tags: ["portfolios", `portfolio:${id}`] },
});
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Meshkee API request failed (${response.status}): ${path}`);
}
const data = (await response.json()) as PortfolioDetailResponse;
return data.portfolio?.status === "published" ? data.portfolio : null;
});
export const getPortfolios = cache(
async ({
page = 1,
pageSize = 20,
categoryId,
}: {
page?: number;
pageSize?: number;
categoryId?: string;
} = {}): Promise<PortfolioListResponse> => {
const path = await tenantPath("/portfolios");
const url = new URL(`${API_BASE_URL}${path}`);
url.searchParams.set("page", String(page));
url.searchParams.set("pageSize", String(pageSize));
if (categoryId) url.searchParams.set("categoryId", categoryId);
const response = await fetch(url, {
headers: { Accept: "application/json" },
next: { revalidate: CONTENT_REVALIDATE_SECONDS, tags: ["portfolios"] },
});
if (!response.ok) {
throw new Error(`Meshkee API request failed (${response.status}): ${url.pathname}`);
}
return response.json() as Promise<PortfolioListResponse>;
},
);
export const getHomeStaticImageSlots = cache(async (): Promise<MeshkeeStaticImageSlot[]> => {
const domain = websiteDomain();
const url = new URL(
`${API_BASE_URL}/tenants/${encodeURIComponent(domain)}/website/static-images`,
);
url.searchParams.set("pageKey", "home");
try {
const response = await fetch(url, {
headers: { Accept: "application/json" },
next: { revalidate: CONTENT_REVALIDATE_SECONDS, tags: ["static-images", "static-images:home"] },
});
if (!response.ok) return [];
const data = (await response.json()) as StaticImageSlotsResponse;
return Array.isArray(data.items) ? data.items : [];
} catch {
return [];
}
});
export function getStaticImageSlot(
slots: MeshkeeStaticImageSlot[],
key: string,
): MeshkeeStaticImageSlot | undefined {
const slot = slots.find((item) => item.key === key);
if (!slot || !Array.isArray(slot.images) || slot.images.length === 0) return undefined;
return {
...slot,
images: slot.kind === "single" ? slot.images.slice(0, 1) : slot.images,
};
}
export async function getRelatedBlogs(blog: MeshkeeBlog) {
if (!blog.categoryId) return [];
try {
const response = await getBlogs({ categoryId: blog.categoryId, pageSize: 6 });
return response.items.filter((item) => item.id !== blog.id).slice(0, 5);
} catch {
return [];
}
}
export function blogTitle(blog: MeshkeeBlog) {
return blog.titleFa || blog.title;
}
export function blogAuthor(blog: MeshkeeBlog) {
return [blog.author?.firstName, blog.author?.lastName].filter(Boolean).join(" ");
}
export function formatBlogDate(blog: Pick<MeshkeeBlog, "publishedAt" | "createdAt">) {
const value = blog.publishedAt || blog.createdAt;
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
}
function escapeHtmlAttribute(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function normalizeRichTextHtml(html: string, imageAltText: string, tableLabel: string) {
let tableIndex = 0;
const imageAlt = escapeHtmlAttribute(imageAltText);
const withImageAlts = html.replace(/<img\b[^>]*>/gi, (imageTag) => {
if (/\salt\s*=\s*(["'])\s*\1/i.test(imageTag)) {
return imageTag.replace(/\salt\s*=\s*(["'])\s*\1/i, ` alt="${imageAlt}"`);
}
if (/\salt\s*=/i.test(imageTag)) return imageTag;
return imageTag.replace(/\s*\/?\s*>$/, (ending) => ` alt="${imageAlt}"${ending}`);
});
return withImageAlts.replace(/(<table\b[\s\S]*?<\/table>)/gi, (table) => {
tableIndex += 1;
return `<div class="cms-table-scroll" role="region" aria-label="${escapeHtmlAttribute(tableLabel)} ${tableIndex}" tabindex="0">${table}</div>`;
});
}
export function normalizeArticleHtml(html: string, title: string) {
return normalizeRichTextHtml(html, `تصویر مقاله ${title}`, "جدول مقاله");
}
+33
View File
@@ -0,0 +1,33 @@
export function slugifyPathSegment(value: string) {
return value
.normalize("NFKC")
.replace(/[يى]/g, "ی")
.replace(/ك/g, "ک")
.replace(/[\u064B-\u065F\u0670]/g, "")
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.trim()
.replace(/[\s-]+/g, "-")
.toLowerCase();
}
export function decodePathSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function blogPath(article: { id: string | number; titleFa?: string | null; title: string }) {
const slug = slugifyPathSegment(article.titleFa || article.title);
return `/blog/${encodeURIComponent(String(article.id))}/${encodeURIComponent(slug)}`;
}
export function portfolioPath(item: {
id: string | number;
titleFa?: string | null;
title: string;
}) {
const slug = slugifyPathSegment(item.titleFa || item.title);
return `/portfolios/${encodeURIComponent(String(item.id))}/${encodeURIComponent(slug)}`;
}
+39
View File
@@ -0,0 +1,39 @@
export const siteConfig = {
domain: "hediehmezon.ir",
name: "هدیه مزون",
nameLatin: "Hediyeh Maison",
tagline: "مانند یک رویا به نظر برسید و احساس کنید",
description:
"هدیه مزون؛ خانه‌ی طراحی و محصولات مد و پوشاک. از طبیعت، پاستیل رنگی و فعالیت‌های روزانه الهام گرفته‌ایم.",
address: "قم، سالاریه، میدان پیچک",
phone: "09123456789",
phoneDisplay: "0912 345 6789",
email: "name@info.com",
socials: {
instagram: "#",
telegram: "#",
facebook: "#",
},
} as const;
export const customerDashboardUrl = `https://customer.${siteConfig.domain}`;
export const businessDashboardUrl = `https://business.${siteConfig.domain}`;
export const navLinks = [
{ label: "صفحه اصلی", href: "/" },
{ label: "محصولات", href: "/portfolios" },
{ label: "مقالات", href: "/blog" },
{ label: "درباره ما", href: "/about" },
{ label: "تماس با ما", href: "/contact" },
] as const;
export const footerPageLinks = [
{ label: "صفحه اصلی", href: "/" },
{ label: "محصولات", href: "/portfolios" },
{ label: "مقالات", href: "/blog" },
] as const;
export const footerUsefulLinks = [
{ label: "درباره ما", href: "/about" },
{ label: "تماس با ما", href: "/contact" },
] as const;