Expand HOM studio site with portfolio, blog, auth, and details video frame.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-12 22:52:38 +03:30
co-authored by Cursor
parent 73971bf6d9
commit 55dd94d2e4
45 changed files with 6056 additions and 651 deletions
+3 -1
View File
@@ -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
View File
@@ -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.
+7 -1
View File
@@ -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
View File
@@ -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>
</>
+17
View File
@@ -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);
}
+10
View File
@@ -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);
}
+107
View File
@@ -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}
</>
);
}
+80
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+23 -4
View File
@@ -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>
);
+3 -4
View File
@@ -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
View File
@@ -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>
</>
+126
View File
@@ -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}
</>
);
}
+88
View File
@@ -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>
</>
);
}
+9
View File
@@ -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)}`);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/** Legacy route — Meshkee portfolios live at `/portfolio`. */
export default function ProjectsRedirect() {
redirect("/portfolio");
}
+125
View File
@@ -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>
</>
);
}
+47
View File
@@ -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>
</>
);
}
+152
View File
@@ -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
View File
@@ -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}
</>
);
}
+149
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+14 -9
View File
@@ -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
View File
@@ -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>
+129
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+141
View File
@@ -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>
);
}
+164
View File
@@ -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>
);
}
+15
View File
@@ -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>;
}
+58
View File
@@ -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>
);
}
+89
View File
@@ -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
View File
@@ -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
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</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
View File
@@ -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
View File
@@ -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) {
+42
View File
@@ -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);
}
+26
View File
@@ -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;
+121
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}</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);
}
+188
View File
@@ -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 || "کاربر";
}
+123
View File
@@ -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;
}
+17
View File
@@ -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
View File
@@ -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,
},
];