Expand HOM studio site with portfolio, blog, auth, and details video frame.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
73971bf6d9
commit
55dd94d2e4
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
NEXT_PUBLIC_WEBSITE_DOMAIN=homgroup.com
|
NEXT_PUBLIC_WEBSITE_DOMAIN=homgroup.ir
|
||||||
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
|
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
|
||||||
|
# Optional — Maps JavaScript API key. Hides POI/place icons on the contact map.
|
||||||
|
# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=
|
||||||
|
|||||||
+51
-63
@@ -1,79 +1,67 @@
|
|||||||
# HOM arch studio — AI / Project Context
|
# HOM arch studio — AI / Project Context
|
||||||
|
|
||||||
Persian (fa, RTL) brochure website for **استودیو معماری هوم** (architecture + interior design group).
|
Persian (fa, RTL) brochure website for **استودیو معماری هوم** (architecture + interior design).
|
||||||
Keep this file current whenever routes, slots, data sources or design rules change.
|
Keep this file current whenever routes, slots, data sources or design rules change.
|
||||||
|
|
||||||
## Source of truth
|
## Source of truth
|
||||||
- Live domain: `https://homgroup.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.com`)
|
- Live domain: `https://homgroup.ir` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.ir`)
|
||||||
- Backend docs: https://api.meshkee.com/docs/website (OpenAPI: `/docs/website/openapi.json`)
|
- Backend docs: https://api.meshkee.com/docs/website
|
||||||
- API base: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_MESHKEE_API_BASE`)
|
- API base: `https://api.meshkee.com/api/v1`
|
||||||
- Tenant: `GET /tenants/homgroup.com` — **not registered yet** (404 as of 2026-10-11). Every Meshkee read has a local fallback, so the site renders fully without the tenant.
|
- Tenant: `GET /tenants/homgroup.ir` → business id `53`
|
||||||
- Prompt reference: `https://api.meshkee.com/api/v1/public/ai-prompts/studio-hom/basic-create/36caa331616c4b1cd382f8cb`
|
- Customer portal: `https://customer.homgroup.ir` (no storefront login pages)
|
||||||
- Original brief: homepage + about + contact only; `lang=fa`, `dir=rtl`, flat design, palette from the logo.
|
- Business dashboard: `https://business.homgroup.ir`
|
||||||
|
- Placeholders prompt: `…/ai-prompts/studio-hom/placeholders/576804987514877ec206af60`
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Next.js 15 App Router + React 19 + TypeScript, no Tailwind — all styles in `src/app/globals.css`
|
- Next.js 15 App Router + React 19 + TypeScript; styles in `src/app/globals.css` (no Tailwind)
|
||||||
- `output: "standalone"` in `next.config.ts` (**required** for the Meshkee websites VM; deploy drops `node_modules`)
|
- `output: "standalone"` required for Meshkee websites VM
|
||||||
- `npm run build` runs `prebuild` → `scripts/generate-sitemap-config.mjs` (scans `src/app` for static pages)
|
- Fonts: IranYekan (FA) + Montserrat (EN / `.latin`)
|
||||||
- `npm run lint` = `tsc --noEmit`
|
|
||||||
- Font: IranYekan (local, `public/fonts/`, weights 300/400/500/700) via `next/font/local` → `--font-yekan`
|
|
||||||
|
|
||||||
## Design language (flat)
|
## Design
|
||||||
- Palette: bg `#ffffff`, surface `#f4f3f1`, text `#111111`, muted `#6b6b6b`, hairline `#e2e0dc`, accent red `#e3002b` (the logo dot)
|
- Flat palette: bg `#fff`, surface `#f4f3f1`, fg `#111`, muted `#6b6b6b`, line `#e2e0dc`, accent `#e3002b`
|
||||||
- Flat only: no shadows, no border-radius, 1px hairlines, solid fills. Red appears as a small square “dot” (eyebrows, hero title) and on hover.
|
- Sticky header + account menu; dark map band on contact; stats band with pattern
|
||||||
- Latin labels use `.latin` (uppercase, letter-spaced, LTR). One dark section (`.section--dark`) for the process.
|
|
||||||
- Header is sticky, white, hairline bottom; mobile burger menu under 900px.
|
## Static image slots (`STATIC_IMAGE_SLOTS` → `/meshkee/static-image-slots`)
|
||||||
- Logo: `public/images/logo.png` (cropped from `assets/logo/logo.jpeg`); favicon `src/app/icon.png`
|
Fetch: `GET /tenants/{domain}/website/static-images?pageKey=…`. Match by **key**. Use all images if `kind=list`, first if `single`. `url` → `<img>`, `linkUrl` → wrap link, `titleFa` / `titleEn` / `subtext` per UI. Empty/fail → local placeholder. Prefer API `aspectRatio`. **If a frame’s size or ratio changes in development, update the matching catalog entry in the same change.**
|
||||||
|
|
||||||
|
| key | pageKey | kind | itemCount | aspectRatio | width | UI |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `slider` | home | list | null | `16:9` | 1920 | Full-viewport `HeroSlider` |
|
||||||
|
| `four-category` | home | list | 4 | `3:4` | 900 | Four hover-expand service panels (`titleFa` / `titleEn` / `subtext`) |
|
||||||
|
| `project-images` | home | list | null | `3:4` (masonry free height) | 1200 | Selected projects masonry |
|
||||||
|
| `sticky-frame-bg` | home | single | 1 | `16:9` | 1920 | `OpeningFrame` background |
|
||||||
|
| `instagram-row` | home | single | 1 | `9:16` | 720 | `InstagramFollow` phone frame (also used on contact) |
|
||||||
|
| `brands` | home | list | null | `3:2` | 480 | Partner logos row (hidden when empty) |
|
||||||
|
| `about-us-image` | about | list | 2 | `16:9` | 1600 | About hero (1st) + secondary (2nd) |
|
||||||
|
|
||||||
|
## Tags & badges (CMS)
|
||||||
|
From `GET /tenants/{domain}/website/business-info`:
|
||||||
|
1. `metaTags: [{ name, content }]` → root `<CmsMeta>` + `generateMetadata.other`
|
||||||
|
2. `trustBadges: [{ kind, embedHtml }]` → footer `dangerouslySetInnerHTML` (scripts stripped). Empty → omit. Do not hardcode eNamad/Samandehi.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
Shared cookies `meshkee_customer_*`; login → customer portal; `AccountMenu` shows `{name} عزیز` + پروفایل من / خروج / مدیریت وبسایت (admin + handoff).
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
| Route | Purpose |
|
| Route | Notes |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/` | Hero (title + CMS hero image), intro, 4 services, 6 selected projects, studio block, 4-step process (dark), stats, CTA |
|
| `/` | Slider, intro, four-category, project-images, sticky frame, process, stats, brands, Instagram, CTA |
|
||||||
| `/about` | Page hero image, story (3 paragraphs from `business-info.about` or fallback), 3-image gallery, values, services list, stats, CTA |
|
| `/portfolio`, `/portfolio/[slug]` | Meshkee portfolios (+ local fallback); lightbox + zoom + similar |
|
||||||
| `/contact` | Facts (phones, email, Instagram, address), contact image, form → `POST /api/contact` |
|
| `/projects*` | Redirect → `/portfolio` |
|
||||||
| `/api/contact` | Proxies to `POST /tenants/{domain}/contact-submissions` (`title`, `name`, `text` required; `email`, `cellNumber` optional) |
|
| `/blog`, `/blog/[slug]` | Meshkee blogs (+ local stubs) |
|
||||||
| `/meshkee/static-image-slots` | Slot catalog (`STATIC_IMAGE_SLOTS`) for the dashboard “Refresh slots” |
|
| `/services*` | Local specialties |
|
||||||
| `/meshkee/sitemap-config.json` | Static pages for the Meshkee-generated sitemap |
|
| `/about` | `about-us-image` (2), story, values, stats, CTA |
|
||||||
|
| `/contact` | Facts + form (API + toast) + Instagram + dark map |
|
||||||
|
| `/api/contact`, `/api/auth/me`, `/api/auth/handoff` | Proxies |
|
||||||
|
| `/meshkee/*` | Slot catalog + sitemap-config |
|
||||||
|
|
||||||
No `/login`, `/register`, `/cart`, `/checkout`, no local `sitemap.xml` / `robots.txt` (nginx proxies those to the API).
|
## Components
|
||||||
|
`Header`/`AccountMenu`, `Footer`, `Providers`, `CmsMeta`, `SlotImage`, `SectionTitle`, `OpeningFrame`, `HeroSlider`, `InstagramFollow`, `MediaGallery`, `ContactForm`
|
||||||
## Meshkee integration (`src/lib/`)
|
|
||||||
- `config.ts` — domain / API base / portal URLs
|
|
||||||
- `meshkee.ts` — `meshkeeGet` (ISR 300s, returns fallback on any error), `getBusinessInfo`, `getFavicon`
|
|
||||||
- `static-images.ts` — slot catalog + `getPageSlots(pageKey)` (parses `{ items: [{ key, aspectRatio, images[] }] }`), `slotImages`, `slotRatio`
|
|
||||||
- `content.ts` — resolves name / logo / phones / emails / address / Instagram / about from `business-info` with fallbacks from `src/data/site.ts`; `parseHeadTag`, `stripScripts`, digit helpers
|
|
||||||
- Root layout: `metaTags` → `<CmsMeta>` in `<head>`; `schema.jsonLd` → `<JsonLd>` when `schema.enabled`; `trustBadges[].embedHtml` → footer (scripts stripped)
|
|
||||||
- Favicon: `GET /website/favicon` → `metadata.icons`; falls back to `src/app/icon.png`
|
|
||||||
|
|
||||||
### Static image slots
|
|
||||||
Fetch per page: `GET /tenants/{domain}/website/static-images?pageKey=…`. Empty/fail → local images from `gallery` in `src/data/site.ts`.
|
|
||||||
`url` → `<img>`, `linkUrl` → wrap in `<a>`, `titleFa`/`titleEn`/`subtext` → captions where the UI shows them.
|
|
||||||
Use the API `aspectRatio` first, catalog second. **If a frame’s size or ratio changes in development, update `STATIC_IMAGE_SLOTS` in the same change.**
|
|
||||||
|
|
||||||
| key | page | kind | itemCount | aspectRatio | width | UI |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| `hero` | home | single | 1 | `16:9` | 1920 | Hero frame; `titleFa` shown in the caption row |
|
|
||||||
| `projects` | home | list | null | `4:5` | 1200 | Selected projects grid; `titleFa` = title, `titleEn` = Latin subtitle, `subtext` = meta (e.g. category · year). When empty → 6 local `projects` |
|
|
||||||
| `studio-image` | home | single | 1 | `4:3` | 1400 | “نگاه ما” block image |
|
|
||||||
| `about-hero` | about | single | 1 | `16:9` | 1920 | About page hero |
|
|
||||||
| `about-gallery` | about | list | 3 | `1:1` | 900 | 3-up square gallery |
|
|
||||||
| `contact-image` | contact | single | 1 | `4:5` | 1200 | Image under the contact facts |
|
|
||||||
|
|
||||||
## Local content / fallbacks (`src/data/site.ts`)
|
|
||||||
- `site` — names (`استودیو معماری هوم` / `HOM arch studio`), tagline, description, placeholder phone `۰۹۱۲ ۰۰۰ ۰۰۰۰`, email `info@homgroup.com`, Instagram `@hom.archstudio`, address `تهران`, 3 about paragraphs. **Replace placeholders once the business profile exists in Meshkee** (they are overridden automatically by `business-info` when present).
|
|
||||||
- `services` (4), `processSteps` (4), `stats` (4), `projects` (6), `gallery` (image paths), `NAV_LINKS`
|
|
||||||
- Images: `public/images/*.jpg` — resized (≤1800px, q82) from `assets/images/` (originals kept out of git). Mapping: `facade-sunset*` ← IMG_1455/1456/1458, `courtyard-brick*` ← IMG_0356/0357, `kitchen-cream*` ← IMG_0374/0375, `study-terracotta*` ← IMG_0376/0377, `kitchen-oak*` ← IMG_0394/0396, `facade-louver` ← IMG_0400, `garden-gate` ← IMG_1037
|
|
||||||
|
|
||||||
## SEO
|
|
||||||
- Unique `<title>` / description per page (`metadata` in each `page.tsx`; template `%s | {name}`)
|
|
||||||
- One `<h1>` per page; content images have `alt`
|
|
||||||
- Static pages published through `/meshkee/sitemap-config.json`; do not add `app/sitemap.ts` / `app/robots.ts`
|
|
||||||
|
|
||||||
## Components (`src/components/`)
|
|
||||||
`Header` (client; sticky, burger), `Footer`, `CmsMeta` (+ `JsonLd`), `SlotImage` (flat frame for slot/fallback images), `ContactForm` (client; posts to `/api/contact`, Persian status messages)
|
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
Standard Meshkee websites VM flow: push → Super Admin **Deploy**. Deploy log must say `standalone build detected`; PM2 script `.next/standalone/server.js`.
|
Standalone build on Meshkee websites VM; PM2 → `.next/standalone/server.js`.
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
- 2026-10-11 — Initial build: home / about / contact, Meshkee business-info + static-image slots + contact form, standalone runtime, sitemap-config generator.
|
- 2026-10-12 — Placeholders prompt: remapped slots (`slider`, `four-category`, `project-images`, `sticky-frame-bg`, `instagram-row`, `brands`, `about-us-image`); brands row; metaTags → `metadata.other`.
|
||||||
|
- 2026-10-12 — Backend prompt: auth, portfolio/blog, contact toast, Instagram frame, domain `homgroup.ir`.
|
||||||
|
- 2026-10-11 — Initial site scaffold.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 365 KiB |
Binary file not shown.
@@ -9,7 +9,13 @@ const appDir = join(root, "src", "app");
|
|||||||
const domain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.com";
|
const domain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.com";
|
||||||
|
|
||||||
const EXCLUDE = /^(api|meshkee|login|register|cart|checkout|account|admin)(\/|$)/;
|
const EXCLUDE = /^(api|meshkee|login|register|cart|checkout|account|admin)(\/|$)/;
|
||||||
const PRIORITY = { "/": 1.0, "/about": 0.6, "/contact": 0.5 };
|
const PRIORITY = {
|
||||||
|
"/": 1.0,
|
||||||
|
"/projects": 0.8,
|
||||||
|
"/services": 0.8,
|
||||||
|
"/about": 0.6,
|
||||||
|
"/contact": 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
function walk(dir, out = []) {
|
function walk(dir, out = []) {
|
||||||
for (const entry of readdirSync(dir)) {
|
for (const entry of readdirSync(dir)) {
|
||||||
|
|||||||
+79
-30
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
import { SlotImage } from "@/components/SlotImage";
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { gallery, services, site, stats } from "@/data/site";
|
import { gallery, services, site, stats } from "@/data/site";
|
||||||
import { resolveAbout, resolveName } from "@/lib/content";
|
import { resolveAbout, resolveName } from "@/lib/content";
|
||||||
@@ -17,14 +18,32 @@ const values = [
|
|||||||
{
|
{
|
||||||
title: "سادگی",
|
title: "سادگی",
|
||||||
text: "حذف اضافات تا آنچه میماند، ضروری و خوانا باشد. سادگی برای ما نتیجه دقت است، نه کمکاری.",
|
text: "حذف اضافات تا آنچه میماند، ضروری و خوانا باشد. سادگی برای ما نتیجه دقت است، نه کمکاری.",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
|
||||||
|
<circle cx="20" cy="20" r="11" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "تناسب",
|
title: "تناسب",
|
||||||
text: "هر طرح در نسبت با زمین، نور، بودجه و شیوه زندگی کارفرما شکل میگیرد؛ نه بر اساس یک الگوی ثابت.",
|
text: "هر طرح در نسبت با زمین، نور، بودجه و شیوه زندگی کارفرما شکل میگیرد؛ نه بر اساس یک الگوی ثابت.",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
|
||||||
|
<rect x="8" y="8" width="24" height="24" />
|
||||||
|
<path d="M8 18h24M18 8v24" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "اجرای دقیق",
|
title: "اجرای دقیق",
|
||||||
text: "طرح فقط روی کاغذ معنا ندارد. جزئیات اجرایی و نظارت مستمر، تضمین میکند آنچه ساخته میشود همان طرح باشد.",
|
text: "طرح فقط روی کاغذ معنا ندارد. جزئیات اجرایی و نظارت مستمر، تضمین میکند آنچه ساخته میشود همان طرح باشد.",
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
|
||||||
|
<path d="M12 28 20 10l8 18" />
|
||||||
|
<path d="M15 22h10" />
|
||||||
|
<circle cx="20" cy="30" r="2" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -32,31 +51,43 @@ export default async function AboutPage() {
|
|||||||
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("about")]);
|
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("about")]);
|
||||||
const name = resolveName(info);
|
const name = resolveName(info);
|
||||||
const about = resolveAbout(info);
|
const about = resolveAbout(info);
|
||||||
const hero = slotImages("about-hero", slots, [{ url: gallery.aboutHero, titleFa: "خانه حیاطدار" }])[0];
|
const aboutImages = slotImages("about-us-image", slots, gallery.aboutImages);
|
||||||
const images = slotImages(
|
const hero = aboutImages[0];
|
||||||
"about-gallery",
|
const galleryImages = aboutImages.slice(1);
|
||||||
slots,
|
|
||||||
gallery.aboutGallery.map((url, index) => ({ url, titleFa: `نمونه کار ${index + 1}` })),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-hero">
|
<section className="page-hero">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p className="eyebrow">درباره ما</p>
|
<SectionTitle
|
||||||
<h1>{name}؛ گروه معماری و طراحی داخلی</h1>
|
en="about"
|
||||||
|
title={`${name}؛`}
|
||||||
|
titleMuted="گروه معماری و طراحی داخلی"
|
||||||
|
as="h1"
|
||||||
|
className="section-title--inline"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<SlotImage image={hero} alt={`نمونه پروژه ${name}`} ratio={slotRatio("about-hero", slots)} className="page-hero__frame" priority />
|
<SlotImage
|
||||||
|
image={hero}
|
||||||
|
alt={hero.titleFa || hero.titleEn || `نمونه پروژه ${name}`}
|
||||||
|
ratio={slotRatio("about-us-image", slots)}
|
||||||
|
className="page-hero__frame"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container split">
|
<div className="container split">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">داستان ما</p>
|
<SectionTitle
|
||||||
<h2 className="title">معماری از گوش دادن شروع میشود</h2>
|
en="story"
|
||||||
|
title="معماری از گوش دادن"
|
||||||
|
titleMuted="شروع میشود"
|
||||||
|
className="section-title--compact"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="about-text">
|
<div className="about-text">
|
||||||
{about.map((paragraph, index) => (
|
{about.map((paragraph, index) => (
|
||||||
@@ -66,24 +97,33 @@ export default async function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{galleryImages.length ? (
|
||||||
<section className="section--tight">
|
<section className="section--tight">
|
||||||
<div className="container about-gallery">
|
<div className="container about-gallery about-gallery--pair">
|
||||||
{images.slice(0, 3).map((image, index) => (
|
{galleryImages.slice(0, 1).map((image, index) => (
|
||||||
<SlotImage key={`${image.url}-${index}`} image={image} alt={`نمونه کار ${name}`} ratio={slotRatio("about-gallery", slots)} />
|
<SlotImage
|
||||||
|
key={`${image.url}-${index}`}
|
||||||
|
image={image}
|
||||||
|
alt={image.titleFa || image.titleEn || `نمونه کار ${name}`}
|
||||||
|
ratio={slotRatio("about-us-image", slots)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="section section--surface">
|
<section className="section section--surface">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p className="eyebrow">ارزشها</p>
|
<SectionTitle en="values" title="آنچه در هر پروژه" titleMuted="به آن پایبندیم" />
|
||||||
<h2 className="title">آنچه در هر پروژه به آن پایبندیم</h2>
|
|
||||||
<div className="values">
|
<div className="values">
|
||||||
{values.map((value) => (
|
{values.map((value) => (
|
||||||
<div key={value.title} className="value">
|
<div key={value.title} className="value">
|
||||||
|
<span className="value__icon">{value.icon}</span>
|
||||||
|
<div className="value__body">
|
||||||
<h3>{value.title}</h3>
|
<h3>{value.title}</h3>
|
||||||
<p>{value.text}</p>
|
<p>{value.text}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,18 +132,22 @@ export default async function AboutPage() {
|
|||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container split">
|
<div className="container split">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">خدمات</p>
|
<SectionTitle
|
||||||
<h2 className="title">از نما تا جزئیات داخلی</h2>
|
en="services"
|
||||||
|
title="از نما تا"
|
||||||
|
titleMuted="جزئیات داخلی"
|
||||||
|
className="section-title--compact"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="about-text">
|
<div className="about-text">
|
||||||
<p>
|
<p>
|
||||||
خدمات {site.shortFa} چهار حوزه اصلی را پوشش میدهد و در هر پروژه، بسته به نیاز شما، یک یا چند مورد از آنها
|
خدمات {site.shortFa} چهار حوزه اصلی را پوشش میدهد و در هر پروژه، بسته به نیاز شما، یک یا چند مورد از آنها
|
||||||
کنار هم قرار میگیرند.
|
کنار هم قرار میگیرند.
|
||||||
</p>
|
</p>
|
||||||
<ul style={{ margin: 0, paddingInlineStart: 20, display: "grid", gap: 10 }}>
|
<ul>
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<li key={service.index}>
|
<li key={service.index}>
|
||||||
<strong style={{ color: "var(--fg)", fontWeight: 500 }}>{service.titleFa}</strong> — {service.text}
|
<strong>{service.titleFa}</strong> — {service.text}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -111,31 +155,36 @@ export default async function AboutPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="section--tight">
|
<section className="stats-band" aria-label="آمار استودیو">
|
||||||
<div className="container">
|
|
||||||
<div className="stats">
|
<div className="stats">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="stat">
|
<div key={stat.label} className="stat">
|
||||||
<strong>{stat.value}</strong>
|
<strong className="latin" lang="en" dir="ltr">
|
||||||
|
{stat.value}
|
||||||
|
</strong>
|
||||||
<span>{stat.label}</span>
|
<span>{stat.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="section">
|
<section className="cta-band">
|
||||||
|
<div className="cta-band__glow" aria-hidden="true" />
|
||||||
<div className="container cta">
|
<div className="container cta">
|
||||||
<div>
|
<SectionTitle
|
||||||
<p className="eyebrow">همکاری</p>
|
en="collaborate"
|
||||||
<h2>آمادهایم درباره پروژه شما صحبت کنیم.</h2>
|
title="آمادهایم درباره پروژه شما"
|
||||||
</div>
|
titleMuted="صحبت کنیم."
|
||||||
<Link href="/contact" className="btn">
|
className="section-title--inline section-title--light"
|
||||||
|
aside={
|
||||||
|
<Link href="/contact" className="btn btn--light">
|
||||||
تماس با ما
|
تماس با ما
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
<path d="M4 12h16M13 5l7 7-7 7" />
|
<path d="M4 12h16M13 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const token = getBearerToken(request);
|
||||||
|
if (!token) return unauthorizedResponse();
|
||||||
|
|
||||||
|
const res = await meshkeeFetch("/auth/handoff", {
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(res.data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const token = getBearerToken(request);
|
||||||
|
if (!token) return unauthorizedResponse();
|
||||||
|
|
||||||
|
const res = await meshkeeFetch("/auth/me", { token });
|
||||||
|
if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status);
|
||||||
|
return jsonResponse(res.data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
import {
|
||||||
|
asBlogHtml,
|
||||||
|
blogHref,
|
||||||
|
blogImage,
|
||||||
|
fetchBlogById,
|
||||||
|
fetchBlogBySlug,
|
||||||
|
fetchBlogs,
|
||||||
|
formatBlogDate,
|
||||||
|
getLocalBlogPost,
|
||||||
|
getLocalBlogPosts,
|
||||||
|
type BlogPost,
|
||||||
|
} from "@/lib/blog";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
async function resolveBlog(slug: string): Promise<BlogPost | null> {
|
||||||
|
const decoded = decodeURIComponent(slug);
|
||||||
|
const bySlug = await fetchBlogBySlug(decoded);
|
||||||
|
if (bySlug) return bySlug;
|
||||||
|
const byId = await fetchBlogById(decoded);
|
||||||
|
if (byId) return byId;
|
||||||
|
return getLocalBlogPost(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = await resolveBlog(slug);
|
||||||
|
if (!post) return { title: "مطلب پیدا نشد" };
|
||||||
|
const description = post.abstract || post.title;
|
||||||
|
const image = blogImage(post);
|
||||||
|
return {
|
||||||
|
title: post.title,
|
||||||
|
description,
|
||||||
|
openGraph: { title: post.title, description, images: [{ url: image, alt: post.title }] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BlogDetailPage({ params }: PageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const post = await resolveBlog(slug);
|
||||||
|
if (!post) notFound();
|
||||||
|
|
||||||
|
const html = asBlogHtml(post.mainTextHtml || post.abstract);
|
||||||
|
const date = formatBlogDate(post.publishedAt || post.createdAt);
|
||||||
|
const related = await fetchBlogs({ page: 1, pageSize: 6 });
|
||||||
|
const pool = related.items.length ? related.items : getLocalBlogPosts();
|
||||||
|
const similar = pool.filter((item) => item.id !== post.id).slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero">
|
||||||
|
<div className="container">
|
||||||
|
<SectionTitle
|
||||||
|
en="article"
|
||||||
|
title={post.title}
|
||||||
|
as="h1"
|
||||||
|
className="section-title--inline"
|
||||||
|
aside={date ? <p className="page-hero__aside latin">{date}</p> : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section--tight">
|
||||||
|
<div className="container blog-detail">
|
||||||
|
<div className="blog-detail__media frame">
|
||||||
|
<img src={blogImage(post)} alt={post.title} />
|
||||||
|
</div>
|
||||||
|
<div className="blog-detail__copy">
|
||||||
|
{post.abstract ? <p className="lead">{post.abstract}</p> : null}
|
||||||
|
{html ? <div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} /> : null}
|
||||||
|
<Link className="text-link" href="/blog">
|
||||||
|
بازگشت به بلاگ
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{similar.length ? (
|
||||||
|
<section className="section section--surface">
|
||||||
|
<div className="container">
|
||||||
|
<SectionTitle en="more" title="مطالب" titleMuted="دیگر" />
|
||||||
|
<div className="blog-grid">
|
||||||
|
{similar.map((item) => (
|
||||||
|
<Link key={item.id} href={blogHref(item)} className="blog-card">
|
||||||
|
<div className="blog-card__media frame">
|
||||||
|
<img src={blogImage(item)} alt={item.title} loading="lazy" decoding="async" />
|
||||||
|
</div>
|
||||||
|
<div className="blog-card__body">
|
||||||
|
<h2>{item.title}</h2>
|
||||||
|
{item.abstract ? <p>{item.abstract}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
import { site } from "@/data/site";
|
||||||
|
import {
|
||||||
|
blogHref,
|
||||||
|
blogImage,
|
||||||
|
fetchBlogs,
|
||||||
|
formatBlogDate,
|
||||||
|
getLocalBlogPosts,
|
||||||
|
} from "@/lib/blog";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "بلاگ",
|
||||||
|
description: `مقالات و یادداشتهای ${site.nameFa} درباره معماری و طراحی داخلی.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BlogPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}) {
|
||||||
|
const { page: pageRaw } = await searchParams;
|
||||||
|
const page = Math.max(1, Number(pageRaw) || 1);
|
||||||
|
const pageSize = 12;
|
||||||
|
const result = await fetchBlogs({ page, pageSize });
|
||||||
|
const usingApi = result.items.length > 0;
|
||||||
|
const posts = usingApi ? result.items : page === 1 ? getLocalBlogPosts() : [];
|
||||||
|
const total = usingApi ? result.total : posts.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero">
|
||||||
|
<div className="container">
|
||||||
|
<SectionTitle en="blog" title="یادداشتهای" titleMuted="استودیو" as="h1" className="section-title--inline" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section--tight">
|
||||||
|
<div className="container">
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p className="empty-state">هنوز مطلبی منتشر نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<div className="blog-grid">
|
||||||
|
{posts.map((post) => {
|
||||||
|
const date = formatBlogDate(post.publishedAt || post.createdAt);
|
||||||
|
return (
|
||||||
|
<Link key={post.id} href={blogHref(post)} className="blog-card">
|
||||||
|
<div className="blog-card__media frame">
|
||||||
|
<img src={blogImage(post)} alt={post.title} loading="lazy" decoding="async" />
|
||||||
|
</div>
|
||||||
|
<div className="blog-card__body">
|
||||||
|
{date ? <span className="blog-card__date latin">{date}</span> : null}
|
||||||
|
<h2>{post.title}</h2>
|
||||||
|
{post.abstract ? <p>{post.abstract}</p> : null}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && posts.length ? (
|
||||||
|
<nav className="pager" aria-label="صفحهبندی">
|
||||||
|
{page > 1 ? <Link href={`/blog?page=${page - 1}`}>قبلی</Link> : <span />}
|
||||||
|
<span className="latin">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages ? <Link href={`/blog?page=${page + 1}`}>بعدی</Link> : <span />}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+121
-34
@@ -1,8 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { ContactForm } from "@/components/ContactForm";
|
import { ContactForm } from "@/components/ContactForm";
|
||||||
import { SlotImage } from "@/components/SlotImage";
|
import { ContactMap } from "@/components/ContactMap";
|
||||||
import { gallery } from "@/data/site";
|
import { InstagramFollow } from "@/components/InstagramFollow";
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
import { gallery, site } from "@/data/site";
|
||||||
import {
|
import {
|
||||||
resolveAddress,
|
resolveAddress,
|
||||||
resolveEmails,
|
resolveEmails,
|
||||||
@@ -19,72 +22,156 @@ export const metadata: Metadata = {
|
|||||||
"برای مشاوره طراحی معماری، طراحی داخلی یا طراحی نما با استودیو معماری هوم تماس بگیرید یا فرم درخواست پروژه را ارسال کنید.",
|
"برای مشاوره طراحی معماری، طراحی داخلی یا طراحی نما با استودیو معماری هوم تماس بگیرید یا فرم درخواست پروژه را ارسال کنید.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function IconPhone() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M8.5 4.5h3l1.5 4-2 1.2a12 12 0 0 0 5.3 5.3l1.2-2 4 1.5v3A2 2 0 0 1 19.5 19.5 15 15 0 0 1 4.5 4.5 2 2 0 0 1 6.5 2.5h2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconMail() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<rect x="3.5" y="5.5" width="17" height="13" rx="1.5" />
|
||||||
|
<path d="m4.5 7.5 7.5 6 7.5-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconInstagram() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="4" />
|
||||||
|
<circle cx="12" cy="12" r="3.5" />
|
||||||
|
<circle cx="17.2" cy="6.8" r="0.9" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconPin() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M12 21s6.5-5.2 6.5-10.2A6.5 6.5 0 0 0 5.5 10.8C5.5 15.8 12 21 12 21Z" />
|
||||||
|
<circle cx="12" cy="10.5" r="2.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fact({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`fact ${className}`.trim()}>
|
||||||
|
<span className="fact__icon" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<div className="fact__body">
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ContactPage() {
|
export default async function ContactPage() {
|
||||||
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("contact")]);
|
const [info, homeSlots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]);
|
||||||
const name = resolveName(info);
|
const name = resolveName(info);
|
||||||
const phones = resolvePhones(info);
|
const phones = resolvePhones(info);
|
||||||
const emails = resolveEmails(info);
|
const emails = resolveEmails(info);
|
||||||
const instagram = resolveInstagram(info);
|
const instagram = resolveInstagram(info);
|
||||||
const address = resolveAddress(info);
|
const address = resolveAddress(info);
|
||||||
const image = slotImages("contact-image", slots, [{ url: gallery.contact, titleFa: "آشپزخانه کرم" }])[0];
|
const instagramImage = slotImages("instagram-row", homeSlots, [
|
||||||
|
{
|
||||||
|
url: gallery.instagram,
|
||||||
|
titleFa: "ما را در اینستاگرام دنبال کنید",
|
||||||
|
titleEn: "Follow us",
|
||||||
|
subtext: "پروژههای تازه و جزئیات اجرا را در صفحه اینستاگرام ببینید.",
|
||||||
|
linkUrl: instagram.href,
|
||||||
|
},
|
||||||
|
])[0];
|
||||||
|
const instagramRatio = slotRatio("instagram-row", homeSlots);
|
||||||
|
const { lat, lng, zoom } = site.map;
|
||||||
|
const mapLink = `https://www.google.com/maps?q=${encodeURIComponent(`${lat},${lng}`)}&z=${zoom}&hl=fa`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="page-hero">
|
<section className="contact-hero">
|
||||||
<div className="container">
|
<div className="contact-hero__glow" aria-hidden="true" />
|
||||||
<p className="eyebrow">تماس با ما</p>
|
<div className="container contact-hero__inner">
|
||||||
<h1>درباره پروژهتان صحبت کنیم</h1>
|
<SectionTitle
|
||||||
|
en="contact"
|
||||||
|
title="درباره پروژهتان"
|
||||||
|
titleMuted="صحبت کنیم"
|
||||||
|
as="h1"
|
||||||
|
className="section-title--inline"
|
||||||
|
aside={
|
||||||
|
<p className="contact-hero__lead">
|
||||||
|
پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس میگیرد.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="section--tight">
|
<section className="section">
|
||||||
<div className="container contact-grid">
|
<div className="container contact-grid">
|
||||||
<div className="contact-facts">
|
<div className="contact-facts">
|
||||||
<p className="lead">
|
<Fact icon={<IconPhone />} label="تلفن" className="fact--phones">
|
||||||
پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس میگیرد.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="fact fact--phones">
|
|
||||||
<span>تلفن</span>
|
|
||||||
{phones.map((phone) => (
|
{phones.map((phone) => (
|
||||||
<a key={phone.href} href={phone.href}>
|
<a key={phone.href} href={phone.href} className="latin" dir="ltr">
|
||||||
{phone.display}
|
{phone.display}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Fact>
|
||||||
|
|
||||||
<div className="fact">
|
<Fact icon={<IconMail />} label="ایمیل">
|
||||||
<span>ایمیل</span>
|
|
||||||
{emails.map((email) => (
|
{emails.map((email) => (
|
||||||
<a key={email} href={`mailto:${email}`} dir="ltr" style={{ textAlign: "right" }}>
|
<a key={email} href={`mailto:${email}`} className="latin" dir="ltr">
|
||||||
{email}
|
{email}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Fact>
|
||||||
|
|
||||||
<div className="fact">
|
<Fact icon={<IconInstagram />} label="اینستاگرام">
|
||||||
<span>اینستاگرام</span>
|
<a href={instagram.href} target="_blank" rel="noreferrer" className="latin" dir="ltr">
|
||||||
<a href={instagram.href} target="_blank" rel="noreferrer" dir="ltr" style={{ textAlign: "right" }}>
|
|
||||||
{instagram.handle}
|
{instagram.handle}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</Fact>
|
||||||
|
|
||||||
<div className="fact">
|
<Fact icon={<IconPin />} label="آدرس">
|
||||||
<span>آدرس</span>
|
|
||||||
<strong>{address}</strong>
|
<strong>{address}</strong>
|
||||||
|
</Fact>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SlotImage image={image} alt={`نمونه پروژه ${name}`} ratio={slotRatio("contact-image", slots)} className="contact-frame" />
|
<div className="contact-form-panel">
|
||||||
</div>
|
<SectionTitle
|
||||||
|
en="inquiry"
|
||||||
<div>
|
title="فرم درخواست"
|
||||||
<h2 className="title" style={{ marginBottom: 32 }}>
|
titleMuted="پروژه"
|
||||||
فرم درخواست پروژه
|
className="section-title--compact"
|
||||||
</h2>
|
/>
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<InstagramFollow
|
||||||
|
handle={instagram.handle}
|
||||||
|
href={instagram.href}
|
||||||
|
image={instagramImage}
|
||||||
|
ratio={instagramRatio}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ContactMap lat={lat} lng={lng} zoom={zoom} address={address} mapLink={mapLink} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2742
-189
File diff suppressed because it is too large
Load Diff
+23
-4
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Montserrat } from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
import { CmsMeta, JsonLd } from "@/components/CmsMeta";
|
import { CmsMeta, JsonLd } from "@/components/CmsMeta";
|
||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
|
import { Providers } from "@/components/Providers";
|
||||||
import { site } from "@/data/site";
|
import { site } from "@/data/site";
|
||||||
import { siteUrl } from "@/lib/config";
|
import { siteUrl } from "@/lib/config";
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +16,7 @@ import {
|
|||||||
resolveName,
|
resolveName,
|
||||||
resolvePhones,
|
resolvePhones,
|
||||||
} from "@/lib/content";
|
} from "@/lib/content";
|
||||||
import { getBusinessInfo, getFavicon } from "@/lib/meshkee";
|
import { getBusinessId, getBusinessInfo, getFavicon } from "@/lib/meshkee";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -29,10 +31,24 @@ const yekan = localFont({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const montserrat = Montserrat({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
|
variable: "--font-montserrat",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const [favicon, info] = await Promise.all([getFavicon(), getBusinessInfo()]);
|
const [favicon, info] = await Promise.all([getFavicon(), getBusinessInfo()]);
|
||||||
const name = resolveName(info);
|
const name = resolveName(info);
|
||||||
const icons = favicon.faviconUrl ? [{ url: favicon.faviconUrl, type: "image/png" }] : undefined;
|
const icons = favicon.faviconUrl
|
||||||
|
? [{ url: favicon.faviconUrl, type: "image/png" }]
|
||||||
|
: [{ url: "/icon.png", type: "image/png" }];
|
||||||
|
const otherMeta = Object.fromEntries(
|
||||||
|
(info.metaTags ?? [])
|
||||||
|
.filter((tag) => tag.name && tag.content != null && !tag.html)
|
||||||
|
.map((tag) => [tag.name as string, tag.content as string]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL(siteUrl),
|
metadataBase: new URL(siteUrl),
|
||||||
@@ -42,6 +58,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
},
|
},
|
||||||
description: site.description,
|
description: site.description,
|
||||||
icons,
|
icons,
|
||||||
|
...(Object.keys(otherMeta).length ? { other: otherMeta } : {}),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "fa_IR",
|
locale: "fa_IR",
|
||||||
@@ -54,7 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
const info = await getBusinessInfo();
|
const [info, businessId] = await Promise.all([getBusinessInfo(), getBusinessId()]);
|
||||||
const name = resolveName(info);
|
const name = resolveName(info);
|
||||||
const logo = resolveLogo(info);
|
const logo = resolveLogo(info);
|
||||||
const phones = resolvePhones(info);
|
const phones = resolvePhones(info);
|
||||||
@@ -62,11 +79,12 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
|||||||
const jsonLd = info.schema?.enabled ? info.schema.jsonLd : null;
|
const jsonLd = info.schema?.enabled ? info.schema.jsonLd : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fa" dir="rtl" className={yekan.variable}>
|
<html lang="fa" dir="rtl" className={`${yekan.variable} ${montserrat.variable}`}>
|
||||||
<head>
|
<head>
|
||||||
<CmsMeta tags={info.metaTags ?? []} />
|
<CmsMeta tags={info.metaTags ?? []} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<Providers businessId={businessId}>
|
||||||
<JsonLd data={jsonLd} />
|
<JsonLd data={jsonLd} />
|
||||||
<Header logo={logo} name={name} phone={phones[0] ?? null} instagram={instagram} />
|
<Header logo={logo} name={name} phone={phones[0] ?? null} instagram={instagram} />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
@@ -79,6 +97,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
|||||||
instagram={instagram}
|
instagram={instagram}
|
||||||
badges={info.trustBadges ?? []}
|
badges={info.trustBadges ?? []}
|
||||||
/>
|
/>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "صفحه پیدا نشد",
|
title: "صفحه پیدا نشد",
|
||||||
description: "صفحهای که دنبال آن بودید وجود ندارد.",
|
description: "صفحهای که دنبال آن بودید وجود ندارد.",
|
||||||
@@ -10,10 +12,7 @@ export const metadata: Metadata = {
|
|||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<section className="container notfound">
|
<section className="container notfound">
|
||||||
<p className="eyebrow" style={{ justifyContent: "center" }}>
|
<SectionTitle en="404" title="صفحه مورد نظر پیدا نشد" as="h1" className="section-title--inline" />
|
||||||
۴۰۴
|
|
||||||
</p>
|
|
||||||
<h1>صفحه مورد نظر پیدا نشد</h1>
|
|
||||||
<p className="lead" style={{ marginInline: "auto" }}>
|
<p className="lead" style={{ marginInline: "auto" }}>
|
||||||
احتمالاً آدرس اشتباه است یا این صفحه جابهجا شده.
|
احتمالاً آدرس اشتباه است یا این صفحه جابهجا شده.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+205
-121
@@ -1,9 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { DetailsFrame } from "@/components/DetailsFrame";
|
||||||
|
import { HeroSlider } from "@/components/HeroSlider";
|
||||||
|
import { InstagramFollow } from "@/components/InstagramFollow";
|
||||||
|
import { OpeningFrame } from "@/components/OpeningFrame";
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
import { SlotImage } from "@/components/SlotImage";
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
import { gallery, processSteps, projects, services, site, stats } from "@/data/site";
|
import { gallery, processSteps, projects, services, site, stats } from "@/data/site";
|
||||||
import { resolveAbout, resolveName } from "@/lib/content";
|
import { resolveAbout, resolveInstagram, resolveLogo, resolveName } from "@/lib/content";
|
||||||
import { getBusinessInfo } from "@/lib/meshkee";
|
import { getBusinessInfo } from "@/lib/meshkee";
|
||||||
import { getPageSlots, slotImages, slotRatio } from "@/lib/static-images";
|
import { getPageSlots, slotImages, slotRatio } from "@/lib/static-images";
|
||||||
|
|
||||||
@@ -22,47 +27,68 @@ export default async function HomePage() {
|
|||||||
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]);
|
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]);
|
||||||
const name = resolveName(info);
|
const name = resolveName(info);
|
||||||
const about = resolveAbout(info);
|
const about = resolveAbout(info);
|
||||||
|
const logo = resolveLogo(info);
|
||||||
|
const instagram = resolveInstagram(info);
|
||||||
|
|
||||||
const hero = slotImages("hero", slots, [{ url: gallery.hero, titleFa: "نمای آجری در غروب" }])[0];
|
const heroSlides = slotImages("slider", slots, gallery.heroSlides);
|
||||||
const studio = slotImages("studio-image", slots, [{ url: gallery.studio, titleFa: "ورودی سبز" }])[0];
|
const categoryImages = slotImages(
|
||||||
const projectSlots = slots.projects?.images ?? [];
|
"four-category",
|
||||||
|
slots,
|
||||||
|
services.map((service) => ({
|
||||||
|
url: service.image,
|
||||||
|
titleFa: service.titleFa,
|
||||||
|
titleEn: service.titleEn,
|
||||||
|
subtext: service.text,
|
||||||
|
linkUrl: `/services/${service.slug}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const projectSlots = slotImages(
|
||||||
|
"project-images",
|
||||||
|
slots,
|
||||||
|
projects.map((project) => ({
|
||||||
|
url: project.image,
|
||||||
|
titleFa: project.titleFa,
|
||||||
|
titleEn: project.titleEn,
|
||||||
|
subtext: `${project.category} · ${project.year}`,
|
||||||
|
linkUrl: `/portfolio/${project.slug}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const sticky = slotImages("sticky-frame-bg", slots, [
|
||||||
|
{ url: gallery.studio, titleFa: "ورودی سبز", titleEn: "Studio", subtext: about[2] ?? site.about[2] },
|
||||||
|
])[0];
|
||||||
|
const openingImage = sticky.url || gallery.studio;
|
||||||
|
const instagramImage = slotImages("instagram-row", slots, [
|
||||||
|
{
|
||||||
|
url: gallery.instagram,
|
||||||
|
titleFa: "ما را در اینستاگرام دنبال کنید",
|
||||||
|
titleEn: "Follow us",
|
||||||
|
subtext: "پروژههای تازه و جزئیات اجرا را در صفحه اینستاگرام ببینید.",
|
||||||
|
linkUrl: instagram.href,
|
||||||
|
},
|
||||||
|
])[0];
|
||||||
|
const instagramRatio = slotRatio("instagram-row", slots);
|
||||||
|
const brandLogos = slotImages("brands", slots, gallery.brands);
|
||||||
|
const brandRatio = slotRatio("brands", slots);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero */}
|
<HeroSlider
|
||||||
<section className="hero">
|
slides={heroSlides}
|
||||||
<div className="container">
|
title="معماری ساده،"
|
||||||
<div className="hero__grid">
|
titleLine2="زندگی روشن"
|
||||||
<h1 className="hero__title">
|
lead={`${name} یک گروه معماری و طراحی داخلی است؛ از طراحی نما و پلان تا جزئیات داخلی و اجرای نهایی.`}
|
||||||
معماری ساده،
|
ctaLabel="شروع یک پروژه"
|
||||||
<br />
|
ctaHref="/contact"
|
||||||
<em>زندگی روشن</em>
|
/>
|
||||||
<span className="hero__dot" aria-hidden="true" />
|
|
||||||
</h1>
|
|
||||||
<div className="hero__aside">
|
|
||||||
<p>
|
|
||||||
{name} یک گروه معماری و طراحی داخلی است؛ از طراحی نما و پلان تا جزئیات داخلی و اجرای نهایی.
|
|
||||||
</p>
|
|
||||||
<Link href="/contact" className="btn">
|
|
||||||
شروع یک پروژه
|
|
||||||
<ArrowIcon />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SlotImage image={hero} alt="نمونه پروژه استودیو معماری هوم" ratio={slotRatio("hero", slots)} className="hero__frame" priority />
|
|
||||||
<div className="hero__caption">
|
|
||||||
<span>{hero.titleFa || hero.titleEn || "پروژه منتخب"}</span>
|
|
||||||
<span className="latin">{site.nameEn}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Intro */}
|
{/* Intro — RTL: logo (rightmost) · copy · focus (left) */}
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container split">
|
<div className="container intro">
|
||||||
<div>
|
<div className="intro__brand">
|
||||||
<p className="eyebrow">استودیو</p>
|
<img src={logo} alt={name} className="intro__logo" width={480} height={186} />
|
||||||
<h2 className="intro__text">{about[0]}</h2>
|
</div>
|
||||||
|
<div className="intro__copy">
|
||||||
|
<SectionTitle en="studio" title={about[0]} className="section-title--compact section-title--story" />
|
||||||
</div>
|
</div>
|
||||||
<div className="intro__meta">
|
<div className="intro__meta">
|
||||||
<p>{about[1] ?? site.about[1]}</p>
|
<p>{about[1] ?? site.about[1]}</p>
|
||||||
@@ -71,110 +97,130 @@ export default async function HomePage() {
|
|||||||
<span>معماری مسکونی، طراحی داخلی، طراحی نما</span>
|
<span>معماری مسکونی، طراحی داخلی، طراحی نما</span>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/about" className="text-link">
|
<Link href="/about" className="text-link">
|
||||||
بیشتر درباره هوم
|
بیشتر بدانید
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Services */}
|
{/* four-category — 4 service panels */}
|
||||||
<section className="section section--surface">
|
<section className="section section--tight">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="section-head">
|
<SectionTitle en="services" title="ما در هوم." titleMuted="چه کارهایی انجام میدهیم" />
|
||||||
<div>
|
|
||||||
<p className="eyebrow">خدمات</p>
|
|
||||||
<h2 className="title">چه کاری انجام میدهیم</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="service-panels" role="list">
|
||||||
|
{Array.from({ length: 4 }, (_, index) => {
|
||||||
|
const service = services[index];
|
||||||
|
const cms = categoryImages[index];
|
||||||
|
const image = cms?.url || service?.image || gallery.heroSlides[0].url;
|
||||||
|
const titleFa = cms?.titleFa || service?.titleFa || `خدمت ${index + 1}`;
|
||||||
|
const titleEn = cms?.titleEn || service?.titleEn || "";
|
||||||
|
const desc = cms?.subtext || service?.text || "";
|
||||||
|
const href = cms?.linkUrl || (service ? `/services/${service.slug}` : "/services");
|
||||||
|
const external = /^https?:\/\//.test(href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={service?.index ?? index}
|
||||||
|
href={href}
|
||||||
|
className="service-panel"
|
||||||
|
role="listitem"
|
||||||
|
aria-label={`${titleFa}${titleEn ? ` — ${titleEn}` : ""}`}
|
||||||
|
target={external ? "_blank" : undefined}
|
||||||
|
rel={external ? "noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image!}
|
||||||
|
alt={`${titleFa}${titleEn ? ` / ${titleEn}` : ""}`}
|
||||||
|
className="service-panel__image"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<div className="service-panel__shade" aria-hidden="true" />
|
||||||
|
<div className="service-panel__body">
|
||||||
|
<span className="service-panel__index">{service?.index ?? String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<h3>{titleFa}</h3>
|
||||||
|
{titleEn ? <span className="latin">{titleEn}</span> : null}
|
||||||
|
{desc ? <p className="service-panel__desc">{desc}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="services">
|
</Link>
|
||||||
{services.map((service) => (
|
);
|
||||||
<article key={service.index} className="service">
|
})}
|
||||||
<span className="service__index">{service.index}</span>
|
|
||||||
<div>
|
|
||||||
<h3>{service.titleFa}</h3>
|
|
||||||
<span className="latin">{service.titleEn}</span>
|
|
||||||
</div>
|
|
||||||
<p>{service.text}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Projects */}
|
{/* project-images — masonry */}
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="section-head">
|
<SectionTitle
|
||||||
<div>
|
en="projects"
|
||||||
<p className="eyebrow">پروژهها</p>
|
title="پروژههای"
|
||||||
<h2 className="title">پروژههای منتخب</h2>
|
titleMuted="منتخب"
|
||||||
</div>
|
aside={<p className="lead">گزیدهای از کارهای اخیر در معماری، طراحی داخلی و نما.</p>}
|
||||||
<p className="lead" style={{ maxWidth: "34ch" }}>
|
/>
|
||||||
گزیدهای از کارهای اخیر در معماری، طراحی داخلی و نما.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="projects">
|
<div className="projects">
|
||||||
{projectSlots.length
|
{projectSlots.map((image, index) => {
|
||||||
? projectSlots.map((image, index) => (
|
const title = image.titleFa || image.titleEn || `پروژه ${index + 1}`;
|
||||||
<div key={`${image.url}-${index}`} className="project">
|
const media = { ...image, linkUrl: null };
|
||||||
<SlotImage image={image} alt={image.titleFa || `پروژه ${index + 1}`} ratio={slotRatio("projects", slots)} />
|
const card = (
|
||||||
|
<>
|
||||||
|
<SlotImage image={media} alt={title} />
|
||||||
<div className="project__meta">
|
<div className="project__meta">
|
||||||
<h3>{image.titleFa || image.titleEn || `پروژه ${index + 1}`}</h3>
|
<h3>{title}</h3>
|
||||||
{image.subtext ? <span>{image.subtext}</span> : null}
|
{image.subtext ? <span>{image.subtext}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{image.titleFa && image.titleEn ? <p className="project__sub latin">{image.titleEn}</p> : null}
|
{image.titleEn ? <p className="project__sub latin">{image.titleEn}</p> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (image.linkUrl) {
|
||||||
|
const external = /^https?:\/\//.test(image.linkUrl);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={`${image.url}-${index}`}
|
||||||
|
href={image.linkUrl}
|
||||||
|
className="project"
|
||||||
|
target={external ? "_blank" : undefined}
|
||||||
|
rel={external ? "noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={`${image.url}-${index}`} className="project">
|
||||||
|
{card}
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
: projects.map((project) => (
|
})}
|
||||||
<article key={project.slug} className="project">
|
|
||||||
<div className="frame" style={{ aspectRatio: slotRatio("projects", slots) }}>
|
|
||||||
<img src={project.image} alt={`${project.titleFa} — ${project.category}`} loading="lazy" decoding="async" />
|
|
||||||
</div>
|
|
||||||
<div className="project__meta">
|
|
||||||
<h3>{project.titleFa}</h3>
|
|
||||||
<span>
|
|
||||||
{project.category} · {project.year}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="project__sub latin">{project.titleEn}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Studio */}
|
<DetailsFrame />
|
||||||
<section className="section section--line-top">
|
|
||||||
<div className="container split split--equal">
|
{/* sticky-frame-bg */}
|
||||||
<SlotImage image={studio} alt="ورودی یکی از پروژههای استودیو هوم" ratio={slotRatio("studio-image", slots)} className="studio__frame" />
|
<OpeningFrame
|
||||||
<div className="studio__body">
|
image={openingImage}
|
||||||
<div>
|
imageAlt={sticky.titleFa || sticky.titleEn || "نگاه استودیو هوم"}
|
||||||
<p className="eyebrow">نگاه ما</p>
|
eyebrow={sticky.titleEn || "نگاه ما"}
|
||||||
<h2 className="title">فضایی که با شما زندگی میکند</h2>
|
headline="معماری ساده،"
|
||||||
</div>
|
headlineMuted="زندگی روشن"
|
||||||
<p>
|
headlineEn="SIMPLE ARCHITECTURE, BRIGHT LIVING"
|
||||||
ما طراحی را از نیاز واقعی آغاز میکنیم؛ نه از فرم. نور، مسیر حرکت، متریال و تناسب، ابزار ما برای ساختن فضایی هستند که
|
body={sticky.subtext || about[2] || site.about[2]}
|
||||||
سالها بعد هم آرام و خوانا بماند.
|
ctaLabel="درباره استودیو"
|
||||||
</p>
|
ctaHref="/about"
|
||||||
<p>{about[2] ?? site.about[2]}</p>
|
/>
|
||||||
<Link href="/about" className="btn btn--ghost">
|
|
||||||
درباره استودیو
|
|
||||||
<ArrowIcon />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Process */}
|
{/* Process */}
|
||||||
<section className="section section--dark">
|
<section className="section section--dark">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<p className="eyebrow eyebrow--light">فرآیند کار</p>
|
<SectionTitle en="process" title="از ایده تا تحویل،" titleMuted="در چهار گام" light />
|
||||||
<h2 className="title">از ایده تا تحویل، در چهار گام</h2>
|
|
||||||
<div className="process">
|
<div className="process">
|
||||||
{processSteps.map((item) => (
|
{processSteps.map((item) => (
|
||||||
<div key={item.step} className="process__item">
|
<div key={item.step} className="process__item">
|
||||||
<span className="process__step">گام {item.step}</span>
|
<span className="process__num latin" lang="en" dir="ltr" aria-hidden="true">
|
||||||
|
{String(item.step).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
<h3>{item.titleFa}</h3>
|
<h3>{item.titleFa}</h3>
|
||||||
<p>{item.text}</p>
|
<p>{item.text}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,30 +230,68 @@ export default async function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<section className="section--tight">
|
<section className="stats-band" aria-label="آمار استودیو">
|
||||||
<div className="container">
|
|
||||||
<div className="stats">
|
<div className="stats">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="stat">
|
<div key={stat.label} className="stat">
|
||||||
<strong>{stat.value}</strong>
|
<strong className="latin" lang="en" dir="ltr">
|
||||||
|
{stat.value}
|
||||||
|
</strong>
|
||||||
<span>{stat.label}</span>
|
<span>{stat.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* brands */}
|
||||||
<section className="section">
|
{brandLogos.length ? (
|
||||||
<div className="container cta">
|
<section className="section section--tight brands" aria-label="همکاران">
|
||||||
<div>
|
<div className="container">
|
||||||
<p className="eyebrow">شروع همکاری</p>
|
<SectionTitle en="partners" title="برندها و" titleMuted="همکاران" />
|
||||||
<h2>پروژهای در ذهن دارید؟ دربارهاش صحبت کنیم.</h2>
|
<ul className="brands__grid">
|
||||||
|
{brandLogos.map((brand, index) => {
|
||||||
|
const alt = brand.titleFa || brand.titleEn || `برند ${index + 1}`;
|
||||||
|
return (
|
||||||
|
<li key={`${brand.url}-${index}`}>
|
||||||
|
<SlotImage image={brand} alt={alt} ratio={brandRatio} className="brands__logo" />
|
||||||
|
{brand.titleFa || brand.titleEn ? (
|
||||||
|
<p className="brands__caption">
|
||||||
|
{brand.titleFa ? <span>{brand.titleFa}</span> : null}
|
||||||
|
{brand.titleEn ? <span className="latin">{brand.titleEn}</span> : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/contact" className="btn">
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* instagram-row */}
|
||||||
|
<InstagramFollow
|
||||||
|
handle={instagram.handle}
|
||||||
|
href={instagram.href}
|
||||||
|
image={instagramImage}
|
||||||
|
ratio={instagramRatio}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="cta-band">
|
||||||
|
<div className="cta-band__glow" aria-hidden="true" />
|
||||||
|
<div className="container cta">
|
||||||
|
<SectionTitle
|
||||||
|
en="collaborate"
|
||||||
|
title="پروژهای در ذهن دارید؟"
|
||||||
|
titleMuted="دربارهاش صحبت کنیم."
|
||||||
|
className="section-title--inline section-title--light"
|
||||||
|
aside={
|
||||||
|
<Link href="/contact" className="btn btn--light">
|
||||||
تماس با ما
|
تماس با ما
|
||||||
<ArrowIcon />
|
<ArrowIcon />
|
||||||
</Link>
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { MediaGallery } from "@/components/MediaGallery";
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
import {
|
||||||
|
asRichHtml,
|
||||||
|
fetchPortfolioById,
|
||||||
|
fetchPortfolioBySlug,
|
||||||
|
fetchPortfolios,
|
||||||
|
getLocalPortfolio,
|
||||||
|
getLocalPortfolios,
|
||||||
|
portfolioCover,
|
||||||
|
portfolioHref,
|
||||||
|
portfolioImages,
|
||||||
|
portfolioTitle,
|
||||||
|
type PortfolioItem,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
async function resolvePortfolio(slug: string): Promise<PortfolioItem | null> {
|
||||||
|
const decoded = decodeURIComponent(slug);
|
||||||
|
const bySlug = await fetchPortfolioBySlug(decoded);
|
||||||
|
if (bySlug) return bySlug;
|
||||||
|
const byId = await fetchPortfolioById(decoded);
|
||||||
|
if (byId) return byId;
|
||||||
|
return getLocalPortfolio(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const portfolio = await resolvePortfolio(slug);
|
||||||
|
if (!portfolio) return { title: "نمونه کار پیدا نشد" };
|
||||||
|
const title = portfolioTitle(portfolio);
|
||||||
|
const description = portfolio.abstract || title;
|
||||||
|
const image = portfolioCover(portfolio);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: { title, description, images: [{ url: image, alt: title }] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PortfolioDetailPage({ params }: PageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const portfolio = await resolvePortfolio(slug);
|
||||||
|
if (!portfolio) notFound();
|
||||||
|
|
||||||
|
const title = portfolioTitle(portfolio);
|
||||||
|
const html = asRichHtml(portfolio.mainTextHtml || portfolio.abstract);
|
||||||
|
const images = portfolioImages(portfolio);
|
||||||
|
|
||||||
|
const related = await fetchPortfolios({ page: 1, pageSize: 8 });
|
||||||
|
const pool = related.items.length ? related.items : getLocalPortfolios();
|
||||||
|
const similar = pool.filter((item) => item.id !== portfolio.id).slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero">
|
||||||
|
<div className="container">
|
||||||
|
<SectionTitle
|
||||||
|
en="project"
|
||||||
|
title={title}
|
||||||
|
as="h1"
|
||||||
|
className="section-title--inline"
|
||||||
|
aside={
|
||||||
|
portfolio.categoryName || portfolio.titleEn ? (
|
||||||
|
<p className="page-hero__aside">
|
||||||
|
{portfolio.categoryName ? <span>{portfolio.categoryName}</span> : null}
|
||||||
|
{portfolio.titleEn ? <span className="latin">{portfolio.titleEn}</span> : null}
|
||||||
|
</p>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section--tight">
|
||||||
|
<div className="container portfolio-detail">
|
||||||
|
<MediaGallery images={images} title={title} />
|
||||||
|
<div className="portfolio-detail__copy">
|
||||||
|
{portfolio.abstract ? <p className="lead">{portfolio.abstract}</p> : null}
|
||||||
|
{html ? <div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} /> : null}
|
||||||
|
{portfolio.projectUrl ? (
|
||||||
|
<a className="text-link" href={portfolio.projectUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
مشاهده لینک پروژه
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<Link className="text-link" href="/portfolio">
|
||||||
|
بازگشت به نمونه کارها
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{similar.length ? (
|
||||||
|
<section className="section section--surface">
|
||||||
|
<div className="container">
|
||||||
|
<SectionTitle en="similar" title="نمونههای" titleMuted="مشابه" />
|
||||||
|
<div className="projects projects--related">
|
||||||
|
{similar.map((item) => {
|
||||||
|
const itemTitle = portfolioTitle(item);
|
||||||
|
return (
|
||||||
|
<Link key={item.id} href={portfolioHref(item)} className="project">
|
||||||
|
<div className="frame">
|
||||||
|
<img src={portfolioCover(item)} alt={itemTitle} loading="lazy" decoding="async" />
|
||||||
|
</div>
|
||||||
|
<div className="project__meta">
|
||||||
|
<h3>{itemTitle}</h3>
|
||||||
|
{item.categoryName ? <span>{item.categoryName}</span> : null}
|
||||||
|
</div>
|
||||||
|
{item.titleEn ? <p className="project__sub latin">{item.titleEn}</p> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
import { site } from "@/data/site";
|
||||||
|
import {
|
||||||
|
fetchPortfolios,
|
||||||
|
getLocalPortfolios,
|
||||||
|
portfolioCover,
|
||||||
|
portfolioHref,
|
||||||
|
portfolioTitle,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "نمونه کارها",
|
||||||
|
description: `نمونه کارهای ${site.nameFa} در معماری، طراحی داخلی و طراحی نما.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PortfolioPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ page?: string }>;
|
||||||
|
}) {
|
||||||
|
const { page: pageRaw } = await searchParams;
|
||||||
|
const page = Math.max(1, Number(pageRaw) || 1);
|
||||||
|
const pageSize = 12;
|
||||||
|
const result = await fetchPortfolios({ page, pageSize });
|
||||||
|
const usingApi = result.items.length > 0;
|
||||||
|
const items = usingApi ? result.items : page === 1 ? getLocalPortfolios() : [];
|
||||||
|
const total = usingApi ? result.total : items.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero">
|
||||||
|
<div className="container">
|
||||||
|
<SectionTitle
|
||||||
|
en="portfolio"
|
||||||
|
title="نمونه کارهای"
|
||||||
|
titleMuted="استودیو"
|
||||||
|
as="h1"
|
||||||
|
className="section-title--inline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section--tight">
|
||||||
|
<div className="container">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="empty-state">هنوز نمونه کاری منتشر نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<div className="projects">
|
||||||
|
{items.map((item) => {
|
||||||
|
const title = portfolioTitle(item);
|
||||||
|
return (
|
||||||
|
<Link key={item.id} href={portfolioHref(item)} className="project">
|
||||||
|
<div className="frame">
|
||||||
|
<img src={portfolioCover(item)} alt={title} loading="lazy" decoding="async" />
|
||||||
|
</div>
|
||||||
|
<div className="project__meta">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{item.categoryName || item.abstract ? (
|
||||||
|
<span>{item.categoryName || item.abstract}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.titleEn ? <p className="project__sub latin">{item.titleEn}</p> : null}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && items.length ? (
|
||||||
|
<nav className="pager" aria-label="صفحهبندی">
|
||||||
|
{page > 1 ? <Link href={`/portfolio?page=${page - 1}`}>قبلی</Link> : <span />}
|
||||||
|
<span className="latin">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages ? <Link href={`/portfolio?page=${page + 1}`}>بعدی</Link> : <span />}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
/** Legacy route — Meshkee portfolios live at `/portfolio/[slug]`. */
|
||||||
|
export default async function ProjectRedirect({ params }: PageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
redirect(`/portfolio/${encodeURIComponent(slug)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
/** Legacy route — Meshkee portfolios live at `/portfolio`. */
|
||||||
|
export default function ProjectsRedirect() {
|
||||||
|
redirect("/portfolio");
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
import { ServiceFrame } from "@/components/ServiceFrame";
|
||||||
|
import { services, site } from "@/data/site";
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return services.map((service) => ({ slug: service.slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const service = services.find((item) => item.slug === slug);
|
||||||
|
if (!service) return { title: "تخصص" };
|
||||||
|
return {
|
||||||
|
title: service.titleFa,
|
||||||
|
description: service.text,
|
||||||
|
openGraph: {
|
||||||
|
title: `${service.titleFa} | ${site.nameFa}`,
|
||||||
|
description: service.text,
|
||||||
|
images: [{ url: service.image, alt: service.titleFa }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ServiceDetailPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const service = services.find((item) => item.slug === slug);
|
||||||
|
if (!service) notFound();
|
||||||
|
|
||||||
|
const others = services.filter((item) => item.slug !== slug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="service-hero">
|
||||||
|
<div className="service-hero__media">
|
||||||
|
<img src={service.image} alt={service.titleFa} loading="eager" decoding="async" />
|
||||||
|
<div className="service-hero__shade" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div className="container service-hero__content">
|
||||||
|
<p className="service-hero__index latin" lang="en" dir="ltr">
|
||||||
|
{service.index}
|
||||||
|
</p>
|
||||||
|
<SectionTitle
|
||||||
|
en={service.titleEn}
|
||||||
|
title={service.titleFa}
|
||||||
|
as="h1"
|
||||||
|
className="section-title--inline section-title--light"
|
||||||
|
/>
|
||||||
|
<p className="service-hero__text">{service.text}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ServiceFrame
|
||||||
|
image={service.frameImage}
|
||||||
|
imageAlt={`${service.titleFa} — ${site.shortFa}`}
|
||||||
|
index={service.index}
|
||||||
|
title={service.titleFa}
|
||||||
|
titleEn={service.titleEn}
|
||||||
|
lead={service.lead}
|
||||||
|
steps={service.steps}
|
||||||
|
ctaLabel="درخواست مشاوره"
|
||||||
|
ctaHref="/contact"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<div className="container split">
|
||||||
|
<div>
|
||||||
|
<SectionTitle
|
||||||
|
en="detail"
|
||||||
|
title="آنچه در این تخصص"
|
||||||
|
titleMuted="دریافت میکنید"
|
||||||
|
className="section-title--compact"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="about-text">
|
||||||
|
<p>{service.lead}</p>
|
||||||
|
<p>
|
||||||
|
تیم {site.nameFa} این تخصص را از مرحله ایده تا جزئیات اجرایی همراهی میکند تا نتیجه نهایی دقیق و هماهنگ با
|
||||||
|
نیاز شما باشد.
|
||||||
|
</p>
|
||||||
|
<Link href="/contact" className="btn" style={{ marginTop: 4, width: "fit-content" }}>
|
||||||
|
شروع گفتگو
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section section--surface">
|
||||||
|
<div className="container">
|
||||||
|
<SectionTitle
|
||||||
|
en="more"
|
||||||
|
title="بیشتر درباره"
|
||||||
|
titleMuted="خدمات ما"
|
||||||
|
aside={
|
||||||
|
<Link href="/services" className="text-link">
|
||||||
|
همه تخصصها
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="service-related">
|
||||||
|
{others.map((item) => (
|
||||||
|
<Link key={item.slug} href={`/services/${item.slug}`} className="service-related__card">
|
||||||
|
<div className="service-related__media">
|
||||||
|
<img src={item.image} alt={item.titleFa} loading="lazy" decoding="async" />
|
||||||
|
</div>
|
||||||
|
<div className="service-related__meta">
|
||||||
|
<span className="latin" lang="en" dir="ltr">
|
||||||
|
{item.index}
|
||||||
|
</span>
|
||||||
|
<h3>{item.titleFa}</h3>
|
||||||
|
<p className="latin">{item.titleEn}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
import { services, site } from "@/data/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "تخصصهای ما",
|
||||||
|
description: `تخصصهای ${site.nameFa}: طراحی معماری، طراحی داخلی، طراحی نما و نظارت و اجرا.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServicesPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="page-hero">
|
||||||
|
<div className="container">
|
||||||
|
<SectionTitle
|
||||||
|
en="services"
|
||||||
|
title="ما در هوم."
|
||||||
|
titleMuted="چه کارهایی انجام میدهیم"
|
||||||
|
as="h1"
|
||||||
|
className="section-title--inline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section--tight">
|
||||||
|
<div className="container">
|
||||||
|
<div className="projects">
|
||||||
|
{services.map((service) => (
|
||||||
|
<Link key={service.slug} href={`/services/${service.slug}`} className="project">
|
||||||
|
<div className="frame">
|
||||||
|
<img src={service.image} alt={`${service.titleFa} / ${service.titleEn}`} loading="lazy" decoding="async" />
|
||||||
|
</div>
|
||||||
|
<div className="project__meta">
|
||||||
|
<h3>{service.titleFa}</h3>
|
||||||
|
<span className="latin">{service.titleEn}</span>
|
||||||
|
</div>
|
||||||
|
<p className="project__sub">{service.text}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||||
|
|
||||||
|
import { businessDashboardUrl, customerPortalUrl } from "@/lib/config";
|
||||||
|
import { useSession, userDisplayName } from "@/lib/session";
|
||||||
|
|
||||||
|
function UserIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path
|
||||||
|
d="M5 19.2c1.6-3.2 4-4.7 7-4.7s5.4 1.5 7 4.7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountMenu() {
|
||||||
|
const { user, isAuthenticated, ready, isWebsiteAdmin, logout, getAccessToken } = useSession();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [handoffBusy, setHandoffBusy] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClickOutside = (event: Event) => {
|
||||||
|
if (menuRef.current && event.target instanceof Node && !menuRef.current.contains(event.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (handoffBusy) return;
|
||||||
|
|
||||||
|
const baseUrl = businessDashboardUrl;
|
||||||
|
const popup = window.open("about:blank", "_blank");
|
||||||
|
setHandoffBusy(true);
|
||||||
|
try {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const res = await fetch("/api/auth/handoff", {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("handoff failed");
|
||||||
|
const data = (await res.json()) as { ticket?: string };
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
if (data.ticket) url.searchParams.set("handoff", data.ticket);
|
||||||
|
if (popup) {
|
||||||
|
popup.opener = null;
|
||||||
|
popup.location.replace(url.toString());
|
||||||
|
} else {
|
||||||
|
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (popup) popup.location.replace(baseUrl);
|
||||||
|
else window.open(baseUrl, "_blank", "noopener,noreferrer");
|
||||||
|
} finally {
|
||||||
|
setHandoffBusy(false);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<span className="account-menu account-menu--loading" aria-hidden="true">
|
||||||
|
<UserIcon />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`${customerPortalUrl}/login`}
|
||||||
|
className="account-menu account-menu--login"
|
||||||
|
aria-label="ورود"
|
||||||
|
title="ورود"
|
||||||
|
>
|
||||||
|
<UserIcon />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = userDisplayName(user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="account-menu account-menu--user">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className="account-menu__trigger"
|
||||||
|
aria-label={`${displayName} عزیز`}
|
||||||
|
>
|
||||||
|
<span className="account-menu__name">{displayName} عزیز</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div role="menu" className="account-menu__dropdown">
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href={`${customerPortalUrl}/profile`}
|
||||||
|
className="account-menu__item"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
پروفایل من
|
||||||
|
</a>
|
||||||
|
{isWebsiteAdmin ? (
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href={businessDashboardUrl}
|
||||||
|
className="account-menu__item"
|
||||||
|
onClick={openBusinessDashboard}
|
||||||
|
>
|
||||||
|
{handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="account-menu__item account-menu__logout"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
خروج
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+134
-28
@@ -1,11 +1,86 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||||
|
|
||||||
type Status = { tone: "idle" | "pending" | "success" | "error"; message: string };
|
type Status = { tone: "idle" | "pending" | "success" | "error"; message: string };
|
||||||
|
|
||||||
|
function IconUser() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="8" r="3.2" />
|
||||||
|
<path d="M5.5 19.5c1.8-3.2 4-4.8 6.5-4.8s4.7 1.6 6.5 4.8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconPhone() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M8.5 4.5h3l1.5 4-2 1.2a12 12 0 0 0 5.3 5.3l1.2-2 4 1.5v3A2 2 0 0 1 19.5 19.5 15 15 0 0 1 4.5 4.5 2 2 0 0 1 6.5 2.5h2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconMail() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<rect x="3.5" y="5.5" width="17" height="13" rx="1.5" />
|
||||||
|
<path d="m4.5 7.5 7.5 6 7.5-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconSubject() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M5 7h14M5 12h10M5 17h7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconMessage() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M5 6.5h14v9H9l-4 3v-12Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
wide,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`field${wide ? " field--wide" : ""}`}>
|
||||||
|
<label className="sr-only" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<span className="field__icon" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ContactForm() {
|
export function ContactForm() {
|
||||||
const [status, setStatus] = useState<Status>({ tone: "idle", message: "" });
|
const [status, setStatus] = useState<Status>({ tone: "idle", message: "" });
|
||||||
|
const [toast, setToast] = useState<{ tone: "success" | "error"; message: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const timer = window.setTimeout(() => setToast(null), 4200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -20,7 +95,9 @@ export function ContactForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!payload.name || !payload.title || !payload.text) {
|
if (!payload.name || !payload.title || !payload.text) {
|
||||||
setStatus({ tone: "error", message: "لطفاً نام، موضوع و متن پیام را کامل کنید." });
|
const message = "لطفاً نام، موضوع و متن پیام را کامل کنید.";
|
||||||
|
setStatus({ tone: "error", message });
|
||||||
|
setToast({ tone: "error", message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,28 +111,38 @@ export function ContactForm() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => ({}))) as { message?: string | string[] };
|
const body = (await response.json().catch(() => ({}))) as { message?: string | string[] };
|
||||||
const message = Array.isArray(body.message) ? body.message.join("، ") : body.message;
|
const message = Array.isArray(body.message) ? body.message.join("، ") : body.message;
|
||||||
// Only surface server messages that are already localized; otherwise use a generic one.
|
throw new Error(
|
||||||
throw new Error(message && /[\u0600-\u06FF]/.test(message) ? message : "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید.");
|
message && /[\u0600-\u06FF]/.test(message)
|
||||||
|
? message
|
||||||
|
: "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
form.reset();
|
form.reset();
|
||||||
setStatus({ tone: "success", message: "پیام شما ثبت شد. بهزودی با شما تماس میگیریم." });
|
const success = "پیام شما ثبت شد. بهزودی با شما تماس میگیریم.";
|
||||||
|
setStatus({ tone: "success", message: success });
|
||||||
|
setToast({ tone: "success", message: success });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus({
|
const message = error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید.";
|
||||||
tone: "error",
|
setStatus({ tone: "error", message });
|
||||||
message: error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید.",
|
setToast({ tone: "error", message });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<form className="form" onSubmit={onSubmit} noValidate>
|
<form className="form" onSubmit={onSubmit} noValidate>
|
||||||
<div className="form__row">
|
<div className="form__row">
|
||||||
<div className="field">
|
<Field id="contact-name" label="نام و نام خانوادگی *" icon={<IconUser />}>
|
||||||
<label htmlFor="contact-name">نام و نام خانوادگی *</label>
|
<input
|
||||||
<input id="contact-name" name="name" type="text" autoComplete="name" required />
|
id="contact-name"
|
||||||
</div>
|
name="name"
|
||||||
<div className="field">
|
type="text"
|
||||||
<label htmlFor="contact-cell">شماره تماس</label>
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
placeholder="نام و نام خانوادگی *"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field id="contact-cell" label="شماره تماس" icon={<IconPhone />}>
|
||||||
<input
|
<input
|
||||||
id="contact-cell"
|
id="contact-cell"
|
||||||
name="cellNumber"
|
name="cellNumber"
|
||||||
@@ -63,29 +150,39 @@ export function ContactForm() {
|
|||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
placeholder="0912 000 0000"
|
placeholder="۰۹۱۲ ۰۰۰ ۰۰۰۰"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="form__row">
|
<div className="form__row">
|
||||||
<div className="field">
|
<Field id="contact-email" label="ایمیل" icon={<IconMail />}>
|
||||||
<label htmlFor="contact-email">ایمیل</label>
|
<input
|
||||||
<input id="contact-email" name="email" type="email" autoComplete="email" dir="ltr" />
|
id="contact-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="ایمیل"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field id="contact-title" label="موضوع *" icon={<IconSubject />}>
|
||||||
|
<input
|
||||||
|
id="contact-title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="موضوع *"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<Field id="contact-text" label="توضیحات پروژه *" icon={<IconMessage />} wide>
|
||||||
<label htmlFor="contact-title">موضوع *</label>
|
|
||||||
<input id="contact-title" name="title" type="text" required placeholder="مثلاً طراحی داخلی واحد مسکونی" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label htmlFor="contact-text">توضیحات پروژه *</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="contact-text"
|
id="contact-text"
|
||||||
name="text"
|
name="text"
|
||||||
required
|
required
|
||||||
placeholder="متراژ، محل پروژه، نوع خدمات مورد نیاز و زمانبندی مدنظر را بنویسید."
|
placeholder="متراژ، محل پروژه، نوع خدمات مورد نیاز و زمانبندی مدنظر را بنویسید."
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="form__footer">
|
<div className="form__footer">
|
||||||
<button type="submit" className="btn" disabled={status.tone === "pending"}>
|
<button type="submit" className="btn" disabled={status.tone === "pending"}>
|
||||||
ارسال پیام
|
ارسال پیام
|
||||||
@@ -98,5 +195,14 @@ export function ContactForm() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{toast ? (
|
||||||
|
<div className="toast-stack" aria-live="polite">
|
||||||
|
<div className={`toast toast--${toast.tone}`} role="status">
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
zoom: number;
|
||||||
|
address: string;
|
||||||
|
mapLink: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dark, low-clutter Google styles — hide POI / transit icons. */
|
||||||
|
const CLEAN_DARK_STYLES: Array<Record<string, unknown>> = [
|
||||||
|
{ elementType: "geometry", stylers: [{ color: "#1d1d1d" }] },
|
||||||
|
{ elementType: "labels.icon", stylers: [{ visibility: "off" }] },
|
||||||
|
{ elementType: "labels.text.fill", stylers: [{ color: "#8a8a8a" }] },
|
||||||
|
{ elementType: "labels.text.stroke", stylers: [{ color: "#1d1d1d" }] },
|
||||||
|
{ featureType: "administrative", elementType: "geometry", stylers: [{ visibility: "off" }] },
|
||||||
|
{ featureType: "poi", stylers: [{ visibility: "off" }] },
|
||||||
|
{ featureType: "poi.park", stylers: [{ visibility: "off" }] },
|
||||||
|
{ featureType: "road", elementType: "geometry", stylers: [{ color: "#2c2c2c" }] },
|
||||||
|
{ featureType: "road", elementType: "labels.icon", stylers: [{ visibility: "off" }] },
|
||||||
|
{ featureType: "road.arterial", elementType: "geometry", stylers: [{ color: "#3a3a3a" }] },
|
||||||
|
{ featureType: "road.highway", elementType: "geometry", stylers: [{ color: "#4a4034" }] },
|
||||||
|
{ featureType: "transit", stylers: [{ visibility: "off" }] },
|
||||||
|
{ featureType: "water", elementType: "geometry", stylers: [{ color: "#0e0e0e" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
google?: {
|
||||||
|
maps: {
|
||||||
|
Map: new (
|
||||||
|
el: HTMLElement,
|
||||||
|
opts: {
|
||||||
|
center: { lat: number; lng: number };
|
||||||
|
zoom: number;
|
||||||
|
styles?: unknown[];
|
||||||
|
disableDefaultUI?: boolean;
|
||||||
|
zoomControl?: boolean;
|
||||||
|
mapTypeControl?: boolean;
|
||||||
|
streetViewControl?: boolean;
|
||||||
|
fullscreenControl?: boolean;
|
||||||
|
clickableIcons?: boolean;
|
||||||
|
},
|
||||||
|
) => unknown;
|
||||||
|
Marker: new (opts: { position: { lat: number; lng: number }; map: unknown }) => unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGoogleMaps(apiKey: string): Promise<void> {
|
||||||
|
if (window.google?.maps) return Promise.resolve();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>("script[data-hom-maps]");
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener("load", () => resolve(), { once: true });
|
||||||
|
existing.addEventListener("error", () => reject(new Error("maps load failed")), { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}`;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.dataset.homMaps = "1";
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error("maps load failed"));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function OsmFallback({ lat, lng, address, mapLink }: Omit<Props, "zoom">) {
|
||||||
|
const delta = 0.035;
|
||||||
|
const osmSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - delta}%2C${lat - delta}%2C${lng + delta}%2C${lat + delta}&layer=mapnik&marker=${lat}%2C${lng}`;
|
||||||
|
return (
|
||||||
|
<section className="contact-map contact-map--dark" aria-label="موقعیت روی نقشه">
|
||||||
|
<iframe
|
||||||
|
className="contact-map__frame"
|
||||||
|
title={`نقشه ${address}`}
|
||||||
|
src={osmSrc}
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
/>
|
||||||
|
<a className="contact-map__open" href={mapLink} target="_blank" rel="noreferrer">
|
||||||
|
مشاهده در نقشه
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Map with places/transit icons hidden.
|
||||||
|
* Requires `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` (Maps JavaScript API enabled).
|
||||||
|
* Falls back to a cleaner OSM embed when the key is missing or fails to load.
|
||||||
|
*/
|
||||||
|
export function ContactMap({ lat, lng, zoom, address, mapLink }: Props) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?.trim() ?? "";
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!apiKey || !hostRef.current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
loadGoogleMaps(apiKey)
|
||||||
|
.then(() => {
|
||||||
|
if (cancelled || !hostRef.current || !window.google?.maps) {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const map = new window.google.maps.Map(hostRef.current, {
|
||||||
|
center: { lat, lng },
|
||||||
|
zoom,
|
||||||
|
styles: CLEAN_DARK_STYLES,
|
||||||
|
disableDefaultUI: true,
|
||||||
|
zoomControl: true,
|
||||||
|
mapTypeControl: false,
|
||||||
|
streetViewControl: false,
|
||||||
|
fullscreenControl: false,
|
||||||
|
clickableIcons: false,
|
||||||
|
});
|
||||||
|
new window.google.maps.Marker({ position: { lat, lng }, map });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [apiKey, lat, lng, zoom]);
|
||||||
|
|
||||||
|
if (!apiKey || failed) {
|
||||||
|
return <OsmFallback lat={lat} lng={lng} address={address} mapLink={mapLink} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="contact-map" aria-label="موقعیت روی نقشه">
|
||||||
|
<div ref={hostRef} className="contact-map__frame contact-map__frame--google" role="presentation" />
|
||||||
|
<a className="contact-map__open" href={mapLink} target="_blank" rel="noreferrer">
|
||||||
|
مشاهده در نقشه
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { SectionTitle } from "@/components/SectionTitle";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
videoSrc?: string;
|
||||||
|
title?: string;
|
||||||
|
titleMuted?: string;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container-width split: SectionTitle + copy (left), framed portrait video (right).
|
||||||
|
*/
|
||||||
|
export function DetailsFrame({
|
||||||
|
videoSrc = "/videos/hom-video.mp4",
|
||||||
|
title = "دقت در جزئیات،",
|
||||||
|
titleMuted = "کیفیت در تجربه",
|
||||||
|
body = "از اتصال متریال تا نورگیر و خط نما، هر جزئیات کوچک بخشی از حس نهایی فضاست. در هوم، جزئیات را نادیده نمیگیریم؛ همانجایی که طرح ساده، زنده و ماندگار میشود.",
|
||||||
|
}: Props) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(true);
|
||||||
|
|
||||||
|
const togglePlayback = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused) {
|
||||||
|
void video.play();
|
||||||
|
setPlaying(true);
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
setPlaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="details-frame" aria-label="جزئیات">
|
||||||
|
<div className="container details-frame__grid">
|
||||||
|
<div className="details-frame__copy">
|
||||||
|
<SectionTitle en="details" title={title} titleMuted={titleMuted} />
|
||||||
|
<p className="details-frame__body">{body}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure className="details-frame__photo">
|
||||||
|
<div className="details-frame__frame">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="details-frame__video"
|
||||||
|
src={videoSrc}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
aria-label="نگاهی به جزئیات پروژههای استودیو هوم"
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`details-frame__play${playing ? " details-frame__play--playing" : ""}`}
|
||||||
|
onClick={togglePlayback}
|
||||||
|
aria-label={playing ? "توقف ویدیو" : "پخش ویدیو"}
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="6" y="5" width="4" height="14" rx="0.5" />
|
||||||
|
<rect x="14" y="5" width="4" height="14" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 5.5v13l11-6.5L8 5.5Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { NAV_LINKS, site } from "@/data/site";
|
import { NAV_LINKS, services, site } from "@/data/site";
|
||||||
import { stripScripts, toFaDigits, type PhoneLink } from "@/lib/content";
|
import { stripScripts, toFaDigits, type PhoneLink } from "@/lib/content";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -38,12 +38,13 @@ export function Footer({ logo, name, address, phones, emails, instagram, badges
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3>خدمات</h3>
|
<h3>تخصصها</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>طراحی معماری</li>
|
{services.map((service) => (
|
||||||
<li>طراحی داخلی</li>
|
<li key={service.slug}>
|
||||||
<li>طراحی نما</li>
|
<Link href={`/services/${service.slug}`}>{service.titleFa}</Link>
|
||||||
<li>نظارت و اجرا</li>
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,17 +54,21 @@ export function Footer({ logo, name, address, phones, emails, instagram, badges
|
|||||||
<li>{address}</li>
|
<li>{address}</li>
|
||||||
{phones.map((phone) => (
|
{phones.map((phone) => (
|
||||||
<li key={phone.href}>
|
<li key={phone.href}>
|
||||||
<a href={phone.href}>{phone.display}</a>
|
<a href={phone.href} className="latin" dir="ltr">
|
||||||
|
{phone.display}
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{emails.map((email) => (
|
{emails.map((email) => (
|
||||||
<li key={email}>
|
<li key={email}>
|
||||||
<a href={`mailto:${email}`}>{email}</a>
|
<a href={`mailto:${email}`} className="latin" dir="ltr">
|
||||||
|
{email}
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
<li>
|
<li>
|
||||||
<a href={instagram.href} target="_blank" rel="noreferrer">
|
<a href={instagram.href} target="_blank" rel="noreferrer">
|
||||||
اینستاگرام {instagram.handle}
|
اینستاگرام <span className="latin">{instagram.handle}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+133
-13
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
import { NAV_LINKS } from "@/data/site";
|
import { AccountMenu } from "@/components/AccountMenu";
|
||||||
|
import { NAV_LINKS, type NavLink } from "@/data/site";
|
||||||
import type { PhoneLink } from "@/lib/content";
|
import type { PhoneLink } from "@/lib/content";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,18 +15,80 @@ type Props = {
|
|||||||
instagram: { href: string; handle: string };
|
instagram: { href: string; handle: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function linkActive(pathname: string, href: string) {
|
||||||
|
if (href === "/") return pathname === "/";
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ link, pathname }: { link: NavLink; pathname: string }) {
|
||||||
|
const menuId = useId();
|
||||||
|
const hasChildren = Boolean(link.children?.length);
|
||||||
|
const active = linkActive(pathname, link.href);
|
||||||
|
|
||||||
|
if (!hasChildren) {
|
||||||
|
return (
|
||||||
|
<Link href={link.href} aria-current={active ? "page" : undefined}>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="nav__item" data-has-menu="">
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="nav__trigger"
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-controls={menuId}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
<svg className="nav__caret" viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M2 4.5 6 8.5 10 4.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<div id={menuId} className="nav__menu" role="menu">
|
||||||
|
{link.children!.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.href}
|
||||||
|
href={child.href}
|
||||||
|
role="menuitem"
|
||||||
|
aria-current={linkActive(pathname, child.href) ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Header({ logo, name, phone, instagram }: Props) {
|
export function Header({ logo, name, phone, instagram }: Props) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setMobileOpen(null);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
const isActive = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="header">
|
<header className="header" data-nav-open={open ? "true" : "false"}>
|
||||||
<div className="container header__inner">
|
<div className="container header__inner">
|
||||||
<Link href="/" className="header__logo" aria-label={name}>
|
<Link href="/" className="header__logo" aria-label={name}>
|
||||||
<img src={logo} alt={name} width={172} height={66} />
|
<img src={logo} alt={name} width={172} height={66} />
|
||||||
@@ -33,18 +96,17 @@ export function Header({ logo, name, phone, instagram }: Props) {
|
|||||||
|
|
||||||
<nav className="nav" aria-label="منوی اصلی">
|
<nav className="nav" aria-label="منوی اصلی">
|
||||||
{NAV_LINKS.map((link) => (
|
{NAV_LINKS.map((link) => (
|
||||||
<Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}>
|
<NavItem key={link.href} link={link} pathname={pathname} />
|
||||||
{link.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="header__tools">
|
<div className="header__tools">
|
||||||
{phone ? (
|
{phone ? (
|
||||||
<a className="header__phone" href={phone.href}>
|
<a className="header__phone latin" href={phone.href} dir="ltr">
|
||||||
{phone.display}
|
{phone.display}
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
|
<AccountMenu />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="header__burger"
|
className="header__burger"
|
||||||
@@ -58,17 +120,75 @@ export function Header({ logo, name, phone, instagram }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mobile-nav" className="mobile-nav" data-open={open}>
|
<div
|
||||||
{NAV_LINKS.map((link) => (
|
id="mobile-nav"
|
||||||
<Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}>
|
className="mobile-nav"
|
||||||
|
data-open={open}
|
||||||
|
hidden={!open}
|
||||||
|
>
|
||||||
|
<nav className="mobile-nav__links" aria-label="منوی موبایل">
|
||||||
|
{NAV_LINKS.map((link) => {
|
||||||
|
const active = linkActive(pathname, link.href);
|
||||||
|
if (!link.children?.length) {
|
||||||
|
return (
|
||||||
|
<Link key={link.href} href={link.href} aria-current={active ? "page" : undefined}>
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expanded = mobileOpen === link.href;
|
||||||
|
return (
|
||||||
|
<div key={link.href} className="mobile-nav__group" data-open={expanded}>
|
||||||
|
<div className="mobile-nav__row">
|
||||||
|
<Link href={link.href} aria-current={active ? "page" : undefined}>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-nav__toggle"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={`${expanded ? "بستن" : "باز کردن"} زیرمنوی ${link.label}`}
|
||||||
|
onClick={() => setMobileOpen(expanded ? null : link.href)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M2 4.5 6 8.5 10 4.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<div className="mobile-nav__sub">
|
||||||
|
{link.children.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.href}
|
||||||
|
href={child.href}
|
||||||
|
aria-current={linkActive(pathname, child.href) ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div className="mobile-nav__meta">
|
<div className="mobile-nav__meta">
|
||||||
{phone ? <a href={phone.href}>{phone.display}</a> : null}
|
{phone ? (
|
||||||
<a href={instagram.href} target="_blank" rel="noreferrer">
|
<a href={phone.href} className="latin" dir="ltr">
|
||||||
|
{phone.display}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<a href={instagram.href} target="_blank" rel="noreferrer" className="latin" dir="ltr">
|
||||||
{instagram.handle}
|
{instagram.handle}
|
||||||
</a>
|
</a>
|
||||||
|
<Link href="/contact" className="btn mobile-nav__cta" onClick={() => setOpen(false)}>
|
||||||
|
تماس با ما
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M4 12h16M13 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { StaticImage } from "@/lib/static-images";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
slides: StaticImage[];
|
||||||
|
title: string;
|
||||||
|
titleLine2: string;
|
||||||
|
lead: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
ctaHref: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTERVAL_MS = 6200;
|
||||||
|
|
||||||
|
export function HeroSlider({ slides, title, titleLine2, lead, ctaLabel, ctaHref }: Props) {
|
||||||
|
const count = slides.length;
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
/** Bumps on every slide change so CSS enter animations restart. */
|
||||||
|
const [enterKey, setEnterKey] = useState(0);
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(next: number) => {
|
||||||
|
if (!count) return;
|
||||||
|
const resolved = ((next % count) + count) % count;
|
||||||
|
if (resolved === index) return;
|
||||||
|
setIndex(resolved);
|
||||||
|
setEnterKey((value) => value + 1);
|
||||||
|
},
|
||||||
|
[count, index],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (count < 2 || paused) return;
|
||||||
|
const id = window.setInterval(() => go(index + 1), INTERVAL_MS);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [count, go, index, paused]);
|
||||||
|
|
||||||
|
if (!count) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="hero-slider"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
aria-label="اسلایدر اصلی"
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
>
|
||||||
|
<div className="hero-slider__viewport">
|
||||||
|
{slides.map((slide, i) => {
|
||||||
|
const active = i === index;
|
||||||
|
const frame = (
|
||||||
|
<img
|
||||||
|
key={active ? `in-${enterKey}` : `out-${i}`}
|
||||||
|
src={slide.url ?? ""}
|
||||||
|
alt={slide.titleFa || slide.titleEn || `اسلاید ${i + 1}`}
|
||||||
|
className="hero-slider__image"
|
||||||
|
loading={i === 0 ? "eager" : "lazy"}
|
||||||
|
decoding="async"
|
||||||
|
fetchPriority={i === 0 ? "high" : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${slide.url}-${i}`}
|
||||||
|
className="hero-slider__slide"
|
||||||
|
data-active={active}
|
||||||
|
aria-hidden={!active}
|
||||||
|
>
|
||||||
|
{slide.linkUrl ? (
|
||||||
|
<a
|
||||||
|
href={slide.linkUrl}
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
target={/^https?:\/\//.test(slide.linkUrl) ? "_blank" : undefined}
|
||||||
|
rel={/^https?:\/\//.test(slide.linkUrl) ? "noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{frame}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
frame
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="hero-slider__bar" key={enterKey}>
|
||||||
|
<div className="hero-slider__panel">
|
||||||
|
<h1 className="hero-slider__title hero-slider__seq" style={{ ["--seq" as string]: 1 }}>
|
||||||
|
{title}{" "}
|
||||||
|
<em>{titleLine2}</em>
|
||||||
|
<span className="hero-slider__dot" aria-hidden="true" />
|
||||||
|
</h1>
|
||||||
|
<p className="hero-slider__lead hero-slider__seq" style={{ ["--seq" as string]: 2 }}>
|
||||||
|
{lead}
|
||||||
|
</p>
|
||||||
|
<div className="hero-slider__actions hero-slider__seq" style={{ ["--seq" as string]: 3 }}>
|
||||||
|
<Link href={ctaHref} className="btn">
|
||||||
|
{ctaLabel}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M4 12h16M13 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
{count > 1 ? (
|
||||||
|
<div className="hero-slider__dots" role="tablist" aria-label="انتخاب اسلاید">
|
||||||
|
{slides.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={i === index}
|
||||||
|
aria-label={`اسلاید ${i + 1}`}
|
||||||
|
className="hero-slider__dot-btn"
|
||||||
|
data-active={i === index}
|
||||||
|
onClick={() => go(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { SlotImage } from "@/components/SlotImage";
|
||||||
|
import type { StaticImage } from "@/lib/static-images";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handle: string;
|
||||||
|
href: string;
|
||||||
|
image: StaticImage;
|
||||||
|
ratio?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instagram follow block — Ayoubi-style framed screenshot + copy.
|
||||||
|
* Image comes from the `instagram-image` Meshkee slot (or local fallback).
|
||||||
|
*/
|
||||||
|
export function InstagramFollow({ handle, href, image, ratio = "9 / 16" }: Props) {
|
||||||
|
const link = image.linkUrl || href;
|
||||||
|
const title = image.titleFa?.trim() || "ما را در اینستاگرام دنبال کنید";
|
||||||
|
const kicker = image.titleEn?.trim() || "Follow us";
|
||||||
|
const lead =
|
||||||
|
image.subtext?.trim() ||
|
||||||
|
"پروژههای تازه، جزئیات اجرا و فضای استودیو را در صفحه اینستاگرام ببینید.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="follow" aria-labelledby="follow-title">
|
||||||
|
<div className="container follow__grid">
|
||||||
|
<div className="follow__copy">
|
||||||
|
<p className="follow__kicker latin" dir="ltr">
|
||||||
|
{kicker}
|
||||||
|
</p>
|
||||||
|
<h2 id="follow-title">{title}</h2>
|
||||||
|
<p className="follow__lead">{lead}</p>
|
||||||
|
<a className="text-link latin" href={link} target="_blank" rel="noreferrer" dir="ltr">
|
||||||
|
{handle}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<figure className="follow__photo">
|
||||||
|
<SlotImage
|
||||||
|
image={{ ...image, linkUrl: link, url: image.url || "/images/garden-gate.jpg" }}
|
||||||
|
alt={image.titleFa || image.titleEn || `صفحه اینستاگرام ${handle}`}
|
||||||
|
ratio={ratio}
|
||||||
|
className="follow__frame"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function MediaGallery({ images, title }: { images: string[]; title: string }) {
|
||||||
|
const gallery = images.length ? images : ["/images/facade-sunset.jpg"];
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [zoom, setZoom] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [lightbox, setLightbox] = useState(false);
|
||||||
|
|
||||||
|
const prev = () => setActive((index) => (index - 1 + gallery.length) % gallery.length);
|
||||||
|
const next = () => setActive((index) => (index + 1) % gallery.length);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lightbox) return;
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setLightbox(false);
|
||||||
|
if (event.key === "ArrowLeft") next();
|
||||||
|
if (event.key === "ArrowRight") prev();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [lightbox, active]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="media-gallery">
|
||||||
|
<div
|
||||||
|
className="media-gallery__stage"
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setZoom({
|
||||||
|
x: ((event.clientX - rect.left) / rect.width) * 100,
|
||||||
|
y: ((event.clientY - rect.top) / rect.height) * 100,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setZoom(null)}
|
||||||
|
onClick={() => setLightbox(true)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setLightbox(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`گالری تصاویر ${title}`}
|
||||||
|
>
|
||||||
|
<img src={gallery[active]} alt={title} />
|
||||||
|
{zoom ? (
|
||||||
|
<span
|
||||||
|
className="media-gallery__zoom"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${gallery[active]})`,
|
||||||
|
backgroundPosition: `${zoom.x}% ${zoom.y}%`,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{gallery.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="media-gallery__nav"
|
||||||
|
aria-label="قبلی"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
prev();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="media-gallery__nav media-gallery__nav--next"
|
||||||
|
aria-label="بعدی"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
next();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gallery.length > 1 ? (
|
||||||
|
<ul className="media-gallery__thumbs">
|
||||||
|
{gallery.map((src, index) => (
|
||||||
|
<li key={`${src}-${index}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={index === active ? "is-active" : undefined}
|
||||||
|
onClick={() => setActive(index)}
|
||||||
|
aria-label={`تصویر ${index + 1}`}
|
||||||
|
>
|
||||||
|
<img src={src} alt="" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{lightbox ? (
|
||||||
|
<div className="media-lightbox" role="dialog" aria-modal aria-label={`گالری ${title}`}>
|
||||||
|
<div className="media-lightbox__bar">
|
||||||
|
<span className="latin">
|
||||||
|
{active + 1} / {gallery.length}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => setLightbox(false)}>
|
||||||
|
بستن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="media-lightbox__body">
|
||||||
|
<img src={gallery[active]} alt={title} />
|
||||||
|
{gallery.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="media-lightbox__nav" onClick={prev} aria-label="قبلی">
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="media-lightbox__nav media-lightbox__nav--next"
|
||||||
|
onClick={next}
|
||||||
|
aria-label="بعدی"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Feature = {
|
||||||
|
title: string;
|
||||||
|
titleEn: string;
|
||||||
|
text: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
image: string;
|
||||||
|
imageAlt?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
headline?: string;
|
||||||
|
headlineMuted?: string;
|
||||||
|
headlineEn?: string;
|
||||||
|
body?: string;
|
||||||
|
features?: Feature[];
|
||||||
|
ctaLabel?: string;
|
||||||
|
ctaHref?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function IconPlan() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
|
||||||
|
<rect x="6" y="8" width="28" height="24" />
|
||||||
|
<path d="M6 16h28M16 8v24M24 16v16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconInterior() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
|
||||||
|
<path d="M8 30V18l12-8 12 8v12" />
|
||||||
|
<path d="M14 30v-8h12v8" />
|
||||||
|
<path d="M8 30h24" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconFacade() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
|
||||||
|
<rect x="8" y="6" width="24" height="28" />
|
||||||
|
<path d="M8 14h24M8 22h24M8 30h24M16 14v16M24 14v16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconBuild() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
|
||||||
|
<path d="M10 28 20 8l10 20" />
|
||||||
|
<path d="M13 22h14" />
|
||||||
|
<circle cx="20" cy="30" r="2.2" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FEATURES: Feature[] = [
|
||||||
|
{
|
||||||
|
title: "معماری",
|
||||||
|
titleEn: "Architecture",
|
||||||
|
text: "از ایده تا نقشههای اجرایی، متناسب با زمین و نور.",
|
||||||
|
icon: <IconPlan />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "طراحی داخلی",
|
||||||
|
titleEn: "Interior",
|
||||||
|
text: "چیدمان، متریال و نور برای فضای زندگی روزمره.",
|
||||||
|
icon: <IconInterior />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "طراحی نما",
|
||||||
|
titleEn: "Facade",
|
||||||
|
text: "ریتم، متریال و نورپردازی در اولین برخورد با بنا.",
|
||||||
|
icon: <IconFacade />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "نظارت و اجرا",
|
||||||
|
titleEn: "Build",
|
||||||
|
text: "همراهی تا تحویل، تا طرح همانطور ساخته شود.",
|
||||||
|
icon: <IconBuild />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky scroll frame (zoom-out reveal) — Hom layout with 4 icon features,
|
||||||
|
* distinct from Mana Collection’s big-type highlight / detail list.
|
||||||
|
*/
|
||||||
|
export function OpeningFrame({
|
||||||
|
image,
|
||||||
|
imageAlt = "نمونه پروژه استودیو هوم",
|
||||||
|
eyebrow = "نگاه ما",
|
||||||
|
headline = "معماری ساده،",
|
||||||
|
headlineMuted = "زندگی روشن",
|
||||||
|
headlineEn = "SIMPLE ARCHITECTURE, BRIGHT LIVING",
|
||||||
|
body = "از نخستین اسکیس تا تحویل نهایی، تیم طراحی و اجرای هوم در کنار شماست تا طرح روی کاغذ، همانطور که تصور شده ساخته شود.",
|
||||||
|
features = DEFAULT_FEATURES,
|
||||||
|
ctaLabel = "درباره استودیو",
|
||||||
|
ctaHref = "/about",
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="opening-track" aria-label="نگاه استودیو">
|
||||||
|
<div className="opening-stage">
|
||||||
|
<div className="opening-media">
|
||||||
|
<img src={image} alt={imageAlt} loading="eager" decoding="async" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="opening-vignette" aria-hidden="true" />
|
||||||
|
<div className="opening-shade" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="opening-headline">
|
||||||
|
<p className="opening-headline__eyebrow">{eyebrow}</p>
|
||||||
|
<h2 className="opening-headline__title">
|
||||||
|
{headline} <em>{headlineMuted}</em>
|
||||||
|
<span className="opening-headline__mark" aria-hidden="true" />
|
||||||
|
</h2>
|
||||||
|
{headlineEn ? (
|
||||||
|
<p className="opening-headline__en latin">{headlineEn}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="opening-copy">
|
||||||
|
<div className="opening-panel">
|
||||||
|
<div className="opening-panel__head">
|
||||||
|
<div className="opening-panel__rail" aria-hidden="true">
|
||||||
|
<span className="latin">studio</span>
|
||||||
|
<span className="opening-panel__line" />
|
||||||
|
<span className="opening-panel__capsule" />
|
||||||
|
<span className="opening-panel__dot" />
|
||||||
|
</div>
|
||||||
|
<p className="opening-panel__lead">{body}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="opening-features">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<article key={feature.titleEn} className="opening-feature">
|
||||||
|
<div className="opening-feature__icon">{feature.icon}</div>
|
||||||
|
<h3>{feature.title}</h3>
|
||||||
|
<span className="latin">{feature.titleEn}</span>
|
||||||
|
<p>{feature.text}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ctaLabel ? (
|
||||||
|
<div className="opening-panel__cta">
|
||||||
|
<Link href={ctaHref} className="btn btn--light">
|
||||||
|
{ctaLabel}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M4 12h16M13 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { SessionProvider } from "@/lib/session";
|
||||||
|
|
||||||
|
export function Providers({
|
||||||
|
businessId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
businessId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return <SessionProvider businessId={businessId}>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** English label — logo-style lowercase latin */
|
||||||
|
en: string;
|
||||||
|
/** Primary Persian phrase (solid black) */
|
||||||
|
title: string;
|
||||||
|
/** Secondary Persian phrase (lighter black), slider-style */
|
||||||
|
titleMuted?: string;
|
||||||
|
as?: "h1" | "h2";
|
||||||
|
/** Invert colors for dark sections */
|
||||||
|
light?: boolean;
|
||||||
|
/** Optional sibling (lead, link, CTA) */
|
||||||
|
aside?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Section heading inspired by the HOM logo + hero slider title:
|
||||||
|
* latin rail (line + red dot), then FA title split into black / muted parts.
|
||||||
|
*/
|
||||||
|
export function SectionTitle({
|
||||||
|
en,
|
||||||
|
title,
|
||||||
|
titleMuted,
|
||||||
|
as = "h2",
|
||||||
|
light = false,
|
||||||
|
aside,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const Heading = as;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`section-title${light ? " section-title--light" : ""}${className ? ` ${className}` : ""}`}>
|
||||||
|
<div className="section-title__main">
|
||||||
|
<div className="section-title__rail" aria-hidden="true">
|
||||||
|
<span className="section-title__en latin">{en}</span>
|
||||||
|
<span className="section-title__track">
|
||||||
|
<span className="section-title__line" />
|
||||||
|
<span className="section-title__capsule" />
|
||||||
|
<span className="section-title__line section-title__line--tail" />
|
||||||
|
<span className="section-title__dot" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Heading className="section-title__fa">
|
||||||
|
{title}
|
||||||
|
{titleMuted ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<em>{titleMuted}</em>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
{aside ? <div className="section-title__aside">{aside}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import type { ServiceStep } from "@/data/site";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
image: string;
|
||||||
|
imageAlt?: string;
|
||||||
|
index: string;
|
||||||
|
title: string;
|
||||||
|
titleEn: string;
|
||||||
|
lead: string;
|
||||||
|
steps: ServiceStep[];
|
||||||
|
ctaLabel?: string;
|
||||||
|
ctaHref?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky scroll frame for service pages — side card + vertical steps.
|
||||||
|
* Same scroll/zoom mechanic as OpeningFrame, different composition.
|
||||||
|
*/
|
||||||
|
export function ServiceFrame({
|
||||||
|
image,
|
||||||
|
imageAlt = "نمونه خدمت",
|
||||||
|
index,
|
||||||
|
title,
|
||||||
|
titleEn,
|
||||||
|
lead,
|
||||||
|
steps,
|
||||||
|
ctaLabel = "درخواست مشاوره",
|
||||||
|
ctaHref = "/contact",
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="service-frame-track" aria-label={title}>
|
||||||
|
<div className="service-frame-stage">
|
||||||
|
<div className="service-frame-media">
|
||||||
|
<img src={image} alt={imageAlt} loading="eager" decoding="async" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="service-frame-vignette" aria-hidden="true" />
|
||||||
|
<div className="service-frame-shade" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="service-frame-headline">
|
||||||
|
<p className="service-frame-headline__index latin" lang="en" dir="ltr">
|
||||||
|
{index}
|
||||||
|
</p>
|
||||||
|
<h2 className="service-frame-headline__title">{title}</h2>
|
||||||
|
<p className="service-frame-headline__en latin">{titleEn}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="service-frame-copy">
|
||||||
|
<aside className="service-frame-card">
|
||||||
|
<div className="service-frame-card__rail" aria-hidden="true">
|
||||||
|
<span className="latin">process</span>
|
||||||
|
<span className="service-frame-card__line" />
|
||||||
|
<span className="service-frame-card__dot" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="service-frame-card__lead">{lead}</p>
|
||||||
|
|
||||||
|
<ol className="service-frame-steps">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<li key={step.title} className="service-frame-step">
|
||||||
|
<span className="service-frame-step__num latin" lang="en" dir="ltr">
|
||||||
|
{String(i + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3>{step.title}</h3>
|
||||||
|
<p>{step.text}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{ctaLabel ? (
|
||||||
|
<div className="service-frame-card__cta">
|
||||||
|
<Link href={ctaHref} className="btn btn--light">
|
||||||
|
{ctaLabel}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||||
|
<path d="M4 12h16M13 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+128
-45
@@ -10,11 +10,8 @@ export type Project = {
|
|||||||
ratio: "4:5" | "1:1" | "5:4";
|
ratio: "4:5" | "1:1" | "5:4";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NAV_LINKS = [
|
export type NavChild = { href: string; label: string };
|
||||||
{ href: "/", label: "خانه" },
|
export type NavLink = { href: string; label: string; children?: NavChild[] };
|
||||||
{ href: "/about", label: "درباره ما" },
|
|
||||||
{ href: "/contact", label: "تماس با ما" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const site = {
|
export const site = {
|
||||||
nameFa: "استودیو معماری هوم",
|
nameFa: "استودیو معماری هوم",
|
||||||
@@ -25,8 +22,10 @@ export const site = {
|
|||||||
"استودیو معماری هوم؛ گروه معماری و طراحی داخلی. طراحی نما، معماری مسکونی، طراحی داخلی و اجرای پروژههای ساختمانی با نگاهی مینیمال، دقیق و کاربردی.",
|
"استودیو معماری هوم؛ گروه معماری و طراحی داخلی. طراحی نما، معماری مسکونی، طراحی داخلی و اجرای پروژههای ساختمانی با نگاهی مینیمال، دقیق و کاربردی.",
|
||||||
logo: "/images/logo.png",
|
logo: "/images/logo.png",
|
||||||
address: "تهران",
|
address: "تهران",
|
||||||
|
/** Default map pin (Tehran) — replace when a studio address is confirmed */
|
||||||
|
map: { lat: 35.7219, lng: 51.3347, zoom: 13 },
|
||||||
email: "info@homgroup.com",
|
email: "info@homgroup.com",
|
||||||
phones: [{ display: "۰۹۱۲ ۰۰۰ ۰۰۰۰", href: "tel:+989120000000" }],
|
phones: [{ display: "0912 000 0000", href: "tel:+989120000000" }],
|
||||||
instagram: { href: "https://instagram.com/hom.archstudio", handle: "@hom.archstudio" },
|
instagram: { href: "https://instagram.com/hom.archstudio", handle: "@hom.archstudio" },
|
||||||
about: [
|
about: [
|
||||||
"استودیو معماری هوم یک گروه معماری و طراحی داخلی است که با تمرکز بر سادگی، تناسب و کیفیت اجرا، فضاهایی برای زندگی و کار طراحی میکند.",
|
"استودیو معماری هوم یک گروه معماری و طراحی داخلی است که با تمرکز بر سادگی، تناسب و کیفیت اجرا، فضاهایی برای زندگی و کار طراحی میکند.",
|
||||||
@@ -35,63 +34,87 @@ export const site = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const services = [
|
export type ServiceStep = { title: string; text: string };
|
||||||
|
|
||||||
|
export type Service = {
|
||||||
|
slug: string;
|
||||||
|
index: string;
|
||||||
|
titleFa: string;
|
||||||
|
titleEn: string;
|
||||||
|
text: string;
|
||||||
|
lead: string;
|
||||||
|
image: string;
|
||||||
|
frameImage: string;
|
||||||
|
steps: ServiceStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const services: Service[] = [
|
||||||
{
|
{
|
||||||
|
slug: "architecture",
|
||||||
index: "01",
|
index: "01",
|
||||||
titleFa: "طراحی معماری",
|
titleFa: "طراحی معماری",
|
||||||
titleEn: "Architecture",
|
titleEn: "Architecture",
|
||||||
text: "طراحی ساختمانهای مسکونی و تجاری از مرحله ایده تا نقشههای اجرایی، با رعایت ضوابط و تناسب با بستر پروژه.",
|
text: "طراحی ساختمانهای مسکونی و تجاری از مرحله ایده تا نقشههای اجرایی، با رعایت ضوابط و تناسب با بستر پروژه.",
|
||||||
|
lead: "از تحلیل زمین و برنامه فضایی تا پلانهای اجرایی؛ معماری که با نور، سازه و زندگی روزمره هماهنگ است.",
|
||||||
|
image: "/images/courtyard-brick.jpg",
|
||||||
|
frameImage: "/images/courtyard-brick-2.jpg",
|
||||||
|
steps: [
|
||||||
|
{ title: "شناخت بستر", text: "بررسی زمین، ضوابط، نور و نیاز کارفرما." },
|
||||||
|
{ title: "ایده و اسکیس", text: "شکلگیری حجم و سازمان فضایی اولیه." },
|
||||||
|
{ title: "توسعه طرح", text: "پلان، نما و مقاطع تا هماهنگی کامل." },
|
||||||
|
{ title: "نقشه اجرایی", text: "مدارک آماده برای اخذ مجوز و ساخت." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
slug: "interior-design",
|
||||||
index: "02",
|
index: "02",
|
||||||
titleFa: "طراحی داخلی",
|
titleFa: "طراحی داخلی",
|
||||||
titleEn: "Interior Design",
|
titleEn: "Interior Design",
|
||||||
text: "طراحی فضاهای داخلی، چیدمان، نورپردازی و انتخاب متریال؛ آشپزخانه، نشیمن، اتاق کار و فضاهای تجاری.",
|
text: "طراحی فضاهای داخلی، چیدمان، نورپردازی و انتخاب متریال؛ آشپزخانه، نشیمن، اتاق کار و فضاهای تجاری.",
|
||||||
|
lead: "فضای داخلی باید آرام، کاربردی و دقیق باشد؛ از چیدمان تا متریال و نور، همه در خدمت تجربه زندگی.",
|
||||||
|
image: "/images/kitchen-cream.jpg",
|
||||||
|
frameImage: "/images/kitchen-oak.jpg",
|
||||||
|
steps: [
|
||||||
|
{ title: "برنامه فضایی", text: "تعیین کارکردها و مسیر حرکت در فضا." },
|
||||||
|
{ title: "ایده طراحی", text: "مفهوم، پالت متریال و حس کلی فضا." },
|
||||||
|
{ title: "جزئیات", text: "نور، مبلمان، کابینت و نقاط تمرکز." },
|
||||||
|
{ title: "اجرای داخلی", text: "نقشهها و همراهی تا تحویل فضا." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
slug: "facade-design",
|
||||||
index: "03",
|
index: "03",
|
||||||
titleFa: "طراحی نما",
|
titleFa: "طراحی نما",
|
||||||
titleEn: "Facade Design",
|
titleEn: "Facade Design",
|
||||||
text: "طراحی و بازطراحی نمای ساختمان با متریالهای آجر، سنگ، چوب و فلز؛ همراه با مدلسازی و رندر واقعگرایانه.",
|
text: "طراحی و بازطراحی نمای ساختمان با متریالهای آجر، سنگ، چوب و فلز؛ همراه با مدلسازی و رندر واقعگرایانه.",
|
||||||
|
lead: "نما اولین برخورد با بناست؛ ریتم، متریال و نورپردازی را طوری میچینیم که هویت پروژه خوانا بماند.",
|
||||||
|
image: "/images/facade-louver.jpg",
|
||||||
|
frameImage: "/images/facade-sunset.jpg",
|
||||||
|
steps: [
|
||||||
|
{ title: "تحلیل نما", text: "بررسی جهت، همسایگی و ضوابط شهری." },
|
||||||
|
{ title: "زبان بصری", text: "ریتم، عمق و ترکیب متریال." },
|
||||||
|
{ title: "مدل و رندر", text: "شبیهسازی واقعگرایانه روز و شب." },
|
||||||
|
{ title: "جزئیات اجرایی", text: "دیتیل اتصال و آماده ساخت." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
slug: "build-supervision",
|
||||||
index: "04",
|
index: "04",
|
||||||
titleFa: "نظارت و اجرا",
|
titleFa: "نظارت و اجرا",
|
||||||
titleEn: "Build & Supervision",
|
titleEn: "Build & Supervision",
|
||||||
text: "مدیریت و نظارت بر اجرای پروژه تا تحویل نهایی، تا آنچه طراحی شده دقیقاً همانطور ساخته شود.",
|
text: "مدیریت و نظارت بر اجرای پروژه تا تحویل نهایی، تا آنچه طراحی شده دقیقاً همانطور ساخته شود.",
|
||||||
|
lead: "طرح روی کاغذ کافی نیست؛ با نظارت مستمر، کیفیت اجرا را تا تحویل نهایی حفظ میکنیم.",
|
||||||
|
image: "/images/garden-gate.jpg",
|
||||||
|
frameImage: "/images/facade-sunset-detail.jpg",
|
||||||
|
steps: [
|
||||||
|
{ title: "برنامه اجرا", text: "زمانبندی، اولویتها و هماهنگی عوامل." },
|
||||||
|
{ title: "کنترل کیفیت", text: "بررسی متریال و جزئیات در کارگاه." },
|
||||||
|
{ title: "هماهنگی تیمها", text: "ارتباط مستمر بین طراحی و اجرا." },
|
||||||
|
{ title: "تحویل نهایی", text: "بازبینی کامل تا تطبیق با طرح." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const processSteps = [
|
|
||||||
{
|
|
||||||
step: "۱",
|
|
||||||
titleFa: "شناخت",
|
|
||||||
text: "جلسه آشنایی، بازدید از محل و تعریف دقیق نیازها، بودجه و زمانبندی پروژه.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "۲",
|
|
||||||
titleFa: "ایده و طرح اولیه",
|
|
||||||
text: "مطالعه بستر، ارائه کانسپت و گزینههای طراحی به همراه تصاویر سهبعدی.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "۳",
|
|
||||||
titleFa: "طراحی تفصیلی",
|
|
||||||
text: "تهیه نقشههای اجرایی، جزئیات، جدول متریال و برآورد هزینه.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: "۴",
|
|
||||||
titleFa: "اجرا و تحویل",
|
|
||||||
text: "هماهنگی پیمانکاران، نظارت مستمر بر کیفیت اجرا و تحویل پروژه.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const stats = [
|
|
||||||
{ value: "۱۰+", label: "سال تجربه" },
|
|
||||||
{ value: "۸۰+", label: "پروژه طراحیشده" },
|
|
||||||
{ value: "۳۰+", label: "پروژه اجراشده" },
|
|
||||||
{ value: "۱۰۰٪", label: "تعهد به جزئیات" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const projects: Project[] = [
|
export const projects: Project[] = [
|
||||||
{
|
{
|
||||||
slug: "sunset-brick-villa",
|
slug: "sunset-brick-villa",
|
||||||
@@ -155,14 +178,74 @@ export const projects: Project[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Top-level nav; items with `children` render a dropdown. */
|
||||||
|
export const NAV_LINKS: NavLink[] = [
|
||||||
|
{ href: "/", label: "خانه" },
|
||||||
|
{
|
||||||
|
href: "/portfolio",
|
||||||
|
label: "پروژه",
|
||||||
|
children: projects.map((project) => ({
|
||||||
|
href: `/portfolio/${project.slug}`,
|
||||||
|
label: project.titleFa,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/services",
|
||||||
|
label: "تخصصهای ما",
|
||||||
|
children: services.map((service) => ({
|
||||||
|
href: `/services/${service.slug}`,
|
||||||
|
label: service.titleFa,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ href: "/blog", label: "بلاگ" },
|
||||||
|
{ href: "/about", label: "درباره ما" },
|
||||||
|
{ href: "/contact", label: "تماس با ما" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const processSteps = [
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
titleFa: "شناخت",
|
||||||
|
text: "جلسه آشنایی، بازدید از محل و تعریف دقیق نیازها، بودجه و زمانبندی پروژه.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
titleFa: "ایده و طرح اولیه",
|
||||||
|
text: "مطالعه بستر، ارائه کانسپت و گزینههای طراحی به همراه تصاویر سهبعدی.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
titleFa: "طراحی تفصیلی",
|
||||||
|
text: "تهیه نقشههای اجرایی، جزئیات، جدول متریال و برآورد هزینه.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
titleFa: "اجرا و تحویل",
|
||||||
|
text: "هماهنگی پیمانکاران، نظارت مستمر بر کیفیت اجرا و تحویل پروژه.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const stats = [
|
||||||
|
{ value: "10+", label: "سال تجربه" },
|
||||||
|
{ value: "80+", label: "پروژه طراحیشده" },
|
||||||
|
{ value: "30+", label: "پروژه اجراشده" },
|
||||||
|
{ value: "100%", label: "تعهد به جزئیات" },
|
||||||
|
];
|
||||||
|
|
||||||
export const gallery = {
|
export const gallery = {
|
||||||
hero: "/images/facade-sunset.jpg",
|
/** Fallback slides for homepage `slider` slot */
|
||||||
studio: "/images/garden-gate.jpg",
|
heroSlides: [
|
||||||
aboutHero: "/images/courtyard-brick.jpg",
|
{ url: "/images/facade-sunset.jpg", titleFa: "ویلای آجری", titleEn: "Brick Residence" },
|
||||||
aboutGallery: [
|
{ url: "/images/courtyard-brick.jpg", titleFa: "خانه حیاطدار", titleEn: "Courtyard House" },
|
||||||
"/images/kitchen-oak-2.jpg",
|
{ url: "/images/garden-gate.jpg", titleFa: "ورودی سبز", titleEn: "Garden Gate" },
|
||||||
"/images/facade-sunset-detail.jpg",
|
{ url: "/images/facade-louver.jpg", titleFa: "نمای لوور", titleEn: "Louver Facade" },
|
||||||
"/images/study-terracotta-2.jpg",
|
|
||||||
],
|
],
|
||||||
contact: "/images/kitchen-cream-portrait.jpg",
|
studio: "/images/garden-gate.jpg",
|
||||||
|
instagram: "/images/hom-instagram.jpeg",
|
||||||
|
aboutImages: [
|
||||||
|
{ url: "/images/courtyard-brick.jpg", titleFa: "خانه حیاطدار", titleEn: "Courtyard", subtext: "معماری مسکونی" },
|
||||||
|
{ url: "/images/kitchen-oak-2.jpg", titleFa: "آشپزخانه بلوط", titleEn: "Oak Kitchen", subtext: "طراحی داخلی" },
|
||||||
|
],
|
||||||
|
/** Fallback logos for homepage `brands` slot — empty until CMS uploads */
|
||||||
|
brands: [] as { url: string; titleFa?: string; titleEn?: string; linkUrl?: string }[],
|
||||||
};
|
};
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
import { meshkeeGet, meshkeeGetOrNull, tenantPath } from "@/lib/meshkee";
|
||||||
|
import { detailPath, slugifyForUrl } from "@/lib/slug";
|
||||||
|
|
||||||
|
export type BlogPost = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug?: string | null;
|
||||||
|
abstract?: string | null;
|
||||||
|
mainTextHtml?: string | null;
|
||||||
|
titleImageUrl?: string | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
commentCount?: number;
|
||||||
|
categoryName?: string | null;
|
||||||
|
categoryNameFa?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PagedBlogs = {
|
||||||
|
items: BlogPost[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOCAL_POSTS: BlogPost[] = [
|
||||||
|
{
|
||||||
|
id: "local-architecture-light",
|
||||||
|
title: "معماری ساده و نور طبیعی",
|
||||||
|
slug: "architecture-light",
|
||||||
|
abstract: "نگاهی به نقش نور در فضاهای مسکونی مینیمال.",
|
||||||
|
titleImageUrl: "/images/courtyard-brick.jpg",
|
||||||
|
mainTextHtml:
|
||||||
|
"<p>نور طبیعی یکی از مهمترین ابزارهای طراحی در استودیو هوم است. با کنترل ورود نور و سایه، فضا هم خوانا میماند و هم در طول روز تغییر احساس میدهد.</p>",
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "local-material-detail",
|
||||||
|
title: "جزئیات متریال در طراحی داخلی",
|
||||||
|
slug: "material-detail",
|
||||||
|
abstract: "چگونه انتخاب متریال، حس فضا را برای سالها حفظ میکند.",
|
||||||
|
titleImageUrl: "/images/kitchen-oak.jpg",
|
||||||
|
mainTextHtml:
|
||||||
|
"<p>در طراحی داخلی، متریال فقط پوشش نیست؛ ریتم و دوام فضا را میسازد. چوب، سنگ و فلز وقتی درست کنار هم قرار بگیرند، فضا را آرام و ماندگار میکنند.</p>",
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "local-facade-rhythm",
|
||||||
|
title: "ریتم نما و اولین برخورد با بنا",
|
||||||
|
slug: "facade-rhythm",
|
||||||
|
abstract: "طراحی نما بهعنوان هویت بیرونی پروژه.",
|
||||||
|
titleImageUrl: "/images/facade-louver.jpg",
|
||||||
|
mainTextHtml:
|
||||||
|
"<p>نما اولین جملهٔ معماری است. ریتم بازشوها، عمق و متریال باید با بافت اطراف هماهنگ باشد و در عین حال شخصیت پروژه را نشان دهد.</p>",
|
||||||
|
publishedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function fetchBlogs(params: { page?: number; pageSize?: number } = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("page", String(params.page ?? 1));
|
||||||
|
query.set("pageSize", String(params.pageSize ?? 12));
|
||||||
|
const data = await meshkeeGet<PagedBlogs>(tenantPath(`/blogs?${query.toString()}`), {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: params.pageSize ?? 12,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
items: (data.items ?? []).map((item) => ({ ...item, id: String(item.id) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBlogBySlug(slug: string) {
|
||||||
|
const data = await meshkeeGetOrNull<{ blog?: BlogPost }>(
|
||||||
|
tenantPath(`/blogs/${encodeURIComponent(slug)}`),
|
||||||
|
);
|
||||||
|
return data?.blog ? { ...data.blog, id: String(data.blog.id) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBlogById(id: string) {
|
||||||
|
const data = await meshkeeGetOrNull<{ blog?: BlogPost }>(
|
||||||
|
tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`),
|
||||||
|
);
|
||||||
|
return data?.blog ? { ...data.blog, id: String(data.blog.id) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogHref(post: Pick<BlogPost, "id" | "slug" | "title">) {
|
||||||
|
if (post.slug?.trim()) return `/blog/${encodeURIComponent(post.slug.trim())}`;
|
||||||
|
return detailPath("/blog", post.id, post.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blogImage(post: BlogPost, fallback = "/images/garden-gate.jpg") {
|
||||||
|
return post.titleImageUrl?.trim() || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asBlogHtml(input?: string | null): string | undefined {
|
||||||
|
if (!input?.trim()) return undefined;
|
||||||
|
if (/<[a-z][\s\S]*>/i.test(input)) return input;
|
||||||
|
return input
|
||||||
|
.split(/\r?\n+/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => `<p>${line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</p>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalBlogPosts(): BlogPost[] {
|
||||||
|
return LOCAL_POSTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalBlogPost(slugOrId: string) {
|
||||||
|
const decoded = decodeURIComponent(slugOrId);
|
||||||
|
return (
|
||||||
|
LOCAL_POSTS.find(
|
||||||
|
(post) =>
|
||||||
|
post.slug === decoded ||
|
||||||
|
post.id === decoded ||
|
||||||
|
slugifyForUrl(post.title) === decoded,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBlogDate(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleDateString("fa-IR");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
export const websiteDomain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.com";
|
export const websiteDomain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.ir";
|
||||||
|
|
||||||
export const meshkeeApiBase =
|
export const meshkeeApiBase =
|
||||||
process.env.NEXT_PUBLIC_MESHKEE_API_BASE || "https://api.meshkee.com/api/v1";
|
process.env.NEXT_PUBLIC_MESHKEE_API_BASE || "https://api.meshkee.com/api/v1";
|
||||||
@@ -6,3 +6,5 @@ export const meshkeeApiBase =
|
|||||||
export const siteUrl = `https://${websiteDomain}`;
|
export const siteUrl = `https://${websiteDomain}`;
|
||||||
|
|
||||||
export const customerPortalUrl = `https://customer.${websiteDomain}`;
|
export const customerPortalUrl = `https://customer.${websiteDomain}`;
|
||||||
|
|
||||||
|
export const businessDashboardUrl = `https://business.${websiteDomain}`;
|
||||||
|
|||||||
+6
-3
@@ -28,10 +28,13 @@ export type PhoneLink = { href: string; display: string };
|
|||||||
export function resolvePhones(info: BusinessInfo): PhoneLink[] {
|
export function resolvePhones(info: BusinessInfo): PhoneLink[] {
|
||||||
const fromApi = (info.phoneNumbers ?? []).map(textOf).filter(Boolean);
|
const fromApi = (info.phoneNumbers ?? []).map(textOf).filter(Boolean);
|
||||||
if (!fromApi.length) return [...site.phones];
|
if (!fromApi.length) return [...site.phones];
|
||||||
return fromApi.map((display) => ({
|
return fromApi.map((raw) => {
|
||||||
|
const display = toLatinDigits(raw);
|
||||||
|
return {
|
||||||
display,
|
display,
|
||||||
href: `tel:${toLatinDigits(display).replace(/[^\d+]/g, "")}`,
|
href: `tel:${display.replace(/[^\d+]/g, "")}`,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAddress(info: BusinessInfo) {
|
export function resolveAddress(info: BusinessInfo) {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { meshkeeApiBase } from "@/lib/config";
|
||||||
|
|
||||||
|
export function getBearerToken(request: Request): string | null {
|
||||||
|
const auth = request.headers.get("authorization");
|
||||||
|
if (auth?.startsWith("Bearer ")) return auth.slice(7);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function meshkeeFetch(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit & { token?: string | null },
|
||||||
|
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!headers.has("Content-Type") && init?.body) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
if (init?.token) {
|
||||||
|
headers.set("Authorization", `Bearer ${init.token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${meshkeeApiBase}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
let data: unknown = null;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: res.ok, status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsonResponse(data: unknown, status = 200) {
|
||||||
|
return Response.json(data, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unauthorizedResponse() {
|
||||||
|
return jsonResponse({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
@@ -12,10 +12,36 @@ export async function meshkeeGet<T>(path: string, fallback: T, revalidate = 300)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function meshkeeGetOrNull<T>(path: string, revalidate = 300): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${meshkeeApiBase}${path}`, { next: { revalidate } });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return (await response.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function tenantPath(suffix: string) {
|
export function tenantPath(suffix: string) {
|
||||||
return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
|
return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tenant = {
|
||||||
|
id: number | string;
|
||||||
|
nameFa?: string;
|
||||||
|
name?: string;
|
||||||
|
domain?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTenant = cache(async () =>
|
||||||
|
meshkeeGet<Tenant | null>(`/tenants/${encodeURIComponent(websiteDomain)}`, null),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getBusinessId = cache(async () => {
|
||||||
|
const tenant = await getTenant();
|
||||||
|
return tenant?.id != null ? String(tenant.id) : "";
|
||||||
|
});
|
||||||
|
|
||||||
export type FaviconInfo = {
|
export type FaviconInfo = {
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { projects } from "@/data/site";
|
||||||
|
import { meshkeeGet, meshkeeGetOrNull, tenantPath } from "@/lib/meshkee";
|
||||||
|
import { detailPath, slugifyForUrl } from "@/lib/slug";
|
||||||
|
|
||||||
|
export type PortfolioItem = {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
titleFa?: string | null;
|
||||||
|
titleEn?: string | null;
|
||||||
|
slug?: string | null;
|
||||||
|
abstract?: string | null;
|
||||||
|
mainTextHtml?: string | null;
|
||||||
|
titleImageUrl?: string | null;
|
||||||
|
categoryId?: string | null;
|
||||||
|
categoryName?: string | null;
|
||||||
|
projectUrl?: string | null;
|
||||||
|
gallery?: { url?: string | null }[] | string[] | null;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PagedPortfolios = {
|
||||||
|
items: PortfolioItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalize(item: PortfolioItem): PortfolioItem {
|
||||||
|
return { ...item, id: String(item.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPortfolios(params: { page?: number; pageSize?: number } = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("page", String(params.page ?? 1));
|
||||||
|
query.set("pageSize", String(params.pageSize ?? 12));
|
||||||
|
const data = await meshkeeGet<PagedPortfolios>(tenantPath(`/portfolios?${query.toString()}`), {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: params.pageSize ?? 12,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
items: (data.items ?? []).map(normalize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPortfolioBySlug(slug: string) {
|
||||||
|
const data = await meshkeeGetOrNull<{ portfolio?: PortfolioItem }>(
|
||||||
|
tenantPath(`/portfolios/${encodeURIComponent(slug)}`),
|
||||||
|
);
|
||||||
|
return data?.portfolio ? normalize(data.portfolio) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPortfolioById(id: string) {
|
||||||
|
const data = await meshkeeGetOrNull<{ portfolio?: PortfolioItem }>(
|
||||||
|
tenantPath(`/portfolios/by-id/${encodeURIComponent(id)}`),
|
||||||
|
);
|
||||||
|
return data?.portfolio ? normalize(data.portfolio) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portfolioTitle(item: Pick<PortfolioItem, "titleFa" | "title" | "titleEn">) {
|
||||||
|
return item.titleFa?.trim() || item.title?.trim() || item.titleEn?.trim() || "نمونه کار";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portfolioHref(item: Pick<PortfolioItem, "id" | "slug" | "titleFa" | "title">) {
|
||||||
|
if (item.slug?.trim()) return `/portfolio/${encodeURIComponent(item.slug.trim())}`;
|
||||||
|
return detailPath("/portfolio", item.id, portfolioTitle(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portfolioImages(item: PortfolioItem, fallback = "/images/facade-sunset.jpg"): string[] {
|
||||||
|
const extras = (item.gallery ?? [])
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : entry?.url))
|
||||||
|
.filter((url): url is string => Boolean(url?.trim()));
|
||||||
|
const urls = [item.titleImageUrl?.trim(), ...extras].filter((url): url is string => Boolean(url));
|
||||||
|
return urls.length ? [...new Set(urls)] : [fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portfolioCover(item: PortfolioItem, fallback = "/images/facade-sunset.jpg") {
|
||||||
|
return portfolioImages(item, fallback)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRichHtml(input?: string | null): string | undefined {
|
||||||
|
if (!input?.trim()) return undefined;
|
||||||
|
if (/<[a-z][\s\S]*>/i.test(input)) return input;
|
||||||
|
return input
|
||||||
|
.split(/\r?\n+/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => `<p>${line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</p>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalPortfolios(): PortfolioItem[] {
|
||||||
|
return projects.map((project) => ({
|
||||||
|
id: `local-${project.slug}`,
|
||||||
|
slug: project.slug,
|
||||||
|
title: project.titleFa,
|
||||||
|
titleFa: project.titleFa,
|
||||||
|
titleEn: project.titleEn,
|
||||||
|
abstract: `${project.category} · ${project.year} · ${project.location}`,
|
||||||
|
categoryName: project.category,
|
||||||
|
titleImageUrl: project.image,
|
||||||
|
gallery: [project.image],
|
||||||
|
mainTextHtml: `<p>${project.titleFa} — ${project.category} در ${project.location}، سال ${project.year}.</p>`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalPortfolio(slugOrId: string) {
|
||||||
|
const decoded = decodeURIComponent(slugOrId);
|
||||||
|
return (
|
||||||
|
getLocalPortfolios().find(
|
||||||
|
(item) => item.slug === decoded || item.id === decoded || item.id === `local-${decoded}`,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localPortfolioSlug(title: string) {
|
||||||
|
return slugifyForUrl(title);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearSharedAuthCookies,
|
||||||
|
hydrateLocalTokensFromSharedCookies,
|
||||||
|
SHARED_ACCESS_TOKEN_KEY,
|
||||||
|
SHARED_REFRESH_TOKEN_KEY,
|
||||||
|
writeSharedAuthCookies,
|
||||||
|
} from "@/lib/shared-auth-cookie";
|
||||||
|
|
||||||
|
export type AuthBusinessMembership = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
teamRole: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
cellNumber?: string;
|
||||||
|
email?: string;
|
||||||
|
roles?: string[];
|
||||||
|
isSuperAdmin?: boolean;
|
||||||
|
businesses?: AuthBusinessMembership[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionContextValue = {
|
||||||
|
user: AuthUser | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
businessId: string;
|
||||||
|
isWebsiteAdmin: boolean;
|
||||||
|
logout: () => void;
|
||||||
|
getAccessToken: () => string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUser(raw: Partial<AuthUser> & { id?: string | number }): AuthUser | null {
|
||||||
|
if (raw.id == null) return null;
|
||||||
|
return {
|
||||||
|
id: String(raw.id),
|
||||||
|
firstName: raw.firstName ?? undefined,
|
||||||
|
lastName: raw.lastName ?? undefined,
|
||||||
|
cellNumber: raw.cellNumber ?? undefined,
|
||||||
|
email: raw.email ?? undefined,
|
||||||
|
roles: Array.isArray(raw.roles) ? raw.roles : undefined,
|
||||||
|
isSuperAdmin: Boolean(raw.isSuperAdmin),
|
||||||
|
businesses: Array.isArray(raw.businesses)
|
||||||
|
? raw.businesses.map((business) => ({
|
||||||
|
id: String(business.id),
|
||||||
|
name: business.name,
|
||||||
|
slug: business.slug,
|
||||||
|
isOwner: Boolean(business.isOwner),
|
||||||
|
teamRole: business.teamRole ?? null,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string): boolean {
|
||||||
|
if (!user || !businessId) return false;
|
||||||
|
if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
|
||||||
|
return Boolean(
|
||||||
|
user.businesses?.some(
|
||||||
|
(business) => String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
|
|
||||||
|
function readToken(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToken(key: string, value: string | null) {
|
||||||
|
try {
|
||||||
|
if (value) localStorage.setItem(key, value);
|
||||||
|
else localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionProvider({
|
||||||
|
children,
|
||||||
|
businessId,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
businessId: string;
|
||||||
|
}) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const getAccessToken = useCallback(() => {
|
||||||
|
hydrateLocalTokensFromSharedCookies(SHARED_ACCESS_TOKEN_KEY, SHARED_REFRESH_TOKEN_KEY);
|
||||||
|
return (
|
||||||
|
readToken(SHARED_ACCESS_TOKEN_KEY) ||
|
||||||
|
readToken("accessToken") ||
|
||||||
|
readToken("meshkeeToken") ||
|
||||||
|
readToken("token")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearTokens = useCallback(() => {
|
||||||
|
writeToken(SHARED_ACCESS_TOKEN_KEY, null);
|
||||||
|
writeToken(SHARED_REFRESH_TOKEN_KEY, null);
|
||||||
|
writeToken("accessToken", null);
|
||||||
|
writeToken("meshkeeToken", null);
|
||||||
|
writeToken("token", null);
|
||||||
|
clearSharedAuthCookies();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = readToken(SHARED_REFRESH_TOKEN_KEY);
|
||||||
|
writeSharedAuthCookies(token, refresh ?? undefined);
|
||||||
|
|
||||||
|
fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearTokens();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { user?: AuthUser } & AuthUser;
|
||||||
|
const next = normalizeUser(data.user ?? data);
|
||||||
|
if (next) setUser(next);
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setReady(true));
|
||||||
|
}, [clearTokens, getAccessToken]);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearTokens();
|
||||||
|
}, [clearTokens]);
|
||||||
|
|
||||||
|
const isWebsiteAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
isAuthenticated: Boolean(user),
|
||||||
|
ready,
|
||||||
|
businessId,
|
||||||
|
isWebsiteAdmin,
|
||||||
|
logout,
|
||||||
|
getAccessToken,
|
||||||
|
}),
|
||||||
|
[user, ready, businessId, isWebsiteAdmin, logout, getAccessToken],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const ctx = useContext(SessionContext);
|
||||||
|
if (!ctx) throw new Error("useSession must be used within SessionProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userDisplayName(user: AuthUser | null | undefined) {
|
||||||
|
if (!user) return "کاربر";
|
||||||
|
const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return full || user.cellNumber || user.email || "کاربر";
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/** Shared auth cookies so storefront + customer.{domain} share one login. */
|
||||||
|
|
||||||
|
export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
|
||||||
|
export const SHARED_REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 2800;
|
||||||
|
const MAX_CHUNKS = 24;
|
||||||
|
|
||||||
|
function cookieDomain(): string | undefined {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
const host = window.location.hostname.toLowerCase();
|
||||||
|
if (host === "localhost" || host === "127.0.0.1") return undefined;
|
||||||
|
const parts = host.split(".").filter(Boolean);
|
||||||
|
if (parts.length < 2) return undefined;
|
||||||
|
return `.${parts.slice(-2).join(".")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRawCookie(name: string): string | null {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const match = document.cookie.split("; ").find((row) => row.startsWith(prefix));
|
||||||
|
if (!match) return null;
|
||||||
|
return match.slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRawCookie(name: string, value: string | null) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const domain = cookieDomain();
|
||||||
|
if (value == null) {
|
||||||
|
let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
|
||||||
|
if (domain) cookie += `; domain=${domain}`;
|
||||||
|
document.cookie = cookie;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const maxAge = 60 * 60 * 24 * 30;
|
||||||
|
let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
||||||
|
if (domain) cookie += `; domain=${domain}`;
|
||||||
|
document.cookie = cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChunkedCookie(name: string) {
|
||||||
|
writeRawCookie(name, null);
|
||||||
|
const n = Number(readRawCookie(`${name}_n`) || "0");
|
||||||
|
writeRawCookie(`${name}_n`, null);
|
||||||
|
for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
|
||||||
|
writeRawCookie(`${name}_${i}`, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCookie(name: string, value: string | null) {
|
||||||
|
clearChunkedCookie(name);
|
||||||
|
if (value == null || value === "") return;
|
||||||
|
|
||||||
|
const encoded = encodeURIComponent(value);
|
||||||
|
if (encoded.length <= 3500) {
|
||||||
|
writeRawCookie(name, encoded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
|
||||||
|
chunks.push(encoded.slice(i, i + CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
writeRawCookie(`${name}_n`, String(chunks.length));
|
||||||
|
chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCookie(name: string): string | null {
|
||||||
|
const n = Number(readRawCookie(`${name}_n`) || "0");
|
||||||
|
if (n > 0) {
|
||||||
|
let encoded = "";
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const part = readRawCookie(`${name}_${i}`);
|
||||||
|
if (part == null) return null;
|
||||||
|
encoded += part;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(encoded);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readRawCookie(name);
|
||||||
|
if (raw == null) return null;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
|
||||||
|
writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken);
|
||||||
|
if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSharedAuthCookies() {
|
||||||
|
writeCookie(SHARED_ACCESS_TOKEN_KEY, null);
|
||||||
|
writeCookie(SHARED_REFRESH_TOKEN_KEY, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSharedAccessToken(): string | null {
|
||||||
|
return readCookie(SHARED_ACCESS_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSharedRefreshToken(): string | null {
|
||||||
|
return readCookie(SHARED_REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hydrateLocalTokensFromSharedCookies(
|
||||||
|
accessKey = SHARED_ACCESS_TOKEN_KEY,
|
||||||
|
refreshKey = SHARED_REFRESH_TOKEN_KEY,
|
||||||
|
): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
if (localStorage.getItem(accessKey)) return false;
|
||||||
|
const access = readSharedAccessToken();
|
||||||
|
if (!access) return false;
|
||||||
|
localStorage.setItem(accessKey, access);
|
||||||
|
const refresh = readSharedRefreshToken();
|
||||||
|
if (refresh) localStorage.setItem(refreshKey, refresh);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** Build a URL path segment from a title (Persian / Arabic / Latin). */
|
||||||
|
export function slugifyForUrl(value: string, fallback = "item"): string {
|
||||||
|
const slug = value
|
||||||
|
.trim()
|
||||||
|
.normalize("NFC")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
|
||||||
|
return slug || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detailPath(base: string, id: string | number, title: string) {
|
||||||
|
const slug = slugifyForUrl(title);
|
||||||
|
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
|
||||||
|
}
|
||||||
+41
-32
@@ -27,58 +27,67 @@ export type StaticImageSlot = {
|
|||||||
*/
|
*/
|
||||||
export const STATIC_IMAGE_SLOTS: StaticImageSlot[] = [
|
export const STATIC_IMAGE_SLOTS: StaticImageSlot[] = [
|
||||||
{
|
{
|
||||||
key: "hero",
|
key: "slider",
|
||||||
label: "تصویر اصلی صفحه اول",
|
label: "اسلایدر تمامصفحه صفحه اول (قابل تکرار)",
|
||||||
kind: "single",
|
kind: "list",
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
aspectRatio: "16:9",
|
aspectRatio: "16:9",
|
||||||
itemCount: 1,
|
itemCount: null,
|
||||||
recommendedWidth: 1920,
|
recommendedWidth: 1920,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "projects",
|
key: "four-category",
|
||||||
label: "پروژههای منتخب صفحه اول",
|
label: "چهار خدمت / تخصص صفحه اول",
|
||||||
kind: "list",
|
kind: "list",
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
aspectRatio: "4:5",
|
aspectRatio: "3:4",
|
||||||
|
itemCount: 4,
|
||||||
|
recommendedWidth: 900,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "project-images",
|
||||||
|
label: "پروژههای منتخب صفحه اول (میسونری — اندازه آزاد)",
|
||||||
|
kind: "list",
|
||||||
|
pageKey: "home",
|
||||||
|
aspectRatio: "3:4",
|
||||||
itemCount: null,
|
itemCount: null,
|
||||||
recommendedWidth: 1200,
|
recommendedWidth: 1200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "studio-image",
|
key: "sticky-frame-bg",
|
||||||
label: "تصویر معرفی استودیو (صفحه اول)",
|
label: "پسزمینه فریم استیکی OpeningFrame",
|
||||||
kind: "single",
|
kind: "single",
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
aspectRatio: "4:3",
|
|
||||||
itemCount: 1,
|
|
||||||
recommendedWidth: 1400,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "about-hero",
|
|
||||||
label: "تصویر اصلی درباره ما",
|
|
||||||
kind: "single",
|
|
||||||
pageKey: "about",
|
|
||||||
aspectRatio: "16:9",
|
aspectRatio: "16:9",
|
||||||
itemCount: 1,
|
itemCount: 1,
|
||||||
recommendedWidth: 1920,
|
recommendedWidth: 1920,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "about-gallery",
|
key: "instagram-row",
|
||||||
label: "گالری درباره ما",
|
label: "فریم صفحه اینستاگرام (اسکرینشات موبایل)",
|
||||||
kind: "list",
|
kind: "single",
|
||||||
pageKey: "about",
|
pageKey: "home",
|
||||||
aspectRatio: "1:1",
|
aspectRatio: "9:16",
|
||||||
itemCount: 3,
|
itemCount: 1,
|
||||||
recommendedWidth: 900,
|
recommendedWidth: 720,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "contact-image",
|
key: "brands",
|
||||||
label: "تصویر تماس با ما",
|
label: "لوگوی برندها / همکاران (قابل تکرار)",
|
||||||
kind: "single",
|
kind: "list",
|
||||||
pageKey: "contact",
|
pageKey: "home",
|
||||||
aspectRatio: "4:5",
|
aspectRatio: "3:2",
|
||||||
itemCount: 1,
|
itemCount: null,
|
||||||
recommendedWidth: 1200,
|
recommendedWidth: 480,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "about-us-image",
|
||||||
|
label: "تصاویر درباره ما (۲ تصویر)",
|
||||||
|
kind: "list",
|
||||||
|
pageKey: "about",
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
itemCount: 2,
|
||||||
|
recommendedWidth: 1600,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user