diff --git a/context.md b/context.md new file mode 100644 index 0000000..9e38b7c --- /dev/null +++ b/context.md @@ -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 +`

`. 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: `

`, `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 ``, `linkUrl` + wraps the item in ``, 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 `
` 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 `` 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. diff --git a/next.config.ts b/next.config.ts index e9ffa30..694f835 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,35 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", + async redirects() { + return [ + { + source: "/portfolio/:path*", + destination: "/portfolios", + permanent: true, + }, + { + source: "/products/:path*", + destination: "/portfolios", + permanent: true, + }, + ]; + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "meshkee-storage.sas.amin.parminstorage.ir", + pathname: "/**", + }, + { + protocol: "https", + hostname: "meshkee-templates.sas.amin.parminstorage.ir", + pathname: "/**", + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 0663d57..85a8d00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,15 @@ "name": "website", "version": "0.1.0", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.3.1", + "@fortawesome/free-brands-svg-icons": "^7.3.1", + "@fortawesome/free-regular-svg-icons": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", + "@fortawesome/react-fontawesome": "^3.5.0", "next": "16.3.5", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "swiper": "^14.2.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -453,6 +459,76 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz", + "integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.1.tgz", + "integrity": "sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.3.1.tgz", + "integrity": "sha512-8eiNzycuFhy3mZsi5EVO8JpO3H8l7/kSCl4Gk4iUew9hJuzYFqAAD6kqgeEup3+YZ9hL+5cjjorGzrRLkYuURg==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.3.1.tgz", + "integrity": "sha512-q1EsmL7Q8DDnkRBUjSvrxbq7c9oVwwVjCn/xa5apKmdp65YSgzUg2y0Ltnd5aDbT6GdAQQdXql5Ha90ArqIReQ==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz", + "integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.5.0.tgz", + "integrity": "sha512-63mlRr6fiBbJ0wjr1Cf6dsDGtP2lNvk9lnatKgxs/fIkhslsZT291hIUzJkuUkI9yr69ZvWnWfgb2qXm4QyVaA==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~6 || ~7", + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -6259,6 +6335,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swiper": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-14.2.0.tgz", + "integrity": "sha512-GsL4M9Fq7Sg22OyL3SjdNj/0bxTfIxPJJHHW394Li0u5vYtExi0a09iBUc8CuLafJi5pAlH2AHUe1PocxHl45g==", + "funding": [ + { + "type": "custom", + "url": "https://sponsors.nolimits4web.com" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nolimits4web" + } + ], + "license": "MIT", + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", diff --git a/package.json b/package.json index 2d2d026..2559771 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,24 @@ "version": "0.1.0", "private": true, "scripts": { + "generate:sitemap-config": "node scripts/generate-sitemap-config.mjs", + "predev": "npm run generate:sitemap-config", "dev": "next dev", + "prebuild": "npm run generate:sitemap-config", "build": "next build", "start": "next start", "lint": "eslint" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.3.1", + "@fortawesome/free-brands-svg-icons": "^7.3.1", + "@fortawesome/free-regular-svg-icons": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", + "@fortawesome/react-fontawesome": "^3.5.0", "next": "16.3.5", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "swiper": "^14.2.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/public/images/about/about-image.png b/public/images/about/about-image.png new file mode 100644 index 0000000..0e86d89 Binary files /dev/null and b/public/images/about/about-image.png differ diff --git a/public/images/about/signature.png b/public/images/about/signature.png new file mode 100644 index 0000000..4a18941 Binary files /dev/null and b/public/images/about/signature.png differ diff --git a/public/images/about/storefront.jpg b/public/images/about/storefront.jpg new file mode 100644 index 0000000..7f85a3c Binary files /dev/null and b/public/images/about/storefront.jpg differ diff --git a/public/images/banners/banner-bags.jpg b/public/images/banners/banner-bags.jpg new file mode 100644 index 0000000..771fb12 Binary files /dev/null and b/public/images/banners/banner-bags.jpg differ diff --git a/public/images/banners/banner-clothing.jpg b/public/images/banners/banner-clothing.jpg new file mode 100644 index 0000000..6cb8375 Binary files /dev/null and b/public/images/banners/banner-clothing.jpg differ diff --git a/public/images/brand/logo.png b/public/images/brand/logo.png new file mode 100644 index 0000000..44c9adb Binary files /dev/null and b/public/images/brand/logo.png differ diff --git a/public/images/brand/meshkee-logo-white.png b/public/images/brand/meshkee-logo-white.png new file mode 100644 index 0000000..a10ecd3 Binary files /dev/null and b/public/images/brand/meshkee-logo-white.png differ diff --git a/public/images/brand/monogram.png b/public/images/brand/monogram.png new file mode 100644 index 0000000..995be19 Binary files /dev/null and b/public/images/brand/monogram.png differ diff --git a/public/images/categories/coat.png b/public/images/categories/coat.png new file mode 100644 index 0000000..7845f9e Binary files /dev/null and b/public/images/categories/coat.png differ diff --git a/public/images/categories/hoodie.png b/public/images/categories/hoodie.png new file mode 100644 index 0000000..4adb788 Binary files /dev/null and b/public/images/categories/hoodie.png differ diff --git a/public/images/categories/scarf.png b/public/images/categories/scarf.png new file mode 100644 index 0000000..1feec14 Binary files /dev/null and b/public/images/categories/scarf.png differ diff --git a/public/images/categories/sunglasses.png b/public/images/categories/sunglasses.png new file mode 100644 index 0000000..e7930cf Binary files /dev/null and b/public/images/categories/sunglasses.png differ diff --git a/public/images/categories/trousers.png b/public/images/categories/trousers.png new file mode 100644 index 0000000..5f0ee9d Binary files /dev/null and b/public/images/categories/trousers.png differ diff --git a/public/images/hero/hero-1.jpg b/public/images/hero/hero-1.jpg new file mode 100644 index 0000000..91aea62 Binary files /dev/null and b/public/images/hero/hero-1.jpg differ diff --git a/public/images/hero/hero-2.jpg b/public/images/hero/hero-2.jpg new file mode 100644 index 0000000..96cd2a7 Binary files /dev/null and b/public/images/hero/hero-2.jpg differ diff --git a/public/images/interior/interior-detail.jpg b/public/images/interior/interior-detail.jpg new file mode 100644 index 0000000..d537949 Binary files /dev/null and b/public/images/interior/interior-detail.jpg differ diff --git a/public/images/interior/interior-wide-2.jpg b/public/images/interior/interior-wide-2.jpg new file mode 100644 index 0000000..3158c91 Binary files /dev/null and b/public/images/interior/interior-wide-2.jpg differ diff --git a/public/images/interior/interior-wide.jpg b/public/images/interior/interior-wide.jpg new file mode 100644 index 0000000..3158c91 Binary files /dev/null and b/public/images/interior/interior-wide.jpg differ diff --git a/public/images/portfolio/coat-alt.png b/public/images/portfolio/coat-alt.png new file mode 100644 index 0000000..99619f7 Binary files /dev/null and b/public/images/portfolio/coat-alt.png differ diff --git a/public/images/portfolio/hoodie-alt.png b/public/images/portfolio/hoodie-alt.png new file mode 100644 index 0000000..c2dae6c Binary files /dev/null and b/public/images/portfolio/hoodie-alt.png differ diff --git a/public/images/portfolio/scarf-alt.png b/public/images/portfolio/scarf-alt.png new file mode 100644 index 0000000..2684d08 Binary files /dev/null and b/public/images/portfolio/scarf-alt.png differ diff --git a/public/images/portfolio/sunglasses-alt.png b/public/images/portfolio/sunglasses-alt.png new file mode 100644 index 0000000..b4cf1cd Binary files /dev/null and b/public/images/portfolio/sunglasses-alt.png differ diff --git a/public/images/portfolio/trousers-alt.png b/public/images/portfolio/trousers-alt.png new file mode 100644 index 0000000..d5278da Binary files /dev/null and b/public/images/portfolio/trousers-alt.png differ diff --git a/public/meshkee/sitemap-config.json b/public/meshkee/sitemap-config.json new file mode 100644 index 0000000..9b259d9 --- /dev/null +++ b/public/meshkee/sitemap-config.json @@ -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 + } + ] +} diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..1141161 --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -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.`); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..bb8b37b --- /dev/null +++ b/src/app/about/page.tsx @@ -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 ( + <> + + +
+
+ {stats.map((stat) => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+
+ +
+
+
+ +
+ +
+
+

مزون هدیه

+

+ لباس رویاهای خود را با مزون ما پیدا کنید +

+
+ +

+ در مزون هدیه، هر لباس از یک رویا آغاز می‌شود؛ رویایی که با ظرافت، خلاقیت و توجه به + جزئیات به واقعیت تبدیل می‌کنیم. طراحی‌های ما با الهام از طبیعت، رنگ‌های آرام و سبک + زندگی امروز شکل می‌گیرند تا زیبایی و راحتی را در کنار هم تجربه کنید. از انتخاب پارچه + تا آخرین دوخت، همه‌چیز با دقت و عشق انجام می‌شود تا لباسی داشته باشید که شخصیت و + سلیقهٔ منحصربه‌فرد شما را روایت کند. باور داریم هر انتخاب زیبا می‌تواند آغاز یک حس + تازه و ماندگار باشد. +

+ + +
+
+
+ + ); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..1fa960b --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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; +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..9e5c1df --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -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" } }); + } +} diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 0000000..4d3db58 --- /dev/null +++ b/src/app/api/contact/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/blog/[id]/[titleSlug]/page.tsx b/src/app/blog/[id]/[titleSlug]/page.tsx new file mode 100644 index 0000000..2613ef7 --- /dev/null +++ b/src/app/blog/[id]/[titleSlug]/page.tsx @@ -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 { + 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 ( + + {post.schema?.jsonLd && ( +