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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -8,9 +8,15 @@
|
|||||||
"name": "website",
|
"name": "website",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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",
|
"next": "16.3.5",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"swiper": "^14.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -453,6 +459,76 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -6259,6 +6335,25 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||||
|
|||||||
@@ -3,15 +3,24 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs",
|
||||||
|
"predev": "npm run generate:sitemap-config",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"prebuild": "npm run generate:sitemap-config",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "16.3.5",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8",
|
||||||
|
"swiper": "^14.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
@theme {
|
||||||
--background: #ffffff;
|
/* Brand palette — sourced from the original site's live-rendered styles
|
||||||
--foreground: #171717;
|
(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 {
|
html {
|
||||||
--color-background: var(--background);
|
scroll-behavior: smooth;
|
||||||
--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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: #fff;
|
||||||
color: var(--foreground);
|
color: var(--color-ink);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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 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";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
faConfig.autoAddCss = false;
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
metadataBase: new URL("https://hediehmezon.ir"),
|
||||||
description: "Generated by create next app",
|
title: {
|
||||||
|
default: "هدیه مزون | محصولات",
|
||||||
|
template: "%s | هدیه مزون",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"هدیه مزون؛ خانهی طراحی و محصولات مد و پوشاک. مجموعهای از طرحها، همکاریها و یادداشتهای ما را ببینید.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="fa"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</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 (
|
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">
|
<Hero slot={sliderSlot} />
|
||||||
<Image
|
<FeatureStrip />
|
||||||
className="dark:invert h-5 w-[100px]"
|
<CategoryCircles slot={categoriesSlot} />
|
||||||
src="/next.svg"
|
<EditorialBanners slot={bannersSlot} />
|
||||||
alt="Next.js logo"
|
<Newsletter />
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||