Build Hediyeh Maison portfolio website
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.`);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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}`, "جدول مقاله");
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||