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
|
||||
# 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
|
||||
|
||||
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.
|
||||
|
||||
## Source of truth
|
||||
- Live domain: `https://homgroup.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.com`)
|
||||
- Backend docs: https://api.meshkee.com/docs/website (OpenAPI: `/docs/website/openapi.json`)
|
||||
- API base: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_MESHKEE_API_BASE`)
|
||||
- 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.
|
||||
- Prompt reference: `https://api.meshkee.com/api/v1/public/ai-prompts/studio-hom/basic-create/36caa331616c4b1cd382f8cb`
|
||||
- Original brief: homepage + about + contact only; `lang=fa`, `dir=rtl`, flat design, palette from the logo.
|
||||
- Live domain: `https://homgroup.ir` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.ir`)
|
||||
- Backend docs: https://api.meshkee.com/docs/website
|
||||
- API base: `https://api.meshkee.com/api/v1`
|
||||
- Tenant: `GET /tenants/homgroup.ir` → business id `53`
|
||||
- Customer portal: `https://customer.homgroup.ir` (no storefront login pages)
|
||||
- Business dashboard: `https://business.homgroup.ir`
|
||||
- Placeholders prompt: `…/ai-prompts/studio-hom/placeholders/576804987514877ec206af60`
|
||||
|
||||
## Stack
|
||||
- Next.js 15 App Router + React 19 + TypeScript, no Tailwind — all styles in `src/app/globals.css`
|
||||
- `output: "standalone"` in `next.config.ts` (**required** for the Meshkee websites VM; deploy drops `node_modules`)
|
||||
- `npm run build` runs `prebuild` → `scripts/generate-sitemap-config.mjs` (scans `src/app` for static pages)
|
||||
- `npm run lint` = `tsc --noEmit`
|
||||
- Font: IranYekan (local, `public/fonts/`, weights 300/400/500/700) via `next/font/local` → `--font-yekan`
|
||||
- Next.js 15 App Router + React 19 + TypeScript; styles in `src/app/globals.css` (no Tailwind)
|
||||
- `output: "standalone"` required for Meshkee websites VM
|
||||
- Fonts: IranYekan (FA) + Montserrat (EN / `.latin`)
|
||||
|
||||
## Design language (flat)
|
||||
- Palette: bg `#ffffff`, surface `#f4f3f1`, text `#111111`, muted `#6b6b6b`, hairline `#e2e0dc`, accent red `#e3002b` (the logo dot)
|
||||
- Flat only: no shadows, no border-radius, 1px hairlines, solid fills. Red appears as a small square “dot” (eyebrows, hero title) and on hover.
|
||||
- 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.
|
||||
- Logo: `public/images/logo.png` (cropped from `assets/logo/logo.jpeg`); favicon `src/app/icon.png`
|
||||
## Design
|
||||
- Flat palette: bg `#fff`, surface `#f4f3f1`, fg `#111`, muted `#6b6b6b`, line `#e2e0dc`, accent `#e3002b`
|
||||
- Sticky header + account menu; dark map band on contact; stats band with pattern
|
||||
|
||||
## Static image slots (`STATIC_IMAGE_SLOTS` → `/meshkee/static-image-slots`)
|
||||
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
|
||||
| Route | Purpose |
|
||||
| Route | Notes |
|
||||
| --- | --- |
|
||||
| `/` | Hero (title + CMS hero image), intro, 4 services, 6 selected projects, studio block, 4-step process (dark), stats, CTA |
|
||||
| `/about` | Page hero image, story (3 paragraphs from `business-info.about` or fallback), 3-image gallery, values, services list, stats, CTA |
|
||||
| `/contact` | Facts (phones, email, Instagram, address), contact image, form → `POST /api/contact` |
|
||||
| `/api/contact` | Proxies to `POST /tenants/{domain}/contact-submissions` (`title`, `name`, `text` required; `email`, `cellNumber` optional) |
|
||||
| `/meshkee/static-image-slots` | Slot catalog (`STATIC_IMAGE_SLOTS`) for the dashboard “Refresh slots” |
|
||||
| `/meshkee/sitemap-config.json` | Static pages for the Meshkee-generated sitemap |
|
||||
| `/` | Slider, intro, four-category, project-images, sticky frame, process, stats, brands, Instagram, CTA |
|
||||
| `/portfolio`, `/portfolio/[slug]` | Meshkee portfolios (+ local fallback); lightbox + zoom + similar |
|
||||
| `/projects*` | Redirect → `/portfolio` |
|
||||
| `/blog`, `/blog/[slug]` | Meshkee blogs (+ local stubs) |
|
||||
| `/services*` | Local specialties |
|
||||
| `/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).
|
||||
|
||||
## 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)
|
||||
## Components
|
||||
`Header`/`AccountMenu`, `Footer`, `Providers`, `CmsMeta`, `SlotImage`, `SectionTitle`, `OpeningFrame`, `HeroSlider`, `InstagramFollow`, `MediaGallery`, `ContactForm`
|
||||
|
||||
## 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
|
||||
- 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 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 = []) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
|
||||
+79
-30
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SectionTitle } from "@/components/SectionTitle";
|
||||
import { SlotImage } from "@/components/SlotImage";
|
||||
import { gallery, services, site, stats } from "@/data/site";
|
||||
import { resolveAbout, resolveName } from "@/lib/content";
|
||||
@@ -17,14 +18,32 @@ const values = [
|
||||
{
|
||||
title: "سادگی",
|
||||
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: "تناسب",
|
||||
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: "اجرای دقیق",
|
||||
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 name = resolveName(info);
|
||||
const about = resolveAbout(info);
|
||||
const hero = slotImages("about-hero", slots, [{ url: gallery.aboutHero, titleFa: "خانه حیاطدار" }])[0];
|
||||
const images = slotImages(
|
||||
"about-gallery",
|
||||
slots,
|
||||
gallery.aboutGallery.map((url, index) => ({ url, titleFa: `نمونه کار ${index + 1}` })),
|
||||
);
|
||||
const aboutImages = slotImages("about-us-image", slots, gallery.aboutImages);
|
||||
const hero = aboutImages[0];
|
||||
const galleryImages = aboutImages.slice(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">درباره ما</p>
|
||||
<h1>{name}؛ گروه معماری و طراحی داخلی</h1>
|
||||
<SectionTitle
|
||||
en="about"
|
||||
title={`${name}؛`}
|
||||
titleMuted="گروه معماری و طراحی داخلی"
|
||||
as="h1"
|
||||
className="section-title--inline"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<section className="section">
|
||||
<div className="container split">
|
||||
<div>
|
||||
<p className="eyebrow">داستان ما</p>
|
||||
<h2 className="title">معماری از گوش دادن شروع میشود</h2>
|
||||
<SectionTitle
|
||||
en="story"
|
||||
title="معماری از گوش دادن"
|
||||
titleMuted="شروع میشود"
|
||||
className="section-title--compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="about-text">
|
||||
{about.map((paragraph, index) => (
|
||||
@@ -66,24 +97,33 @@ export default async function AboutPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{galleryImages.length ? (
|
||||
<section className="section--tight">
|
||||
<div className="container about-gallery">
|
||||
{images.slice(0, 3).map((image, index) => (
|
||||
<SlotImage key={`${image.url}-${index}`} image={image} alt={`نمونه کار ${name}`} ratio={slotRatio("about-gallery", slots)} />
|
||||
<div className="container about-gallery about-gallery--pair">
|
||||
{galleryImages.slice(0, 1).map((image, index) => (
|
||||
<SlotImage
|
||||
key={`${image.url}-${index}`}
|
||||
image={image}
|
||||
alt={image.titleFa || image.titleEn || `نمونه کار ${name}`}
|
||||
ratio={slotRatio("about-us-image", slots)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section section--surface">
|
||||
<div className="container">
|
||||
<p className="eyebrow">ارزشها</p>
|
||||
<h2 className="title">آنچه در هر پروژه به آن پایبندیم</h2>
|
||||
<SectionTitle en="values" title="آنچه در هر پروژه" titleMuted="به آن پایبندیم" />
|
||||
<div className="values">
|
||||
{values.map((value) => (
|
||||
<div key={value.title} className="value">
|
||||
<span className="value__icon">{value.icon}</span>
|
||||
<div className="value__body">
|
||||
<h3>{value.title}</h3>
|
||||
<p>{value.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,18 +132,22 @@ export default async function AboutPage() {
|
||||
<section className="section">
|
||||
<div className="container split">
|
||||
<div>
|
||||
<p className="eyebrow">خدمات</p>
|
||||
<h2 className="title">از نما تا جزئیات داخلی</h2>
|
||||
<SectionTitle
|
||||
en="services"
|
||||
title="از نما تا"
|
||||
titleMuted="جزئیات داخلی"
|
||||
className="section-title--compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="about-text">
|
||||
<p>
|
||||
خدمات {site.shortFa} چهار حوزه اصلی را پوشش میدهد و در هر پروژه، بسته به نیاز شما، یک یا چند مورد از آنها
|
||||
کنار هم قرار میگیرند.
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingInlineStart: 20, display: "grid", gap: 10 }}>
|
||||
<ul>
|
||||
{services.map((service) => (
|
||||
<li key={service.index}>
|
||||
<strong style={{ color: "var(--fg)", fontWeight: 500 }}>{service.titleFa}</strong> — {service.text}
|
||||
<strong>{service.titleFa}</strong> — {service.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -111,31 +155,36 @@ export default async function AboutPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section--tight">
|
||||
<div className="container">
|
||||
<section className="stats-band" aria-label="آمار استودیو">
|
||||
<div className="stats">
|
||||
{stats.map((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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<section className="cta-band">
|
||||
<div className="cta-band__glow" aria-hidden="true" />
|
||||
<div className="container cta">
|
||||
<div>
|
||||
<p className="eyebrow">همکاری</p>
|
||||
<h2>آمادهایم درباره پروژه شما صحبت کنیم.</h2>
|
||||
</div>
|
||||
<Link href="/contact" className="btn">
|
||||
<SectionTitle
|
||||
en="collaborate"
|
||||
title="آمادهایم درباره پروژه شما"
|
||||
titleMuted="صحبت کنیم."
|
||||
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">
|
||||
<path d="M4 12h16M13 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</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 { ReactNode } from "react";
|
||||
|
||||
import { ContactForm } from "@/components/ContactForm";
|
||||
import { SlotImage } from "@/components/SlotImage";
|
||||
import { gallery } from "@/data/site";
|
||||
import { ContactMap } from "@/components/ContactMap";
|
||||
import { InstagramFollow } from "@/components/InstagramFollow";
|
||||
import { SectionTitle } from "@/components/SectionTitle";
|
||||
import { gallery, site } from "@/data/site";
|
||||
import {
|
||||
resolveAddress,
|
||||
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() {
|
||||
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("contact")]);
|
||||
const [info, homeSlots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]);
|
||||
const name = resolveName(info);
|
||||
const phones = resolvePhones(info);
|
||||
const emails = resolveEmails(info);
|
||||
const instagram = resolveInstagram(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 (
|
||||
<>
|
||||
<section className="page-hero">
|
||||
<div className="container">
|
||||
<p className="eyebrow">تماس با ما</p>
|
||||
<h1>درباره پروژهتان صحبت کنیم</h1>
|
||||
<section className="contact-hero">
|
||||
<div className="contact-hero__glow" aria-hidden="true" />
|
||||
<div className="container contact-hero__inner">
|
||||
<SectionTitle
|
||||
en="contact"
|
||||
title="درباره پروژهتان"
|
||||
titleMuted="صحبت کنیم"
|
||||
as="h1"
|
||||
className="section-title--inline"
|
||||
aside={
|
||||
<p className="contact-hero__lead">
|
||||
پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس میگیرد.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section--tight">
|
||||
<section className="section">
|
||||
<div className="container contact-grid">
|
||||
<div className="contact-facts">
|
||||
<p className="lead">
|
||||
پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس میگیرد.
|
||||
</p>
|
||||
|
||||
<div className="fact fact--phones">
|
||||
<span>تلفن</span>
|
||||
<Fact icon={<IconPhone />} label="تلفن" className="fact--phones">
|
||||
{phones.map((phone) => (
|
||||
<a key={phone.href} href={phone.href}>
|
||||
<a key={phone.href} href={phone.href} className="latin" dir="ltr">
|
||||
{phone.display}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Fact>
|
||||
|
||||
<div className="fact">
|
||||
<span>ایمیل</span>
|
||||
<Fact icon={<IconMail />} label="ایمیل">
|
||||
{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}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Fact>
|
||||
|
||||
<div className="fact">
|
||||
<span>اینستاگرام</span>
|
||||
<a href={instagram.href} target="_blank" rel="noreferrer" dir="ltr" style={{ textAlign: "right" }}>
|
||||
<Fact icon={<IconInstagram />} label="اینستاگرام">
|
||||
<a href={instagram.href} target="_blank" rel="noreferrer" className="latin" dir="ltr">
|
||||
{instagram.handle}
|
||||
</a>
|
||||
</div>
|
||||
</Fact>
|
||||
|
||||
<div className="fact">
|
||||
<span>آدرس</span>
|
||||
<Fact icon={<IconPin />} label="آدرس">
|
||||
<strong>{address}</strong>
|
||||
</Fact>
|
||||
</div>
|
||||
|
||||
<SlotImage image={image} alt={`نمونه پروژه ${name}`} ratio={slotRatio("contact-image", slots)} className="contact-frame" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="title" style={{ marginBottom: 32 }}>
|
||||
فرم درخواست پروژه
|
||||
</h2>
|
||||
<div className="contact-form-panel">
|
||||
<SectionTitle
|
||||
en="inquiry"
|
||||
title="فرم درخواست"
|
||||
titleMuted="پروژه"
|
||||
className="section-title--compact"
|
||||
/>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</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 { Montserrat } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
|
||||
import { CmsMeta, JsonLd } from "@/components/CmsMeta";
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { Header } from "@/components/Header";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { site } from "@/data/site";
|
||||
import { siteUrl } from "@/lib/config";
|
||||
import {
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
resolveName,
|
||||
resolvePhones,
|
||||
} from "@/lib/content";
|
||||
import { getBusinessInfo, getFavicon } from "@/lib/meshkee";
|
||||
import { getBusinessId, getBusinessInfo, getFavicon } from "@/lib/meshkee";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
@@ -29,10 +31,24 @@ const yekan = localFont({
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const montserrat = Montserrat({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
variable: "--font-montserrat",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [favicon, info] = await Promise.all([getFavicon(), getBusinessInfo()]);
|
||||
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 {
|
||||
metadataBase: new URL(siteUrl),
|
||||
@@ -42,6 +58,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
},
|
||||
description: site.description,
|
||||
icons,
|
||||
...(Object.keys(otherMeta).length ? { other: otherMeta } : {}),
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fa_IR",
|
||||
@@ -54,7 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
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 logo = resolveLogo(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;
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className={yekan.variable}>
|
||||
<html lang="fa" dir="rtl" className={`${yekan.variable} ${montserrat.variable}`}>
|
||||
<head>
|
||||
<CmsMeta tags={info.metaTags ?? []} />
|
||||
</head>
|
||||
<body>
|
||||
<Providers businessId={businessId}>
|
||||
<JsonLd data={jsonLd} />
|
||||
<Header logo={logo} name={name} phone={phones[0] ?? null} instagram={instagram} />
|
||||
<main>{children}</main>
|
||||
@@ -79,6 +97,7 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
||||
instagram={instagram}
|
||||
badges={info.trustBadges ?? []}
|
||||
/>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SectionTitle } from "@/components/SectionTitle";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "صفحه پیدا نشد",
|
||||
description: "صفحهای که دنبال آن بودید وجود ندارد.",
|
||||
@@ -10,10 +12,7 @@ export const metadata: Metadata = {
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section className="container notfound">
|
||||
<p className="eyebrow" style={{ justifyContent: "center" }}>
|
||||
۴۰۴
|
||||
</p>
|
||||
<h1>صفحه مورد نظر پیدا نشد</h1>
|
||||
<SectionTitle en="404" title="صفحه مورد نظر پیدا نشد" as="h1" className="section-title--inline" />
|
||||
<p className="lead" style={{ marginInline: "auto" }}>
|
||||
احتمالاً آدرس اشتباه است یا این صفحه جابهجا شده.
|
||||
</p>
|
||||
|
||||
+205
-121
@@ -1,9 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
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 { 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 { 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 name = resolveName(info);
|
||||
const about = resolveAbout(info);
|
||||
const logo = resolveLogo(info);
|
||||
const instagram = resolveInstagram(info);
|
||||
|
||||
const hero = slotImages("hero", slots, [{ url: gallery.hero, titleFa: "نمای آجری در غروب" }])[0];
|
||||
const studio = slotImages("studio-image", slots, [{ url: gallery.studio, titleFa: "ورودی سبز" }])[0];
|
||||
const projectSlots = slots.projects?.images ?? [];
|
||||
const heroSlides = slotImages("slider", slots, gallery.heroSlides);
|
||||
const categoryImages = slotImages(
|
||||
"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 (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="hero">
|
||||
<div className="container">
|
||||
<div className="hero__grid">
|
||||
<h1 className="hero__title">
|
||||
معماری ساده،
|
||||
<br />
|
||||
<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>
|
||||
<HeroSlider
|
||||
slides={heroSlides}
|
||||
title="معماری ساده،"
|
||||
titleLine2="زندگی روشن"
|
||||
lead={`${name} یک گروه معماری و طراحی داخلی است؛ از طراحی نما و پلان تا جزئیات داخلی و اجرای نهایی.`}
|
||||
ctaLabel="شروع یک پروژه"
|
||||
ctaHref="/contact"
|
||||
/>
|
||||
|
||||
{/* Intro */}
|
||||
{/* Intro — RTL: logo (rightmost) · copy · focus (left) */}
|
||||
<section className="section">
|
||||
<div className="container split">
|
||||
<div>
|
||||
<p className="eyebrow">استودیو</p>
|
||||
<h2 className="intro__text">{about[0]}</h2>
|
||||
<div className="container intro">
|
||||
<div className="intro__brand">
|
||||
<img src={logo} alt={name} className="intro__logo" width={480} height={186} />
|
||||
</div>
|
||||
<div className="intro__copy">
|
||||
<SectionTitle en="studio" title={about[0]} className="section-title--compact section-title--story" />
|
||||
</div>
|
||||
<div className="intro__meta">
|
||||
<p>{about[1] ?? site.about[1]}</p>
|
||||
@@ -71,110 +97,130 @@ export default async function HomePage() {
|
||||
<span>معماری مسکونی، طراحی داخلی، طراحی نما</span>
|
||||
</div>
|
||||
<Link href="/about" className="text-link">
|
||||
بیشتر درباره هوم
|
||||
بیشتر بدانید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services */}
|
||||
<section className="section section--surface">
|
||||
{/* four-category — 4 service panels */}
|
||||
<section className="section section--tight">
|
||||
<div className="container">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<p className="eyebrow">خدمات</p>
|
||||
<h2 className="title">چه کاری انجام میدهیم</h2>
|
||||
<SectionTitle en="services" title="ما در هوم." titleMuted="چه کارهایی انجام میدهیم" />
|
||||
</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 className="services">
|
||||
{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>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Projects */}
|
||||
{/* project-images — masonry */}
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<p className="eyebrow">پروژهها</p>
|
||||
<h2 className="title">پروژههای منتخب</h2>
|
||||
</div>
|
||||
<p className="lead" style={{ maxWidth: "34ch" }}>
|
||||
گزیدهای از کارهای اخیر در معماری، طراحی داخلی و نما.
|
||||
</p>
|
||||
</div>
|
||||
<SectionTitle
|
||||
en="projects"
|
||||
title="پروژههای"
|
||||
titleMuted="منتخب"
|
||||
aside={<p className="lead">گزیدهای از کارهای اخیر در معماری، طراحی داخلی و نما.</p>}
|
||||
/>
|
||||
|
||||
<div className="projects">
|
||||
{projectSlots.length
|
||||
? projectSlots.map((image, index) => (
|
||||
<div key={`${image.url}-${index}`} className="project">
|
||||
<SlotImage image={image} alt={image.titleFa || `پروژه ${index + 1}`} ratio={slotRatio("projects", slots)} />
|
||||
{projectSlots.map((image, index) => {
|
||||
const title = image.titleFa || image.titleEn || `پروژه ${index + 1}`;
|
||||
const media = { ...image, linkUrl: null };
|
||||
const card = (
|
||||
<>
|
||||
<SlotImage image={media} alt={title} />
|
||||
<div className="project__meta">
|
||||
<h3>{image.titleFa || image.titleEn || `پروژه ${index + 1}`}</h3>
|
||||
<h3>{title}</h3>
|
||||
{image.subtext ? <span>{image.subtext}</span> : null}
|
||||
</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>
|
||||
))
|
||||
: 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>
|
||||
</section>
|
||||
|
||||
{/* Studio */}
|
||||
<section className="section section--line-top">
|
||||
<div className="container split split--equal">
|
||||
<SlotImage image={studio} alt="ورودی یکی از پروژههای استودیو هوم" ratio={slotRatio("studio-image", slots)} className="studio__frame" />
|
||||
<div className="studio__body">
|
||||
<div>
|
||||
<p className="eyebrow">نگاه ما</p>
|
||||
<h2 className="title">فضایی که با شما زندگی میکند</h2>
|
||||
</div>
|
||||
<p>
|
||||
ما طراحی را از نیاز واقعی آغاز میکنیم؛ نه از فرم. نور، مسیر حرکت، متریال و تناسب، ابزار ما برای ساختن فضایی هستند که
|
||||
سالها بعد هم آرام و خوانا بماند.
|
||||
</p>
|
||||
<p>{about[2] ?? site.about[2]}</p>
|
||||
<Link href="/about" className="btn btn--ghost">
|
||||
درباره استودیو
|
||||
<ArrowIcon />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<DetailsFrame />
|
||||
|
||||
{/* sticky-frame-bg */}
|
||||
<OpeningFrame
|
||||
image={openingImage}
|
||||
imageAlt={sticky.titleFa || sticky.titleEn || "نگاه استودیو هوم"}
|
||||
eyebrow={sticky.titleEn || "نگاه ما"}
|
||||
headline="معماری ساده،"
|
||||
headlineMuted="زندگی روشن"
|
||||
headlineEn="SIMPLE ARCHITECTURE, BRIGHT LIVING"
|
||||
body={sticky.subtext || about[2] || site.about[2]}
|
||||
ctaLabel="درباره استودیو"
|
||||
ctaHref="/about"
|
||||
/>
|
||||
|
||||
{/* Process */}
|
||||
<section className="section section--dark">
|
||||
<div className="container">
|
||||
<p className="eyebrow eyebrow--light">فرآیند کار</p>
|
||||
<h2 className="title">از ایده تا تحویل، در چهار گام</h2>
|
||||
<SectionTitle en="process" title="از ایده تا تحویل،" titleMuted="در چهار گام" light />
|
||||
<div className="process">
|
||||
{processSteps.map((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>
|
||||
<p>{item.text}</p>
|
||||
</div>
|
||||
@@ -184,30 +230,68 @@ export default async function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="section--tight">
|
||||
<div className="container">
|
||||
<section className="stats-band" aria-label="آمار استودیو">
|
||||
<div className="stats">
|
||||
{stats.map((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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="section">
|
||||
<div className="container cta">
|
||||
<div>
|
||||
<p className="eyebrow">شروع همکاری</p>
|
||||
<h2>پروژهای در ذهن دارید؟ دربارهاش صحبت کنیم.</h2>
|
||||
{/* brands */}
|
||||
{brandLogos.length ? (
|
||||
<section className="section section--tight brands" aria-label="همکاران">
|
||||
<div className="container">
|
||||
<SectionTitle en="partners" title="برندها و" titleMuted="همکاران" />
|
||||
<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>
|
||||
<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 />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</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";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||
|
||||
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() {
|
||||
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>) {
|
||||
event.preventDefault();
|
||||
@@ -20,7 +95,9 @@ export function ContactForm() {
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.title || !payload.text) {
|
||||
setStatus({ tone: "error", message: "لطفاً نام، موضوع و متن پیام را کامل کنید." });
|
||||
const message = "لطفاً نام، موضوع و متن پیام را کامل کنید.";
|
||||
setStatus({ tone: "error", message });
|
||||
setToast({ tone: "error", message });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,28 +111,38 @@ export function ContactForm() {
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as { message?: string | string[] };
|
||||
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(message && /[\u0600-\u06FF]/.test(message) ? message : "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید.");
|
||||
throw new Error(
|
||||
message && /[\u0600-\u06FF]/.test(message)
|
||||
? message
|
||||
: "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید.",
|
||||
);
|
||||
}
|
||||
form.reset();
|
||||
setStatus({ tone: "success", message: "پیام شما ثبت شد. بهزودی با شما تماس میگیریم." });
|
||||
const success = "پیام شما ثبت شد. بهزودی با شما تماس میگیریم.";
|
||||
setStatus({ tone: "success", message: success });
|
||||
setToast({ tone: "success", message: success });
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
tone: "error",
|
||||
message: error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید.",
|
||||
});
|
||||
const message = error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید.";
|
||||
setStatus({ tone: "error", message });
|
||||
setToast({ tone: "error", message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<div className="form__row">
|
||||
<div className="field">
|
||||
<label htmlFor="contact-name">نام و نام خانوادگی *</label>
|
||||
<input id="contact-name" name="name" type="text" autoComplete="name" required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="contact-cell">شماره تماس</label>
|
||||
<Field id="contact-name" label="نام و نام خانوادگی *" icon={<IconUser />}>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
placeholder="نام و نام خانوادگی *"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="contact-cell" label="شماره تماس" icon={<IconPhone />}>
|
||||
<input
|
||||
id="contact-cell"
|
||||
name="cellNumber"
|
||||
@@ -63,29 +150,39 @@ export function ContactForm() {
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
dir="ltr"
|
||||
placeholder="0912 000 0000"
|
||||
placeholder="۰۹۱۲ ۰۰۰ ۰۰۰۰"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form__row">
|
||||
<div className="field">
|
||||
<label htmlFor="contact-email">ایمیل</label>
|
||||
<input id="contact-email" name="email" type="email" autoComplete="email" dir="ltr" />
|
||||
<Field id="contact-email" label="ایمیل" icon={<IconMail />}>
|
||||
<input
|
||||
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 className="field">
|
||||
<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>
|
||||
<Field id="contact-text" label="توضیحات پروژه *" icon={<IconMessage />} wide>
|
||||
<textarea
|
||||
id="contact-text"
|
||||
name="text"
|
||||
required
|
||||
placeholder="متراژ، محل پروژه، نوع خدمات مورد نیاز و زمانبندی مدنظر را بنویسید."
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="form__footer">
|
||||
<button type="submit" className="btn" disabled={status.tone === "pending"}>
|
||||
ارسال پیام
|
||||
@@ -98,5 +195,14 @@ export function ContactForm() {
|
||||
</p>
|
||||
</div>
|
||||
</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 { NAV_LINKS, site } from "@/data/site";
|
||||
import { NAV_LINKS, services, site } from "@/data/site";
|
||||
import { stripScripts, toFaDigits, type PhoneLink } from "@/lib/content";
|
||||
|
||||
type Props = {
|
||||
@@ -38,12 +38,13 @@ export function Footer({ logo, name, address, phones, emails, instagram, badges
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>خدمات</h3>
|
||||
<h3>تخصصها</h3>
|
||||
<ul>
|
||||
<li>طراحی معماری</li>
|
||||
<li>طراحی داخلی</li>
|
||||
<li>طراحی نما</li>
|
||||
<li>نظارت و اجرا</li>
|
||||
{services.map((service) => (
|
||||
<li key={service.slug}>
|
||||
<Link href={`/services/${service.slug}`}>{service.titleFa}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -53,17 +54,21 @@ export function Footer({ logo, name, address, phones, emails, instagram, badges
|
||||
<li>{address}</li>
|
||||
{phones.map((phone) => (
|
||||
<li key={phone.href}>
|
||||
<a href={phone.href}>{phone.display}</a>
|
||||
<a href={phone.href} className="latin" dir="ltr">
|
||||
{phone.display}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{emails.map((email) => (
|
||||
<li key={email}>
|
||||
<a href={`mailto:${email}`}>{email}</a>
|
||||
<a href={`mailto:${email}`} className="latin" dir="ltr">
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<a href={instagram.href} target="_blank" rel="noreferrer">
|
||||
اینستاگرام {instagram.handle}
|
||||
اینستاگرام <span className="latin">{instagram.handle}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
+133
-13
@@ -2,9 +2,10 @@
|
||||
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
@@ -14,18 +15,80 @@ type Props = {
|
||||
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) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
setMobileOpen(null);
|
||||
}, [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 (
|
||||
<header className="header">
|
||||
<header className="header" data-nav-open={open ? "true" : "false"}>
|
||||
<div className="container header__inner">
|
||||
<Link href="/" className="header__logo" aria-label={name}>
|
||||
<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_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}>
|
||||
{link.label}
|
||||
</Link>
|
||||
<NavItem key={link.href} link={link} pathname={pathname} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="header__tools">
|
||||
{phone ? (
|
||||
<a className="header__phone" href={phone.href}>
|
||||
<a className="header__phone latin" href={phone.href} dir="ltr">
|
||||
{phone.display}
|
||||
</a>
|
||||
) : null}
|
||||
<AccountMenu />
|
||||
<button
|
||||
type="button"
|
||||
className="header__burger"
|
||||
@@ -58,17 +120,75 @@ export function Header({ logo, name, phone, instagram }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mobile-nav" className="mobile-nav" data-open={open}>
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}>
|
||||
<div
|
||||
id="mobile-nav"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
{phone ? <a href={phone.href}>{phone.display}</a> : null}
|
||||
<a href={instagram.href} target="_blank" rel="noreferrer">
|
||||
{phone ? (
|
||||
<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}
|
||||
</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>
|
||||
</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";
|
||||
};
|
||||
|
||||
export const NAV_LINKS = [
|
||||
{ href: "/", label: "خانه" },
|
||||
{ href: "/about", label: "درباره ما" },
|
||||
{ href: "/contact", label: "تماس با ما" },
|
||||
];
|
||||
export type NavChild = { href: string; label: string };
|
||||
export type NavLink = { href: string; label: string; children?: NavChild[] };
|
||||
|
||||
export const site = {
|
||||
nameFa: "استودیو معماری هوم",
|
||||
@@ -25,8 +22,10 @@ export const site = {
|
||||
"استودیو معماری هوم؛ گروه معماری و طراحی داخلی. طراحی نما، معماری مسکونی، طراحی داخلی و اجرای پروژههای ساختمانی با نگاهی مینیمال، دقیق و کاربردی.",
|
||||
logo: "/images/logo.png",
|
||||
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",
|
||||
phones: [{ display: "۰۹۱۲ ۰۰۰ ۰۰۰۰", href: "tel:+989120000000" }],
|
||||
phones: [{ display: "0912 000 0000", href: "tel:+989120000000" }],
|
||||
instagram: { href: "https://instagram.com/hom.archstudio", handle: "@hom.archstudio" },
|
||||
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",
|
||||
titleFa: "طراحی معماری",
|
||||
titleEn: "Architecture",
|
||||
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",
|
||||
titleFa: "طراحی داخلی",
|
||||
titleEn: "Interior Design",
|
||||
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",
|
||||
titleFa: "طراحی نما",
|
||||
titleEn: "Facade Design",
|
||||
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",
|
||||
titleFa: "نظارت و اجرا",
|
||||
titleEn: "Build & Supervision",
|
||||
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[] = [
|
||||
{
|
||||
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 = {
|
||||
hero: "/images/facade-sunset.jpg",
|
||||
studio: "/images/garden-gate.jpg",
|
||||
aboutHero: "/images/courtyard-brick.jpg",
|
||||
aboutGallery: [
|
||||
"/images/kitchen-oak-2.jpg",
|
||||
"/images/facade-sunset-detail.jpg",
|
||||
"/images/study-terracotta-2.jpg",
|
||||
/** Fallback slides for homepage `slider` slot */
|
||||
heroSlides: [
|
||||
{ url: "/images/facade-sunset.jpg", titleFa: "ویلای آجری", titleEn: "Brick Residence" },
|
||||
{ url: "/images/courtyard-brick.jpg", titleFa: "خانه حیاطدار", titleEn: "Courtyard House" },
|
||||
{ url: "/images/garden-gate.jpg", titleFa: "ورودی سبز", titleEn: "Garden Gate" },
|
||||
{ url: "/images/facade-louver.jpg", titleFa: "نمای لوور", titleEn: "Louver Facade" },
|
||||
],
|
||||
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 =
|
||||
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 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[] {
|
||||
const fromApi = (info.phoneNumbers ?? []).map(textOf).filter(Boolean);
|
||||
if (!fromApi.length) return [...site.phones];
|
||||
return fromApi.map((display) => ({
|
||||
return fromApi.map((raw) => {
|
||||
const display = toLatinDigits(raw);
|
||||
return {
|
||||
display,
|
||||
href: `tel:${toLatinDigits(display).replace(/[^\d+]/g, "")}`,
|
||||
}));
|
||||
href: `tel:${display.replace(/[^\d+]/g, "")}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
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 = {
|
||||
faviconUrl: 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[] = [
|
||||
{
|
||||
key: "hero",
|
||||
label: "تصویر اصلی صفحه اول",
|
||||
kind: "single",
|
||||
key: "slider",
|
||||
label: "اسلایدر تمامصفحه صفحه اول (قابل تکرار)",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "16:9",
|
||||
itemCount: 1,
|
||||
itemCount: null,
|
||||
recommendedWidth: 1920,
|
||||
},
|
||||
{
|
||||
key: "projects",
|
||||
label: "پروژههای منتخب صفحه اول",
|
||||
key: "four-category",
|
||||
label: "چهار خدمت / تخصص صفحه اول",
|
||||
kind: "list",
|
||||
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,
|
||||
recommendedWidth: 1200,
|
||||
},
|
||||
{
|
||||
key: "studio-image",
|
||||
label: "تصویر معرفی استودیو (صفحه اول)",
|
||||
key: "sticky-frame-bg",
|
||||
label: "پسزمینه فریم استیکی OpeningFrame",
|
||||
kind: "single",
|
||||
pageKey: "home",
|
||||
aspectRatio: "4:3",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 1400,
|
||||
},
|
||||
{
|
||||
key: "about-hero",
|
||||
label: "تصویر اصلی درباره ما",
|
||||
kind: "single",
|
||||
pageKey: "about",
|
||||
aspectRatio: "16:9",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 1920,
|
||||
},
|
||||
{
|
||||
key: "about-gallery",
|
||||
label: "گالری درباره ما",
|
||||
kind: "list",
|
||||
pageKey: "about",
|
||||
aspectRatio: "1:1",
|
||||
itemCount: 3,
|
||||
recommendedWidth: 900,
|
||||
key: "instagram-row",
|
||||
label: "فریم صفحه اینستاگرام (اسکرینشات موبایل)",
|
||||
kind: "single",
|
||||
pageKey: "home",
|
||||
aspectRatio: "9:16",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 720,
|
||||
},
|
||||
{
|
||||
key: "contact-image",
|
||||
label: "تصویر تماس با ما",
|
||||
kind: "single",
|
||||
pageKey: "contact",
|
||||
aspectRatio: "4:5",
|
||||
itemCount: 1,
|
||||
recommendedWidth: 1200,
|
||||
key: "brands",
|
||||
label: "لوگوی برندها / همکاران (قابل تکرار)",
|
||||
kind: "list",
|
||||
pageKey: "home",
|
||||
aspectRatio: "3:2",
|
||||
itemCount: null,
|
||||
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