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 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 # HOM arch studio — AI / Project Context
Persian (fa, RTL) brochure website for **استودیو معماری هوم** (architecture + interior design group). Persian (fa, RTL) brochure website for **استودیو معماری هوم** (architecture + interior design).
Keep this file current whenever routes, slots, data sources or design rules change. Keep this file current whenever routes, slots, data sources or design rules change.
## Source of truth ## Source of truth
- Live domain: `https://homgroup.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.com`) - Live domain: `https://homgroup.ir` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.ir`)
- Backend docs: https://api.meshkee.com/docs/website (OpenAPI: `/docs/website/openapi.json`) - Backend docs: https://api.meshkee.com/docs/website
- API base: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_MESHKEE_API_BASE`) - API base: `https://api.meshkee.com/api/v1`
- Tenant: `GET /tenants/homgroup.com` — **not registered yet** (404 as of 2026-10-11). Every Meshkee read has a local fallback, so the site renders fully without the tenant. - Tenant: `GET /tenants/homgroup.ir` → business id `53`
- Prompt reference: `https://api.meshkee.com/api/v1/public/ai-prompts/studio-hom/basic-create/36caa331616c4b1cd382f8cb` - Customer portal: `https://customer.homgroup.ir` (no storefront login pages)
- Original brief: homepage + about + contact only; `lang=fa`, `dir=rtl`, flat design, palette from the logo. - Business dashboard: `https://business.homgroup.ir`
- Placeholders prompt: `…/ai-prompts/studio-hom/placeholders/576804987514877ec206af60`
## Stack ## Stack
- Next.js 15 App Router + React 19 + TypeScript, no Tailwind — all styles in `src/app/globals.css` - Next.js 15 App Router + React 19 + TypeScript; styles in `src/app/globals.css` (no Tailwind)
- `output: "standalone"` in `next.config.ts` (**required** for the Meshkee websites VM; deploy drops `node_modules`) - `output: "standalone"` required for Meshkee websites VM
- `npm run build` runs `prebuild` → `scripts/generate-sitemap-config.mjs` (scans `src/app` for static pages) - Fonts: IranYekan (FA) + Montserrat (EN / `.latin`)
- `npm run lint` = `tsc --noEmit`
- Font: IranYekan (local, `public/fonts/`, weights 300/400/500/700) via `next/font/local` → `--font-yekan`
## Design language (flat) ## Design
- Palette: bg `#ffffff`, surface `#f4f3f1`, text `#111111`, muted `#6b6b6b`, hairline `#e2e0dc`, accent red `#e3002b` (the logo dot) - Flat palette: bg `#fff`, surface `#f4f3f1`, fg `#111`, muted `#6b6b6b`, line `#e2e0dc`, accent `#e3002b`
- Flat only: no shadows, no border-radius, 1px hairlines, solid fills. Red appears as a small square “dot” (eyebrows, hero title) and on hover. - Sticky header + account menu; dark map band on contact; stats band with pattern
- Latin labels use `.latin` (uppercase, letter-spaced, LTR). One dark section (`.section--dark`) for the process.
- Header is sticky, white, hairline bottom; mobile burger menu under 900px. ## Static image slots (`STATIC_IMAGE_SLOTS` → `/meshkee/static-image-slots`)
- Logo: `public/images/logo.png` (cropped from `assets/logo/logo.jpeg`); favicon `src/app/icon.png` Fetch: `GET /tenants/{domain}/website/static-images?pageKey=…`. Match by **key**. Use all images if `kind=list`, first if `single`. `url` → `<img>`, `linkUrl` → wrap link, `titleFa` / `titleEn` / `subtext` per UI. Empty/fail → local placeholder. Prefer API `aspectRatio`. **If a frame’s size or ratio changes in development, update the matching catalog entry in the same change.**
| key | pageKey | kind | itemCount | aspectRatio | width | UI |
| --- | --- | --- | --- | --- | --- | --- |
| `slider` | home | list | null | `16:9` | 1920 | Full-viewport `HeroSlider` |
| `four-category` | home | list | 4 | `3:4` | 900 | Four hover-expand service panels (`titleFa` / `titleEn` / `subtext`) |
| `project-images` | home | list | null | `3:4` (masonry free height) | 1200 | Selected projects masonry |
| `sticky-frame-bg` | home | single | 1 | `16:9` | 1920 | `OpeningFrame` background |
| `instagram-row` | home | single | 1 | `9:16` | 720 | `InstagramFollow` phone frame (also used on contact) |
| `brands` | home | list | null | `3:2` | 480 | Partner logos row (hidden when empty) |
| `about-us-image` | about | list | 2 | `16:9` | 1600 | About hero (1st) + secondary (2nd) |
## Tags & badges (CMS)
From `GET /tenants/{domain}/website/business-info`:
1. `metaTags: [{ name, content }]` → root `<CmsMeta>` + `generateMetadata.other`
2. `trustBadges: [{ kind, embedHtml }]` → footer `dangerouslySetInnerHTML` (scripts stripped). Empty → omit. Do not hardcode eNamad/Samandehi.
## Auth
Shared cookies `meshkee_customer_*`; login → customer portal; `AccountMenu` shows `{name} عزیز` + پروفایل من / خروج / مدیریت وبسایت (admin + handoff).
## Routes ## Routes
| Route | Purpose | | Route | Notes |
| --- | --- | | --- | --- |
| `/` | Hero (title + CMS hero image), intro, 4 services, 6 selected projects, studio block, 4-step process (dark), stats, CTA | | `/` | Slider, intro, four-category, project-images, sticky frame, process, stats, brands, Instagram, CTA |
| `/about` | Page hero image, story (3 paragraphs from `business-info.about` or fallback), 3-image gallery, values, services list, stats, CTA | | `/portfolio`, `/portfolio/[slug]` | Meshkee portfolios (+ local fallback); lightbox + zoom + similar |
| `/contact` | Facts (phones, email, Instagram, address), contact image, form → `POST /api/contact` | | `/projects*` | Redirect → `/portfolio` |
| `/api/contact` | Proxies to `POST /tenants/{domain}/contact-submissions` (`title`, `name`, `text` required; `email`, `cellNumber` optional) | | `/blog`, `/blog/[slug]` | Meshkee blogs (+ local stubs) |
| `/meshkee/static-image-slots` | Slot catalog (`STATIC_IMAGE_SLOTS`) for the dashboard “Refresh slots” | | `/services*` | Local specialties |
| `/meshkee/sitemap-config.json` | Static pages for the Meshkee-generated sitemap | | `/about` | `about-us-image` (2), story, values, stats, CTA |
| `/contact` | Facts + form (API + toast) + Instagram + dark map |
| `/api/contact`, `/api/auth/me`, `/api/auth/handoff` | Proxies |
| `/meshkee/*` | Slot catalog + sitemap-config |
No `/login`, `/register`, `/cart`, `/checkout`, no local `sitemap.xml` / `robots.txt` (nginx proxies those to the API). ## Components
`Header`/`AccountMenu`, `Footer`, `Providers`, `CmsMeta`, `SlotImage`, `SectionTitle`, `OpeningFrame`, `HeroSlider`, `InstagramFollow`, `MediaGallery`, `ContactForm`
## Meshkee integration (`src/lib/`)
- `config.ts` — domain / API base / portal URLs
- `meshkee.ts` — `meshkeeGet` (ISR 300s, returns fallback on any error), `getBusinessInfo`, `getFavicon`
- `static-images.ts` — slot catalog + `getPageSlots(pageKey)` (parses `{ items: [{ key, aspectRatio, images[] }] }`), `slotImages`, `slotRatio`
- `content.ts` — resolves name / logo / phones / emails / address / Instagram / about from `business-info` with fallbacks from `src/data/site.ts`; `parseHeadTag`, `stripScripts`, digit helpers
- Root layout: `metaTags` → `<CmsMeta>` in `<head>`; `schema.jsonLd` → `<JsonLd>` when `schema.enabled`; `trustBadges[].embedHtml` → footer (scripts stripped)
- Favicon: `GET /website/favicon` → `metadata.icons`; falls back to `src/app/icon.png`
### Static image slots
Fetch per page: `GET /tenants/{domain}/website/static-images?pageKey=…`. Empty/fail → local images from `gallery` in `src/data/site.ts`.
`url` → `<img>`, `linkUrl` → wrap in `<a>`, `titleFa`/`titleEn`/`subtext` → captions where the UI shows them.
Use the API `aspectRatio` first, catalog second. **If a frame’s size or ratio changes in development, update `STATIC_IMAGE_SLOTS` in the same change.**
| key | page | kind | itemCount | aspectRatio | width | UI |
| --- | --- | --- | --- | --- | --- | --- |
| `hero` | home | single | 1 | `16:9` | 1920 | Hero frame; `titleFa` shown in the caption row |
| `projects` | home | list | null | `4:5` | 1200 | Selected projects grid; `titleFa` = title, `titleEn` = Latin subtitle, `subtext` = meta (e.g. category · year). When empty → 6 local `projects` |
| `studio-image` | home | single | 1 | `4:3` | 1400 | “نگاه ما” block image |
| `about-hero` | about | single | 1 | `16:9` | 1920 | About page hero |
| `about-gallery` | about | list | 3 | `1:1` | 900 | 3-up square gallery |
| `contact-image` | contact | single | 1 | `4:5` | 1200 | Image under the contact facts |
## Local content / fallbacks (`src/data/site.ts`)
- `site` — names (`استودیو معماری هوم` / `HOM arch studio`), tagline, description, placeholder phone `۰۹۱۲ ۰۰۰ ۰۰۰۰`, email `info@homgroup.com`, Instagram `@hom.archstudio`, address `تهران`, 3 about paragraphs. **Replace placeholders once the business profile exists in Meshkee** (they are overridden automatically by `business-info` when present).
- `services` (4), `processSteps` (4), `stats` (4), `projects` (6), `gallery` (image paths), `NAV_LINKS`
- Images: `public/images/*.jpg` — resized (≤1800px, q82) from `assets/images/` (originals kept out of git). Mapping: `facade-sunset*` ← IMG_1455/1456/1458, `courtyard-brick*` ← IMG_0356/0357, `kitchen-cream*` ← IMG_0374/0375, `study-terracotta*` ← IMG_0376/0377, `kitchen-oak*` ← IMG_0394/0396, `facade-louver` ← IMG_0400, `garden-gate` ← IMG_1037
## SEO
- Unique `<title>` / description per page (`metadata` in each `page.tsx`; template `%s | {name}`)
- One `<h1>` per page; content images have `alt`
- Static pages published through `/meshkee/sitemap-config.json`; do not add `app/sitemap.ts` / `app/robots.ts`
## Components (`src/components/`)
`Header` (client; sticky, burger), `Footer`, `CmsMeta` (+ `JsonLd`), `SlotImage` (flat frame for slot/fallback images), `ContactForm` (client; posts to `/api/contact`, Persian status messages)
## Deploy ## Deploy
Standard Meshkee websites VM flow: push → Super Admin **Deploy**. Deploy log must say `standalone build detected`; PM2 script `.next/standalone/server.js`. Standalone build on Meshkee websites VM; PM2 → `.next/standalone/server.js`.
## Changelog ## Changelog
- 2026-10-11 — Initial build: home / about / contact, Meshkee business-info + static-image slots + contact form, standalone runtime, sitemap-config generator. - 2026-10-12 — Placeholders prompt: remapped slots (`slider`, `four-category`, `project-images`, `sticky-frame-bg`, `instagram-row`, `brands`, `about-us-image`); brands row; metaTags → `metadata.other`.
- 2026-10-12 — Backend prompt: auth, portfolio/blog, contact toast, Instagram frame, domain `homgroup.ir`.
- 2026-10-11 — Initial site scaffold.
Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.
+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 domain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.com";
const EXCLUDE = /^(api|meshkee|login|register|cart|checkout|account|admin)(\/|$)/; const EXCLUDE = /^(api|meshkee|login|register|cart|checkout|account|admin)(\/|$)/;
const PRIORITY = { "/": 1.0, "/about": 0.6, "/contact": 0.5 }; const PRIORITY = {
"/": 1.0,
"/projects": 0.8,
"/services": 0.8,
"/about": 0.6,
"/contact": 0.5,
};
function walk(dir, out = []) { function walk(dir, out = []) {
for (const entry of readdirSync(dir)) { for (const entry of readdirSync(dir)) {
+96 -47
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { SectionTitle } from "@/components/SectionTitle";
import { SlotImage } from "@/components/SlotImage"; import { SlotImage } from "@/components/SlotImage";
import { gallery, services, site, stats } from "@/data/site"; import { gallery, services, site, stats } from "@/data/site";
import { resolveAbout, resolveName } from "@/lib/content"; import { resolveAbout, resolveName } from "@/lib/content";
@@ -17,14 +18,32 @@ const values = [
{ {
title: "سادگی", title: "سادگی",
text: "حذف اضافات تا آنچه می‌ماند، ضروری و خوانا باشد. سادگی برای ما نتیجه دقت است، نه کم‌کاری.", text: "حذف اضافات تا آنچه می‌ماند، ضروری و خوانا باشد. سادگی برای ما نتیجه دقت است، نه کم‌کاری.",
icon: (
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<circle cx="20" cy="20" r="11" />
</svg>
),
}, },
{ {
title: "تناسب", title: "تناسب",
text: "هر طرح در نسبت با زمین، نور، بودجه و شیوه زندگی کارفرما شکل می‌گیرد؛ نه بر اساس یک الگوی ثابت.", text: "هر طرح در نسبت با زمین، نور، بودجه و شیوه زندگی کارفرما شکل می‌گیرد؛ نه بر اساس یک الگوی ثابت.",
icon: (
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<rect x="8" y="8" width="24" height="24" />
<path d="M8 18h24M18 8v24" />
</svg>
),
}, },
{ {
title: "اجرای دقیق", title: "اجرای دقیق",
text: "طرح فقط روی کاغذ معنا ندارد. جزئیات اجرایی و نظارت مستمر، تضمین می‌کند آنچه ساخته می‌شود همان طرح باشد.", text: "طرح فقط روی کاغذ معنا ندارد. جزئیات اجرایی و نظارت مستمر، تضمین می‌کند آنچه ساخته می‌شود همان طرح باشد.",
icon: (
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<path d="M12 28 20 10l8 18" />
<path d="M15 22h10" />
<circle cx="20" cy="30" r="2" fill="currentColor" stroke="none" />
</svg>
),
}, },
]; ];
@@ -32,31 +51,43 @@ export default async function AboutPage() {
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("about")]); const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("about")]);
const name = resolveName(info); const name = resolveName(info);
const about = resolveAbout(info); const about = resolveAbout(info);
const hero = slotImages("about-hero", slots, [{ url: gallery.aboutHero, titleFa: "خانه حیاط‌دار" }])[0]; const aboutImages = slotImages("about-us-image", slots, gallery.aboutImages);
const images = slotImages( const hero = aboutImages[0];
"about-gallery", const galleryImages = aboutImages.slice(1);
slots,
gallery.aboutGallery.map((url, index) => ({ url, titleFa: `نمونه کار ${index + 1}` })),
);
return ( return (
<> <>
<section className="page-hero"> <section className="page-hero">
<div className="container"> <div className="container">
<p className="eyebrow">درباره ما</p> <SectionTitle
<h1>{name}؛ گروه معماری و طراحی داخلی</h1> en="about"
title={`${name}؛`}
titleMuted="گروه معماری و طراحی داخلی"
as="h1"
className="section-title--inline"
/>
</div> </div>
</section> </section>
<div className="container"> <div className="container">
<SlotImage image={hero} alt={`نمونه پروژه ${name}`} ratio={slotRatio("about-hero", slots)} className="page-hero__frame" priority /> <SlotImage
image={hero}
alt={hero.titleFa || hero.titleEn || `نمونه پروژه ${name}`}
ratio={slotRatio("about-us-image", slots)}
className="page-hero__frame"
priority
/>
</div> </div>
<section className="section"> <section className="section">
<div className="container split"> <div className="container split">
<div> <div>
<p className="eyebrow">داستان ما</p> <SectionTitle
<h2 className="title">معماری از گوش دادن شروع می‌شود</h2> en="story"
title="معماری از گوش دادن"
titleMuted="شروع می‌شود"
className="section-title--compact"
/>
</div> </div>
<div className="about-text"> <div className="about-text">
{about.map((paragraph, index) => ( {about.map((paragraph, index) => (
@@ -66,23 +97,32 @@ export default async function AboutPage() {
</div> </div>
</section> </section>
<section className="section--tight"> {galleryImages.length ? (
<div className="container about-gallery"> <section className="section--tight">
{images.slice(0, 3).map((image, index) => ( <div className="container about-gallery about-gallery--pair">
<SlotImage key={`${image.url}-${index}`} image={image} alt={`نمونه کار ${name}`} ratio={slotRatio("about-gallery", slots)} /> {galleryImages.slice(0, 1).map((image, index) => (
))} <SlotImage
</div> key={`${image.url}-${index}`}
</section> image={image}
alt={image.titleFa || image.titleEn || `نمونه کار ${name}`}
ratio={slotRatio("about-us-image", slots)}
/>
))}
</div>
</section>
) : null}
<section className="section section--surface"> <section className="section section--surface">
<div className="container"> <div className="container">
<p className="eyebrow">ارزش‌ها</p> <SectionTitle en="values" title="آنچه در هر پروژه" titleMuted="به آن پایبندیم" />
<h2 className="title">آنچه در هر پروژه به آن پایبندیم</h2>
<div className="values"> <div className="values">
{values.map((value) => ( {values.map((value) => (
<div key={value.title} className="value"> <div key={value.title} className="value">
<h3>{value.title}</h3> <span className="value__icon">{value.icon}</span>
<p>{value.text}</p> <div className="value__body">
<h3>{value.title}</h3>
<p>{value.text}</p>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -92,18 +132,22 @@ export default async function AboutPage() {
<section className="section"> <section className="section">
<div className="container split"> <div className="container split">
<div> <div>
<p className="eyebrow">خدمات</p> <SectionTitle
<h2 className="title">از نما تا جزئیات داخلی</h2> en="services"
title="از نما تا"
titleMuted="جزئیات داخلی"
className="section-title--compact"
/>
</div> </div>
<div className="about-text"> <div className="about-text">
<p> <p>
خدمات {site.shortFa} چهار حوزه اصلی را پوشش می‌دهد و در هر پروژه، بسته به نیاز شما، یک یا چند مورد از آن‌ها خدمات {site.shortFa} چهار حوزه اصلی را پوشش می‌دهد و در هر پروژه، بسته به نیاز شما، یک یا چند مورد از آن‌ها
کنار هم قرار می‌گیرند. کنار هم قرار می‌گیرند.
</p> </p>
<ul style={{ margin: 0, paddingInlineStart: 20, display: "grid", gap: 10 }}> <ul>
{services.map((service) => ( {services.map((service) => (
<li key={service.index}> <li key={service.index}>
<strong style={{ color: "var(--fg)", fontWeight: 500 }}>{service.titleFa}</strong> — {service.text} <strong>{service.titleFa}</strong> — {service.text}
</li> </li>
))} ))}
</ul> </ul>
@@ -111,31 +155,36 @@ export default async function AboutPage() {
</div> </div>
</section> </section>
<section className="section--tight"> <section className="stats-band" aria-label="آمار استودیو">
<div className="container"> <div className="stats">
<div className="stats"> {stats.map((stat) => (
{stats.map((stat) => ( <div key={stat.label} className="stat">
<div key={stat.label} className="stat"> <strong className="latin" lang="en" dir="ltr">
<strong>{stat.value}</strong> {stat.value}
<span>{stat.label}</span> </strong>
</div> <span>{stat.label}</span>
))} </div>
</div> ))}
</div> </div>
</section> </section>
<section className="section"> <section className="cta-band">
<div className="cta-band__glow" aria-hidden="true" />
<div className="container cta"> <div className="container cta">
<div> <SectionTitle
<p className="eyebrow">همکاری</p> en="collaborate"
<h2>آماده‌ایم درباره پروژه شما صحبت کنیم.</h2> title="آماده‌ایم درباره پروژه شما"
</div> titleMuted="صحبت کنیم."
<Link href="/contact" className="btn"> className="section-title--inline section-title--light"
تماس با ما aside={
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> <Link href="/contact" className="btn btn--light">
<path d="M4 12h16M13 5l7 7-7 7" /> تماس با ما
</svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
</Link> <path d="M4 12h16M13 5l7 7-7 7" />
</svg>
</Link>
}
/>
</div> </div>
</section> </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 { Metadata } from "next";
import type { ReactNode } from "react";
import { ContactForm } from "@/components/ContactForm"; import { ContactForm } from "@/components/ContactForm";
import { SlotImage } from "@/components/SlotImage"; import { ContactMap } from "@/components/ContactMap";
import { gallery } from "@/data/site"; import { InstagramFollow } from "@/components/InstagramFollow";
import { SectionTitle } from "@/components/SectionTitle";
import { gallery, site } from "@/data/site";
import { import {
resolveAddress, resolveAddress,
resolveEmails, resolveEmails,
@@ -19,72 +22,156 @@ export const metadata: Metadata = {
"برای مشاوره طراحی معماری، طراحی داخلی یا طراحی نما با استودیو معماری هوم تماس بگیرید یا فرم درخواست پروژه را ارسال کنید.", "برای مشاوره طراحی معماری، طراحی داخلی یا طراحی نما با استودیو معماری هوم تماس بگیرید یا فرم درخواست پروژه را ارسال کنید.",
}; };
function IconPhone() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M8.5 4.5h3l1.5 4-2 1.2a12 12 0 0 0 5.3 5.3l1.2-2 4 1.5v3A2 2 0 0 1 19.5 19.5 15 15 0 0 1 4.5 4.5 2 2 0 0 1 6.5 2.5h2Z" />
</svg>
);
}
function IconMail() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="3.5" y="5.5" width="17" height="13" rx="1.5" />
<path d="m4.5 7.5 7.5 6 7.5-6" />
</svg>
);
}
function IconInstagram() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="4" y="4" width="16" height="16" rx="4" />
<circle cx="12" cy="12" r="3.5" />
<circle cx="17.2" cy="6.8" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
}
function IconPin() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M12 21s6.5-5.2 6.5-10.2A6.5 6.5 0 0 0 5.5 10.8C5.5 15.8 12 21 12 21Z" />
<circle cx="12" cy="10.5" r="2.2" />
</svg>
);
}
function Fact({
icon,
label,
children,
className = "",
}: {
icon: ReactNode;
label: string;
children: ReactNode;
className?: string;
}) {
return (
<div className={`fact ${className}`.trim()}>
<span className="fact__icon" aria-hidden="true">
{icon}
</span>
<div className="fact__body">
<span className="sr-only">{label}</span>
{children}
</div>
</div>
);
}
export default async function ContactPage() { export default async function ContactPage() {
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("contact")]); const [info, homeSlots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]);
const name = resolveName(info); const name = resolveName(info);
const phones = resolvePhones(info); const phones = resolvePhones(info);
const emails = resolveEmails(info); const emails = resolveEmails(info);
const instagram = resolveInstagram(info); const instagram = resolveInstagram(info);
const address = resolveAddress(info); const address = resolveAddress(info);
const image = slotImages("contact-image", slots, [{ url: gallery.contact, titleFa: "آشپزخانه کرم" }])[0]; const instagramImage = slotImages("instagram-row", homeSlots, [
{
url: gallery.instagram,
titleFa: "ما را در اینستاگرام دنبال کنید",
titleEn: "Follow us",
subtext: "پروژه‌های تازه و جزئیات اجرا را در صفحه اینستاگرام ببینید.",
linkUrl: instagram.href,
},
])[0];
const instagramRatio = slotRatio("instagram-row", homeSlots);
const { lat, lng, zoom } = site.map;
const mapLink = `https://www.google.com/maps?q=${encodeURIComponent(`${lat},${lng}`)}&z=${zoom}&hl=fa`;
return ( return (
<> <>
<section className="page-hero"> <section className="contact-hero">
<div className="container"> <div className="contact-hero__glow" aria-hidden="true" />
<p className="eyebrow">تماس با ما</p> <div className="container contact-hero__inner">
<h1>درباره پروژه‌تان صحبت کنیم</h1> <SectionTitle
en="contact"
title="درباره پروژه‌تان"
titleMuted="صحبت کنیم"
as="h1"
className="section-title--inline"
aside={
<p className="contact-hero__lead">
پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس می‌گیرد.
</p>
}
/>
</div> </div>
</section> </section>
<section className="section--tight"> <section className="section">
<div className="container contact-grid"> <div className="container contact-grid">
<div className="contact-facts"> <div className="contact-facts">
<p className="lead"> <Fact icon={<IconPhone />} label="تلفن" className="fact--phones">
پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس می‌گیرد.
</p>
<div className="fact fact--phones">
<span>تلفن</span>
{phones.map((phone) => ( {phones.map((phone) => (
<a key={phone.href} href={phone.href}> <a key={phone.href} href={phone.href} className="latin" dir="ltr">
{phone.display} {phone.display}
</a> </a>
))} ))}
</div> </Fact>
<div className="fact"> <Fact icon={<IconMail />} label="ایمیل">
<span>ایمیل</span>
{emails.map((email) => ( {emails.map((email) => (
<a key={email} href={`mailto:${email}`} dir="ltr" style={{ textAlign: "right" }}> <a key={email} href={`mailto:${email}`} className="latin" dir="ltr">
{email} {email}
</a> </a>
))} ))}
</div> </Fact>
<div className="fact"> <Fact icon={<IconInstagram />} label="اینستاگرام">
<span>اینستاگرام</span> <a href={instagram.href} target="_blank" rel="noreferrer" className="latin" dir="ltr">
<a href={instagram.href} target="_blank" rel="noreferrer" dir="ltr" style={{ textAlign: "right" }}>
{instagram.handle} {instagram.handle}
</a> </a>
</div> </Fact>
<div className="fact"> <Fact icon={<IconPin />} label="آدرس">
<span>آدرس</span>
<strong>{address}</strong> <strong>{address}</strong>
</div> </Fact>
<SlotImage image={image} alt={`نمونه پروژه ${name}`} ratio={slotRatio("contact-image", slots)} className="contact-frame" />
</div> </div>
<div> <div className="contact-form-panel">
<h2 className="title" style={{ marginBottom: 32 }}> <SectionTitle
فرم درخواست پروژه en="inquiry"
</h2> title="فرم درخواست"
titleMuted="پروژه"
className="section-title--compact"
/>
<ContactForm /> <ContactForm />
</div> </div>
</div> </div>
</section> </section>
<InstagramFollow
handle={instagram.handle}
href={instagram.href}
image={instagramImage}
ratio={instagramRatio}
/>
<ContactMap lat={lat} lng={lng} zoom={zoom} address={address} mapLink={mapLink} />
</> </>
); );
} }
+2742 -189
View File
File diff suppressed because it is too large Load Diff
+35 -16
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Montserrat } from "next/font/google";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { CmsMeta, JsonLd } from "@/components/CmsMeta"; import { CmsMeta, JsonLd } from "@/components/CmsMeta";
import { Footer } from "@/components/Footer"; import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { Providers } from "@/components/Providers";
import { site } from "@/data/site"; import { site } from "@/data/site";
import { siteUrl } from "@/lib/config"; import { siteUrl } from "@/lib/config";
import { import {
@@ -14,7 +16,7 @@ import {
resolveName, resolveName,
resolvePhones, resolvePhones,
} from "@/lib/content"; } from "@/lib/content";
import { getBusinessInfo, getFavicon } from "@/lib/meshkee"; import { getBusinessId, getBusinessInfo, getFavicon } from "@/lib/meshkee";
import "./globals.css"; import "./globals.css";
@@ -29,10 +31,24 @@ const yekan = localFont({
display: "swap", display: "swap",
}); });
const montserrat = Montserrat({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-montserrat",
display: "swap",
});
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const [favicon, info] = await Promise.all([getFavicon(), getBusinessInfo()]); const [favicon, info] = await Promise.all([getFavicon(), getBusinessInfo()]);
const name = resolveName(info); const name = resolveName(info);
const icons = favicon.faviconUrl ? [{ url: favicon.faviconUrl, type: "image/png" }] : undefined; const icons = favicon.faviconUrl
? [{ url: favicon.faviconUrl, type: "image/png" }]
: [{ url: "/icon.png", type: "image/png" }];
const otherMeta = Object.fromEntries(
(info.metaTags ?? [])
.filter((tag) => tag.name && tag.content != null && !tag.html)
.map((tag) => [tag.name as string, tag.content as string]),
);
return { return {
metadataBase: new URL(siteUrl), metadataBase: new URL(siteUrl),
@@ -42,6 +58,7 @@ export async function generateMetadata(): Promise<Metadata> {
}, },
description: site.description, description: site.description,
icons, icons,
...(Object.keys(otherMeta).length ? { other: otherMeta } : {}),
openGraph: { openGraph: {
type: "website", type: "website",
locale: "fa_IR", locale: "fa_IR",
@@ -54,7 +71,7 @@ export async function generateMetadata(): Promise<Metadata> {
} }
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const info = await getBusinessInfo(); const [info, businessId] = await Promise.all([getBusinessInfo(), getBusinessId()]);
const name = resolveName(info); const name = resolveName(info);
const logo = resolveLogo(info); const logo = resolveLogo(info);
const phones = resolvePhones(info); const phones = resolvePhones(info);
@@ -62,23 +79,25 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
const jsonLd = info.schema?.enabled ? info.schema.jsonLd : null; const jsonLd = info.schema?.enabled ? info.schema.jsonLd : null;
return ( return (
<html lang="fa" dir="rtl" className={yekan.variable}> <html lang="fa" dir="rtl" className={`${yekan.variable} ${montserrat.variable}`}>
<head> <head>
<CmsMeta tags={info.metaTags ?? []} /> <CmsMeta tags={info.metaTags ?? []} />
</head> </head>
<body> <body>
<JsonLd data={jsonLd} /> <Providers businessId={businessId}>
<Header logo={logo} name={name} phone={phones[0] ?? null} instagram={instagram} /> <JsonLd data={jsonLd} />
<main>{children}</main> <Header logo={logo} name={name} phone={phones[0] ?? null} instagram={instagram} />
<Footer <main>{children}</main>
logo={logo} <Footer
name={name} logo={logo}
address={resolveAddress(info)} name={name}
phones={phones} address={resolveAddress(info)}
emails={resolveEmails(info)} phones={phones}
instagram={instagram} emails={resolveEmails(info)}
badges={info.trustBadges ?? []} instagram={instagram}
/> badges={info.trustBadges ?? []}
/>
</Providers>
</body> </body>
</html> </html>
); );
+3 -4
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { SectionTitle } from "@/components/SectionTitle";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "صفحه پیدا نشد", title: "صفحه پیدا نشد",
description: "صفحه‌ای که دنبال آن بودید وجود ندارد.", description: "صفحه‌ای که دنبال آن بودید وجود ندارد.",
@@ -10,10 +12,7 @@ export const metadata: Metadata = {
export default function NotFound() { export default function NotFound() {
return ( return (
<section className="container notfound"> <section className="container notfound">
<p className="eyebrow" style={{ justifyContent: "center" }}> <SectionTitle en="404" title="صفحه مورد نظر پیدا نشد" as="h1" className="section-title--inline" />
۴۰۴
</p>
<h1>صفحه مورد نظر پیدا نشد</h1>
<p className="lead" style={{ marginInline: "auto" }}> <p className="lead" style={{ marginInline: "auto" }}>
احتمالاً آدرس اشتباه است یا این صفحه جابه‌جا شده. احتمالاً آدرس اشتباه است یا این صفحه جابه‌جا شده.
</p> </p>
+218 -134
View File
@@ -1,9 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { DetailsFrame } from "@/components/DetailsFrame";
import { HeroSlider } from "@/components/HeroSlider";
import { InstagramFollow } from "@/components/InstagramFollow";
import { OpeningFrame } from "@/components/OpeningFrame";
import { SectionTitle } from "@/components/SectionTitle";
import { SlotImage } from "@/components/SlotImage"; import { SlotImage } from "@/components/SlotImage";
import { gallery, processSteps, projects, services, site, stats } from "@/data/site"; import { gallery, processSteps, projects, services, site, stats } from "@/data/site";
import { resolveAbout, resolveName } from "@/lib/content"; import { resolveAbout, resolveInstagram, resolveLogo, resolveName } from "@/lib/content";
import { getBusinessInfo } from "@/lib/meshkee"; import { getBusinessInfo } from "@/lib/meshkee";
import { getPageSlots, slotImages, slotRatio } from "@/lib/static-images"; import { getPageSlots, slotImages, slotRatio } from "@/lib/static-images";
@@ -22,47 +27,68 @@ export default async function HomePage() {
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]); const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]);
const name = resolveName(info); const name = resolveName(info);
const about = resolveAbout(info); const about = resolveAbout(info);
const logo = resolveLogo(info);
const instagram = resolveInstagram(info);
const hero = slotImages("hero", slots, [{ url: gallery.hero, titleFa: "نمای آجری در غروب" }])[0]; const heroSlides = slotImages("slider", slots, gallery.heroSlides);
const studio = slotImages("studio-image", slots, [{ url: gallery.studio, titleFa: "ورودی سبز" }])[0]; const categoryImages = slotImages(
const projectSlots = slots.projects?.images ?? []; "four-category",
slots,
services.map((service) => ({
url: service.image,
titleFa: service.titleFa,
titleEn: service.titleEn,
subtext: service.text,
linkUrl: `/services/${service.slug}`,
})),
);
const projectSlots = slotImages(
"project-images",
slots,
projects.map((project) => ({
url: project.image,
titleFa: project.titleFa,
titleEn: project.titleEn,
subtext: `${project.category} · ${project.year}`,
linkUrl: `/portfolio/${project.slug}`,
})),
);
const sticky = slotImages("sticky-frame-bg", slots, [
{ url: gallery.studio, titleFa: "ورودی سبز", titleEn: "Studio", subtext: about[2] ?? site.about[2] },
])[0];
const openingImage = sticky.url || gallery.studio;
const instagramImage = slotImages("instagram-row", slots, [
{
url: gallery.instagram,
titleFa: "ما را در اینستاگرام دنبال کنید",
titleEn: "Follow us",
subtext: "پروژه‌های تازه و جزئیات اجرا را در صفحه اینستاگرام ببینید.",
linkUrl: instagram.href,
},
])[0];
const instagramRatio = slotRatio("instagram-row", slots);
const brandLogos = slotImages("brands", slots, gallery.brands);
const brandRatio = slotRatio("brands", slots);
return ( return (
<> <>
{/* Hero */} <HeroSlider
<section className="hero"> slides={heroSlides}
<div className="container"> title="معماری ساده،"
<div className="hero__grid"> titleLine2="زندگی روشن"
<h1 className="hero__title"> lead={`${name} یک گروه معماری و طراحی داخلی است؛ از طراحی نما و پلان تا جزئیات داخلی و اجرای نهایی.`}
معماری ساده، ctaLabel="شروع یک پروژه"
<br /> ctaHref="/contact"
<em>زندگی روشن</em> />
<span className="hero__dot" aria-hidden="true" />
</h1>
<div className="hero__aside">
<p>
{name} یک گروه معماری و طراحی داخلی است؛ از طراحی نما و پلان تا جزئیات داخلی و اجرای نهایی.
</p>
<Link href="/contact" className="btn">
شروع یک پروژه
<ArrowIcon />
</Link>
</div>
</div>
<SlotImage image={hero} alt="نمونه پروژه استودیو معماری هوم" ratio={slotRatio("hero", slots)} className="hero__frame" priority />
<div className="hero__caption">
<span>{hero.titleFa || hero.titleEn || "پروژه منتخب"}</span>
<span className="latin">{site.nameEn}</span>
</div>
</div>
</section>
{/* Intro */} {/* Intro — RTL: logo (rightmost) · copy · focus (left) */}
<section className="section"> <section className="section">
<div className="container split"> <div className="container intro">
<div> <div className="intro__brand">
<p className="eyebrow">استودیو</p> <img src={logo} alt={name} className="intro__logo" width={480} height={186} />
<h2 className="intro__text">{about[0]}</h2> </div>
<div className="intro__copy">
<SectionTitle en="studio" title={about[0]} className="section-title--compact section-title--story" />
</div> </div>
<div className="intro__meta"> <div className="intro__meta">
<p>{about[1] ?? site.about[1]}</p> <p>{about[1] ?? site.about[1]}</p>
@@ -71,110 +97,130 @@ export default async function HomePage() {
<span>معماری مسکونی، طراحی داخلی، طراحی نما</span> <span>معماری مسکونی، طراحی داخلی، طراحی نما</span>
</div> </div>
<Link href="/about" className="text-link"> <Link href="/about" className="text-link">
بیشتر درباره هوم بیشتر بدانید
</Link> </Link>
</div> </div>
</div> </div>
</section> </section>
{/* Services */} {/* four-category — 4 service panels */}
<section className="section section--surface"> <section className="section section--tight">
<div className="container"> <div className="container">
<div className="section-head"> <SectionTitle en="services" title="ما در هوم." titleMuted="چه کارهایی انجام می‌دهیم" />
<div> </div>
<p className="eyebrow">خدمات</p> <div className="service-panels" role="list">
<h2 className="title">چه کاری انجام می‌دهیم</h2> {Array.from({ length: 4 }, (_, index) => {
</div> const service = services[index];
</div> const cms = categoryImages[index];
<div className="services"> const image = cms?.url || service?.image || gallery.heroSlides[0].url;
{services.map((service) => ( const titleFa = cms?.titleFa || service?.titleFa || `خدمت ${index + 1}`;
<article key={service.index} className="service"> const titleEn = cms?.titleEn || service?.titleEn || "";
<span className="service__index">{service.index}</span> const desc = cms?.subtext || service?.text || "";
<div> const href = cms?.linkUrl || (service ? `/services/${service.slug}` : "/services");
<h3>{service.titleFa}</h3> const external = /^https?:\/\//.test(href);
<span className="latin">{service.titleEn}</span> 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>
<p>{service.text}</p> </Link>
</article> );
))} })}
</div>
</div> </div>
</section> </section>
{/* Projects */} {/* project-images — masonry */}
<section className="section"> <section className="section">
<div className="container"> <div className="container">
<div className="section-head"> <SectionTitle
<div> en="projects"
<p className="eyebrow">پروژه‌ها</p> title="پروژه‌های"
<h2 className="title">پروژه‌های منتخب</h2> titleMuted="منتخب"
</div> aside={<p className="lead">گزیده‌ای از کارهای اخیر در معماری، طراحی داخلی و نما.</p>}
<p className="lead" style={{ maxWidth: "34ch" }}> />
گزیده‌ای از کارهای اخیر در معماری، طراحی داخلی و نما.
</p>
</div>
<div className="projects"> <div className="projects">
{projectSlots.length {projectSlots.map((image, index) => {
? projectSlots.map((image, index) => ( const title = image.titleFa || image.titleEn || `پروژه ${index + 1}`;
<div key={`${image.url}-${index}`} className="project"> const media = { ...image, linkUrl: null };
<SlotImage image={image} alt={image.titleFa || `پروژه ${index + 1}`} ratio={slotRatio("projects", slots)} /> const card = (
<div className="project__meta"> <>
<h3>{image.titleFa || image.titleEn || `پروژه ${index + 1}`}</h3> <SlotImage image={media} alt={title} />
{image.subtext ? <span>{image.subtext}</span> : null} <div className="project__meta">
</div> <h3>{title}</h3>
{image.titleFa && image.titleEn ? <p className="project__sub latin">{image.titleEn}</p> : null} {image.subtext ? <span>{image.subtext}</span> : null}
</div> </div>
)) {image.titleEn ? <p className="project__sub latin">{image.titleEn}</p> : null}
: projects.map((project) => ( </>
<article key={project.slug} className="project"> );
<div className="frame" style={{ aspectRatio: slotRatio("projects", slots) }}> if (image.linkUrl) {
<img src={project.image} alt={`${project.titleFa} — ${project.category}`} loading="lazy" decoding="async" /> const external = /^https?:\/\//.test(image.linkUrl);
</div> return (
<div className="project__meta"> <Link
<h3>{project.titleFa}</h3> key={`${image.url}-${index}`}
<span> href={image.linkUrl}
{project.category} · {project.year} className="project"
</span> target={external ? "_blank" : undefined}
</div> rel={external ? "noreferrer" : undefined}
<p className="project__sub latin">{project.titleEn}</p> >
</article> {card}
))} </Link>
);
}
return (
<div key={`${image.url}-${index}`} className="project">
{card}
</div>
);
})}
</div> </div>
</div> </div>
</section> </section>
{/* Studio */} <DetailsFrame />
<section className="section section--line-top">
<div className="container split split--equal"> {/* sticky-frame-bg */}
<SlotImage image={studio} alt="ورودی یکی از پروژه‌های استودیو هوم" ratio={slotRatio("studio-image", slots)} className="studio__frame" /> <OpeningFrame
<div className="studio__body"> image={openingImage}
<div> imageAlt={sticky.titleFa || sticky.titleEn || "نگاه استودیو هوم"}
<p className="eyebrow">نگاه ما</p> eyebrow={sticky.titleEn || "نگاه ما"}
<h2 className="title">فضایی که با شما زندگی می‌کند</h2> headline="معماری ساده،"
</div> headlineMuted="زندگی روشن"
<p> headlineEn="SIMPLE ARCHITECTURE, BRIGHT LIVING"
ما طراحی را از نیاز واقعی آغاز می‌کنیم؛ نه از فرم. نور، مسیر حرکت، متریال و تناسب، ابزار ما برای ساختن فضایی هستند که body={sticky.subtext || about[2] || site.about[2]}
سال‌ها بعد هم آرام و خوانا بماند. ctaLabel="درباره استودیو"
</p> ctaHref="/about"
<p>{about[2] ?? site.about[2]}</p> />
<Link href="/about" className="btn btn--ghost">
درباره استودیو
<ArrowIcon />
</Link>
</div>
</div>
</section>
{/* Process */} {/* Process */}
<section className="section section--dark"> <section className="section section--dark">
<div className="container"> <div className="container">
<p className="eyebrow eyebrow--light">فرآیند کار</p> <SectionTitle en="process" title="از ایده تا تحویل،" titleMuted="در چهار گام" light />
<h2 className="title">از ایده تا تحویل، در چهار گام</h2>
<div className="process"> <div className="process">
{processSteps.map((item) => ( {processSteps.map((item) => (
<div key={item.step} className="process__item"> <div key={item.step} className="process__item">
<span className="process__step">گام {item.step}</span> <span className="process__num latin" lang="en" dir="ltr" aria-hidden="true">
{String(item.step).padStart(2, "0")}
</span>
<h3>{item.titleFa}</h3> <h3>{item.titleFa}</h3>
<p>{item.text}</p> <p>{item.text}</p>
</div> </div>
@@ -184,30 +230,68 @@ export default async function HomePage() {
</section> </section>
{/* Stats */} {/* Stats */}
<section className="section--tight"> <section className="stats-band" aria-label="آمار استودیو">
<div className="container"> <div className="stats">
<div className="stats"> {stats.map((stat) => (
{stats.map((stat) => ( <div key={stat.label} className="stat">
<div key={stat.label} className="stat"> <strong className="latin" lang="en" dir="ltr">
<strong>{stat.value}</strong> {stat.value}
<span>{stat.label}</span> </strong>
</div> <span>{stat.label}</span>
))} </div>
</div> ))}
</div> </div>
</section> </section>
{/* CTA */} {/* brands */}
<section className="section"> {brandLogos.length ? (
<div className="container cta"> <section className="section section--tight brands" aria-label="همکاران">
<div> <div className="container">
<p className="eyebrow">شروع همکاری</p> <SectionTitle en="partners" title="برندها و" titleMuted="همکاران" />
<h2>پروژه‌ای در ذهن دارید؟ درباره‌اش صحبت کنیم.</h2> <ul className="brands__grid">
{brandLogos.map((brand, index) => {
const alt = brand.titleFa || brand.titleEn || `برند ${index + 1}`;
return (
<li key={`${brand.url}-${index}`}>
<SlotImage image={brand} alt={alt} ratio={brandRatio} className="brands__logo" />
{brand.titleFa || brand.titleEn ? (
<p className="brands__caption">
{brand.titleFa ? <span>{brand.titleFa}</span> : null}
{brand.titleEn ? <span className="latin">{brand.titleEn}</span> : null}
</p>
) : null}
</li>
);
})}
</ul>
</div> </div>
<Link href="/contact" className="btn"> </section>
تماس با ما ) : null}
<ArrowIcon />
</Link> {/* 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> </div>
</section> </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>
);
}
+161 -55
View File
@@ -1,11 +1,86 @@
"use client"; "use client";
import { useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent, type ReactNode } from "react";
type Status = { tone: "idle" | "pending" | "success" | "error"; message: string }; type Status = { tone: "idle" | "pending" | "success" | "error"; message: string };
function IconUser() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="12" cy="8" r="3.2" />
<path d="M5.5 19.5c1.8-3.2 4-4.8 6.5-4.8s4.7 1.6 6.5 4.8" />
</svg>
);
}
function IconPhone() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M8.5 4.5h3l1.5 4-2 1.2a12 12 0 0 0 5.3 5.3l1.2-2 4 1.5v3A2 2 0 0 1 19.5 19.5 15 15 0 0 1 4.5 4.5 2 2 0 0 1 6.5 2.5h2Z" />
</svg>
);
}
function IconMail() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="3.5" y="5.5" width="17" height="13" rx="1.5" />
<path d="m4.5 7.5 7.5 6 7.5-6" />
</svg>
);
}
function IconSubject() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M5 7h14M5 12h10M5 17h7" />
</svg>
);
}
function IconMessage() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M5 6.5h14v9H9l-4 3v-12Z" />
</svg>
);
}
function Field({
id,
label,
icon,
children,
wide,
}: {
id: string;
label: string;
icon: ReactNode;
children: ReactNode;
wide?: boolean;
}) {
return (
<div className={`field${wide ? " field--wide" : ""}`}>
<label className="sr-only" htmlFor={id}>
{label}
</label>
<span className="field__icon" aria-hidden="true">
{icon}
</span>
{children}
</div>
);
}
export function ContactForm() { export function ContactForm() {
const [status, setStatus] = useState<Status>({ tone: "idle", message: "" }); const [status, setStatus] = useState<Status>({ tone: "idle", message: "" });
const [toast, setToast] = useState<{ tone: "success" | "error"; message: string } | null>(null);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 4200);
return () => window.clearTimeout(timer);
}, [toast]);
async function onSubmit(event: FormEvent<HTMLFormElement>) { async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -20,7 +95,9 @@ export function ContactForm() {
}; };
if (!payload.name || !payload.title || !payload.text) { if (!payload.name || !payload.title || !payload.text) {
setStatus({ tone: "error", message: "لطفاً نام، موضوع و متن پیام را کامل کنید." }); const message = "لطفاً نام، موضوع و متن پیام را کامل کنید.";
setStatus({ tone: "error", message });
setToast({ tone: "error", message });
return; return;
} }
@@ -34,69 +111,98 @@ export function ContactForm() {
if (!response.ok) { if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { message?: string | string[] }; const body = (await response.json().catch(() => ({}))) as { message?: string | string[] };
const message = Array.isArray(body.message) ? body.message.join("، ") : body.message; const message = Array.isArray(body.message) ? body.message.join("، ") : body.message;
// Only surface server messages that are already localized; otherwise use a generic one. throw new Error(
throw new Error(message && /[\u0600-\u06FF]/.test(message) ? message : "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید."); message && /[\u0600-\u06FF]/.test(message)
? message
: "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید.",
);
} }
form.reset(); form.reset();
setStatus({ tone: "success", message: "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم." }); const success = "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.";
setStatus({ tone: "success", message: success });
setToast({ tone: "success", message: success });
} catch (error) { } catch (error) {
setStatus({ const message = error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید.";
tone: "error", setStatus({ tone: "error", message });
message: error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید.", setToast({ tone: "error", message });
});
} }
} }
return ( return (
<form className="form" onSubmit={onSubmit} noValidate> <>
<div className="form__row"> <form className="form" onSubmit={onSubmit} noValidate>
<div className="field"> <div className="form__row">
<label htmlFor="contact-name">نام و نام خانوادگی *</label> <Field id="contact-name" label="نام و نام خانوادگی *" icon={<IconUser />}>
<input id="contact-name" name="name" type="text" autoComplete="name" required /> <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"
type="tel"
inputMode="tel"
autoComplete="tel"
dir="ltr"
placeholder="۰۹۱۲ ۰۰۰ ۰۰۰۰"
/>
</Field>
</div> </div>
<div className="field"> <div className="form__row">
<label htmlFor="contact-cell">شماره تماس</label> <Field id="contact-email" label="ایمیل" icon={<IconMail />}>
<input <input
id="contact-cell" id="contact-email"
name="cellNumber" name="email"
type="tel" type="email"
inputMode="tel" autoComplete="email"
autoComplete="tel" dir="ltr"
dir="ltr" placeholder="ایمیل"
placeholder="0912 000 0000" />
</Field>
<Field id="contact-title" label="موضوع *" icon={<IconSubject />}>
<input
id="contact-title"
name="title"
type="text"
required
placeholder="موضوع *"
/>
</Field>
</div>
<Field id="contact-text" label="توضیحات پروژه *" icon={<IconMessage />} wide>
<textarea
id="contact-text"
name="text"
required
placeholder="متراژ، محل پروژه، نوع خدمات مورد نیاز و زمان‌بندی مدنظر را بنویسید."
/> />
</Field>
<div className="form__footer">
<button type="submit" className="btn" disabled={status.tone === "pending"}>
ارسال پیام
<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>
</button>
<p className="form__status" data-tone={status.tone} role="status" aria-live="polite">
{status.message}
</p>
</div> </div>
</div> </form>
<div className="form__row">
<div className="field"> {toast ? (
<label htmlFor="contact-email">ایمیل</label> <div className="toast-stack" aria-live="polite">
<input id="contact-email" name="email" type="email" autoComplete="email" dir="ltr" /> <div className={`toast toast--${toast.tone}`} role="status">
{toast.message}
</div>
</div> </div>
<div className="field"> ) : null}
<label htmlFor="contact-title">موضوع *</label> </>
<input id="contact-title" name="title" type="text" required placeholder="مثلاً طراحی داخلی واحد مسکونی" />
</div>
</div>
<div className="field">
<label htmlFor="contact-text">توضیحات پروژه *</label>
<textarea
id="contact-text"
name="text"
required
placeholder="متراژ، محل پروژه، نوع خدمات مورد نیاز و زمان‌بندی مدنظر را بنویسید."
/>
</div>
<div className="form__footer">
<button type="submit" className="btn" disabled={status.tone === "pending"}>
ارسال پیام
<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>
</button>
<p className="form__status" data-tone={status.tone} role="status" aria-live="polite">
{status.message}
</p>
</div>
</form>
); );
} }
+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 Link from "next/link";
import { NAV_LINKS, site } from "@/data/site"; import { NAV_LINKS, services, site } from "@/data/site";
import { stripScripts, toFaDigits, type PhoneLink } from "@/lib/content"; import { stripScripts, toFaDigits, type PhoneLink } from "@/lib/content";
type Props = { type Props = {
@@ -38,12 +38,13 @@ export function Footer({ logo, name, address, phones, emails, instagram, badges
</div> </div>
<div> <div>
<h3>خدمات</h3> <h3>تخصص‌ها</h3>
<ul> <ul>
<li>طراحی معماری</li> {services.map((service) => (
<li>طراحی داخلی</li> <li key={service.slug}>
<li>طراحی نما</li> <Link href={`/services/${service.slug}`}>{service.titleFa}</Link>
<li>نظارت و اجرا</li> </li>
))}
</ul> </ul>
</div> </div>
@@ -53,17 +54,21 @@ export function Footer({ logo, name, address, phones, emails, instagram, badges
<li>{address}</li> <li>{address}</li>
{phones.map((phone) => ( {phones.map((phone) => (
<li key={phone.href}> <li key={phone.href}>
<a href={phone.href}>{phone.display}</a> <a href={phone.href} className="latin" dir="ltr">
{phone.display}
</a>
</li> </li>
))} ))}
{emails.map((email) => ( {emails.map((email) => (
<li key={email}> <li key={email}>
<a href={`mailto:${email}`}>{email}</a> <a href={`mailto:${email}`} className="latin" dir="ltr">
{email}
</a>
</li> </li>
))} ))}
<li> <li>
<a href={instagram.href} target="_blank" rel="noreferrer"> <a href={instagram.href} target="_blank" rel="noreferrer">
اینستاگرام {instagram.handle} اینستاگرام <span className="latin">{instagram.handle}</span>
</a> </a>
</li> </li>
</ul> </ul>
+136 -16
View File
@@ -2,9 +2,10 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useId, useState } from "react";
import { NAV_LINKS } from "@/data/site"; import { AccountMenu } from "@/components/AccountMenu";
import { NAV_LINKS, type NavLink } from "@/data/site";
import type { PhoneLink } from "@/lib/content"; import type { PhoneLink } from "@/lib/content";
type Props = { type Props = {
@@ -14,18 +15,80 @@ type Props = {
instagram: { href: string; handle: string }; instagram: { href: string; handle: string };
}; };
function linkActive(pathname: string, href: string) {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
function NavItem({ link, pathname }: { link: NavLink; pathname: string }) {
const menuId = useId();
const hasChildren = Boolean(link.children?.length);
const active = linkActive(pathname, link.href);
if (!hasChildren) {
return (
<Link href={link.href} aria-current={active ? "page" : undefined}>
{link.label}
</Link>
);
}
return (
<div className="nav__item" data-has-menu="">
<Link
href={link.href}
className="nav__trigger"
aria-current={active ? "page" : undefined}
aria-haspopup="menu"
aria-controls={menuId}
>
{link.label}
<svg className="nav__caret" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2 4.5 6 8.5 10 4.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
</svg>
</Link>
<div id={menuId} className="nav__menu" role="menu">
{link.children!.map((child) => (
<Link
key={child.href}
href={child.href}
role="menuitem"
aria-current={linkActive(pathname, child.href) ? "page" : undefined}
>
{child.label}
</Link>
))}
</div>
</div>
);
}
export function Header({ logo, name, phone, instagram }: Props) { export function Header({ logo, name, phone, instagram }: Props) {
const pathname = usePathname(); const pathname = usePathname();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mobileOpen, setMobileOpen] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setOpen(false); setOpen(false);
setMobileOpen(null);
}, [pathname]); }, [pathname]);
const isActive = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href)); useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open]);
return ( return (
<header className="header"> <header className="header" data-nav-open={open ? "true" : "false"}>
<div className="container header__inner"> <div className="container header__inner">
<Link href="/" className="header__logo" aria-label={name}> <Link href="/" className="header__logo" aria-label={name}>
<img src={logo} alt={name} width={172} height={66} /> <img src={logo} alt={name} width={172} height={66} />
@@ -33,18 +96,17 @@ export function Header({ logo, name, phone, instagram }: Props) {
<nav className="nav" aria-label="منوی اصلی"> <nav className="nav" aria-label="منوی اصلی">
{NAV_LINKS.map((link) => ( {NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}> <NavItem key={link.href} link={link} pathname={pathname} />
{link.label}
</Link>
))} ))}
</nav> </nav>
<div className="header__tools"> <div className="header__tools">
{phone ? ( {phone ? (
<a className="header__phone" href={phone.href}> <a className="header__phone latin" href={phone.href} dir="ltr">
{phone.display} {phone.display}
</a> </a>
) : null} ) : null}
<AccountMenu />
<button <button
type="button" type="button"
className="header__burger" className="header__burger"
@@ -58,17 +120,75 @@ export function Header({ logo, name, phone, instagram }: Props) {
</div> </div>
</div> </div>
<div id="mobile-nav" className="mobile-nav" data-open={open}> <div
{NAV_LINKS.map((link) => ( id="mobile-nav"
<Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}> className="mobile-nav"
{link.label} data-open={open}
</Link> 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"> <div className="mobile-nav__meta">
{phone ? <a href={phone.href}>{phone.display}</a> : null} {phone ? (
<a href={instagram.href} target="_blank" rel="noreferrer"> <a href={phone.href} className="latin" dir="ltr">
{phone.display}
</a>
) : null}
<a href={instagram.href} target="_blank" rel="noreferrer" className="latin" dir="ltr">
{instagram.handle} {instagram.handle}
</a> </a>
<Link href="/contact" className="btn mobile-nav__cta" onClick={() => setOpen(false)}>
تماس با ما
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M4 12h16M13 5l7 7-7 7" />
</svg>
</Link>
</div> </div>
</div> </div>
</header> </header>
+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"; ratio: "4:5" | "1:1" | "5:4";
}; };
export const NAV_LINKS = [ export type NavChild = { href: string; label: string };
{ href: "/", label: "خانه" }, export type NavLink = { href: string; label: string; children?: NavChild[] };
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "تماس با ما" },
];
export const site = { export const site = {
nameFa: "استودیو معماری هوم", nameFa: "استودیو معماری هوم",
@@ -25,8 +22,10 @@ export const site = {
"استودیو معماری هوم؛ گروه معماری و طراحی داخلی. طراحی نما، معماری مسکونی، طراحی داخلی و اجرای پروژه‌های ساختمانی با نگاهی مینیمال، دقیق و کاربردی.", "استودیو معماری هوم؛ گروه معماری و طراحی داخلی. طراحی نما، معماری مسکونی، طراحی داخلی و اجرای پروژه‌های ساختمانی با نگاهی مینیمال، دقیق و کاربردی.",
logo: "/images/logo.png", logo: "/images/logo.png",
address: "تهران", address: "تهران",
/** Default map pin (Tehran) — replace when a studio address is confirmed */
map: { lat: 35.7219, lng: 51.3347, zoom: 13 },
email: "info@homgroup.com", email: "info@homgroup.com",
phones: [{ display: "۰۹۱۲ ۰۰۰ ۰۰۰۰", href: "tel:+989120000000" }], phones: [{ display: "0912 000 0000", href: "tel:+989120000000" }],
instagram: { href: "https://instagram.com/hom.archstudio", handle: "@hom.archstudio" }, instagram: { href: "https://instagram.com/hom.archstudio", handle: "@hom.archstudio" },
about: [ about: [
"استودیو معماری هوم یک گروه معماری و طراحی داخلی است که با تمرکز بر سادگی، تناسب و کیفیت اجرا، فضاهایی برای زندگی و کار طراحی می‌کند.", "استودیو معماری هوم یک گروه معماری و طراحی داخلی است که با تمرکز بر سادگی، تناسب و کیفیت اجرا، فضاهایی برای زندگی و کار طراحی می‌کند.",
@@ -35,63 +34,87 @@ export const site = {
], ],
}; };
export const services = [ export type ServiceStep = { title: string; text: string };
export type Service = {
slug: string;
index: string;
titleFa: string;
titleEn: string;
text: string;
lead: string;
image: string;
frameImage: string;
steps: ServiceStep[];
};
export const services: Service[] = [
{ {
slug: "architecture",
index: "01", index: "01",
titleFa: "طراحی معماری", titleFa: "طراحی معماری",
titleEn: "Architecture", titleEn: "Architecture",
text: "طراحی ساختمان‌های مسکونی و تجاری از مرحله ایده تا نقشه‌های اجرایی، با رعایت ضوابط و تناسب با بستر پروژه.", text: "طراحی ساختمان‌های مسکونی و تجاری از مرحله ایده تا نقشه‌های اجرایی، با رعایت ضوابط و تناسب با بستر پروژه.",
lead: "از تحلیل زمین و برنامه فضایی تا پلان‌های اجرایی؛ معماری که با نور، سازه و زندگی روزمره هماهنگ است.",
image: "/images/courtyard-brick.jpg",
frameImage: "/images/courtyard-brick-2.jpg",
steps: [
{ title: "شناخت بستر", text: "بررسی زمین، ضوابط، نور و نیاز کارفرما." },
{ title: "ایده و اسکیس", text: "شکل‌گیری حجم و سازمان فضایی اولیه." },
{ title: "توسعه طرح", text: "پلان، نما و مقاطع تا هماهنگی کامل." },
{ title: "نقشه اجرایی", text: "مدارک آماده برای اخذ مجوز و ساخت." },
],
}, },
{ {
slug: "interior-design",
index: "02", index: "02",
titleFa: "طراحی داخلی", titleFa: "طراحی داخلی",
titleEn: "Interior Design", titleEn: "Interior Design",
text: "طراحی فضاهای داخلی، چیدمان، نورپردازی و انتخاب متریال؛ آشپزخانه، نشیمن، اتاق کار و فضاهای تجاری.", text: "طراحی فضاهای داخلی، چیدمان، نورپردازی و انتخاب متریال؛ آشپزخانه، نشیمن، اتاق کار و فضاهای تجاری.",
lead: "فضای داخلی باید آرام، کاربردی و دقیق باشد؛ از چیدمان تا متریال و نور، همه در خدمت تجربه زندگی.",
image: "/images/kitchen-cream.jpg",
frameImage: "/images/kitchen-oak.jpg",
steps: [
{ title: "برنامه فضایی", text: "تعیین کارکردها و مسیر حرکت در فضا." },
{ title: "ایده طراحی", text: "مفهوم، پالت متریال و حس کلی فضا." },
{ title: "جزئیات", text: "نور، مبلمان، کابینت و نقاط تمرکز." },
{ title: "اجرای داخلی", text: "نقشه‌ها و همراهی تا تحویل فضا." },
],
}, },
{ {
slug: "facade-design",
index: "03", index: "03",
titleFa: "طراحی نما", titleFa: "طراحی نما",
titleEn: "Facade Design", titleEn: "Facade Design",
text: "طراحی و بازطراحی نمای ساختمان با متریال‌های آجر، سنگ، چوب و فلز؛ همراه با مدل‌سازی و رندر واقع‌گرایانه.", text: "طراحی و بازطراحی نمای ساختمان با متریال‌های آجر، سنگ، چوب و فلز؛ همراه با مدل‌سازی و رندر واقع‌گرایانه.",
lead: "نما اولین برخورد با بناست؛ ریتم، متریال و نورپردازی را طوری می‌چینیم که هویت پروژه خوانا بماند.",
image: "/images/facade-louver.jpg",
frameImage: "/images/facade-sunset.jpg",
steps: [
{ title: "تحلیل نما", text: "بررسی جهت، همسایگی و ضوابط شهری." },
{ title: "زبان بصری", text: "ریتم، عمق و ترکیب متریال." },
{ title: "مدل و رندر", text: "شبیه‌سازی واقع‌گرایانه روز و شب." },
{ title: "جزئیات اجرایی", text: "دیتیل اتصال و آماده ساخت." },
],
}, },
{ {
slug: "build-supervision",
index: "04", index: "04",
titleFa: "نظارت و اجرا", titleFa: "نظارت و اجرا",
titleEn: "Build & Supervision", titleEn: "Build & Supervision",
text: "مدیریت و نظارت بر اجرای پروژه تا تحویل نهایی، تا آنچه طراحی شده دقیقاً همان‌طور ساخته شود.", text: "مدیریت و نظارت بر اجرای پروژه تا تحویل نهایی، تا آنچه طراحی شده دقیقاً همان‌طور ساخته شود.",
lead: "طرح روی کاغذ کافی نیست؛ با نظارت مستمر، کیفیت اجرا را تا تحویل نهایی حفظ می‌کنیم.",
image: "/images/garden-gate.jpg",
frameImage: "/images/facade-sunset-detail.jpg",
steps: [
{ title: "برنامه اجرا", text: "زمان‌بندی، اولویت‌ها و هماهنگی عوامل." },
{ title: "کنترل کیفیت", text: "بررسی متریال و جزئیات در کارگاه." },
{ title: "هماهنگی تیم‌ها", text: "ارتباط مستمر بین طراحی و اجرا." },
{ title: "تحویل نهایی", text: "بازبینی کامل تا تطبیق با طرح." },
],
}, },
]; ];
export const processSteps = [
{
step: "۱",
titleFa: "شناخت",
text: "جلسه آشنایی، بازدید از محل و تعریف دقیق نیازها، بودجه و زمان‌بندی پروژه.",
},
{
step: "۲",
titleFa: "ایده و طرح اولیه",
text: "مطالعه بستر، ارائه کانسپت و گزینه‌های طراحی به همراه تصاویر سه‌بعدی.",
},
{
step: "۳",
titleFa: "طراحی تفصیلی",
text: "تهیه نقشه‌های اجرایی، جزئیات، جدول متریال و برآورد هزینه.",
},
{
step: "۴",
titleFa: "اجرا و تحویل",
text: "هماهنگی پیمانکاران، نظارت مستمر بر کیفیت اجرا و تحویل پروژه.",
},
];
export const stats = [
{ value: "۱۰+", label: "سال تجربه" },
{ value: "۸۰+", label: "پروژه طراحی‌شده" },
{ value: "۳۰+", label: "پروژه اجراشده" },
{ value: "۱۰۰٪", label: "تعهد به جزئیات" },
];
export const projects: Project[] = [ export const projects: Project[] = [
{ {
slug: "sunset-brick-villa", slug: "sunset-brick-villa",
@@ -155,14 +178,74 @@ export const projects: Project[] = [
}, },
]; ];
/** Top-level nav; items with `children` render a dropdown. */
export const NAV_LINKS: NavLink[] = [
{ href: "/", label: "خانه" },
{
href: "/portfolio",
label: "پروژه",
children: projects.map((project) => ({
href: `/portfolio/${project.slug}`,
label: project.titleFa,
})),
},
{
href: "/services",
label: "تخصص‌های ما",
children: services.map((service) => ({
href: `/services/${service.slug}`,
label: service.titleFa,
})),
},
{ href: "/blog", label: "بلاگ" },
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "تماس با ما" },
];
export const processSteps = [
{
step: 1,
titleFa: "شناخت",
text: "جلسه آشنایی، بازدید از محل و تعریف دقیق نیازها، بودجه و زمان‌بندی پروژه.",
},
{
step: 2,
titleFa: "ایده و طرح اولیه",
text: "مطالعه بستر، ارائه کانسپت و گزینه‌های طراحی به همراه تصاویر سه‌بعدی.",
},
{
step: 3,
titleFa: "طراحی تفصیلی",
text: "تهیه نقشه‌های اجرایی، جزئیات، جدول متریال و برآورد هزینه.",
},
{
step: 4,
titleFa: "اجرا و تحویل",
text: "هماهنگی پیمانکاران، نظارت مستمر بر کیفیت اجرا و تحویل پروژه.",
},
];
export const stats = [
{ value: "10+", label: "سال تجربه" },
{ value: "80+", label: "پروژه طراحی‌شده" },
{ value: "30+", label: "پروژه اجراشده" },
{ value: "100%", label: "تعهد به جزئیات" },
];
export const gallery = { export const gallery = {
hero: "/images/facade-sunset.jpg", /** Fallback slides for homepage `slider` slot */
studio: "/images/garden-gate.jpg", heroSlides: [
aboutHero: "/images/courtyard-brick.jpg", { url: "/images/facade-sunset.jpg", titleFa: "ویلای آجری", titleEn: "Brick Residence" },
aboutGallery: [ { url: "/images/courtyard-brick.jpg", titleFa: "خانه حیاط‌دار", titleEn: "Courtyard House" },
"/images/kitchen-oak-2.jpg", { url: "/images/garden-gate.jpg", titleFa: "ورودی سبز", titleEn: "Garden Gate" },
"/images/facade-sunset-detail.jpg", { url: "/images/facade-louver.jpg", titleFa: "نمای لوور", titleEn: "Louver Facade" },
"/images/study-terracotta-2.jpg",
], ],
contact: "/images/kitchen-cream-portrait.jpg", studio: "/images/garden-gate.jpg",
instagram: "/images/hom-instagram.jpeg",
aboutImages: [
{ url: "/images/courtyard-brick.jpg", titleFa: "خانه حیاط‌دار", titleEn: "Courtyard", subtext: "معماری مسکونی" },
{ url: "/images/kitchen-oak-2.jpg", titleFa: "آشپزخانه بلوط", titleEn: "Oak Kitchen", subtext: "طراحی داخلی" },
],
/** Fallback logos for homepage `brands` slot — empty until CMS uploads */
brands: [] as { url: string; titleFa?: string; titleEn?: string; linkUrl?: string }[],
}; };
+131
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 = export const meshkeeApiBase =
process.env.NEXT_PUBLIC_MESHKEE_API_BASE || "https://api.meshkee.com/api/v1"; process.env.NEXT_PUBLIC_MESHKEE_API_BASE || "https://api.meshkee.com/api/v1";
@@ -6,3 +6,5 @@ export const meshkeeApiBase =
export const siteUrl = `https://${websiteDomain}`; export const siteUrl = `https://${websiteDomain}`;
export const customerPortalUrl = `https://customer.${websiteDomain}`; export const customerPortalUrl = `https://customer.${websiteDomain}`;
export const businessDashboardUrl = `https://business.${websiteDomain}`;
+7 -4
View File
@@ -28,10 +28,13 @@ export type PhoneLink = { href: string; display: string };
export function resolvePhones(info: BusinessInfo): PhoneLink[] { export function resolvePhones(info: BusinessInfo): PhoneLink[] {
const fromApi = (info.phoneNumbers ?? []).map(textOf).filter(Boolean); const fromApi = (info.phoneNumbers ?? []).map(textOf).filter(Boolean);
if (!fromApi.length) return [...site.phones]; if (!fromApi.length) return [...site.phones];
return fromApi.map((display) => ({ return fromApi.map((raw) => {
display, const display = toLatinDigits(raw);
href: `tel:${toLatinDigits(display).replace(/[^\d+]/g, "")}`, return {
})); display,
href: `tel:${display.replace(/[^\d+]/g, "")}`,
};
});
} }
export function resolveAddress(info: BusinessInfo) { 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) { export function tenantPath(suffix: string) {
return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`; return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
} }
type Tenant = {
id: number | string;
nameFa?: string;
name?: string;
domain?: string;
};
export const getTenant = cache(async () =>
meshkeeGet<Tenant | null>(`/tenants/${encodeURIComponent(websiteDomain)}`, null),
);
export const getBusinessId = cache(async () => {
const tenant = await getTenant();
return tenant?.id != null ? String(tenant.id) : "";
});
export type FaviconInfo = { export type FaviconInfo = {
faviconUrl: string | null; faviconUrl: string | null;
logoUrl: string | null; logoUrl: string | null;
+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[] = [ export const STATIC_IMAGE_SLOTS: StaticImageSlot[] = [
{ {
key: "hero", key: "slider",
label: "تصویر اصلی صفحه اول", label: "اسلایدر تمام‌صفحه صفحه اول (قابل تکرار)",
kind: "single", kind: "list",
pageKey: "home", pageKey: "home",
aspectRatio: "16:9", aspectRatio: "16:9",
itemCount: 1, itemCount: null,
recommendedWidth: 1920, recommendedWidth: 1920,
}, },
{ {
key: "projects", key: "four-category",
label: "پروژه‌های منتخب صفحه اول", label: "چهار خدمت / تخصص صفحه اول",
kind: "list", kind: "list",
pageKey: "home", pageKey: "home",
aspectRatio: "4:5", aspectRatio: "3:4",
itemCount: 4,
recommendedWidth: 900,
},
{
key: "project-images",
label: "پروژه‌های منتخب صفحه اول (میسونری — اندازه آزاد)",
kind: "list",
pageKey: "home",
aspectRatio: "3:4",
itemCount: null, itemCount: null,
recommendedWidth: 1200, recommendedWidth: 1200,
}, },
{ {
key: "studio-image", key: "sticky-frame-bg",
label: "تصویر معرفی استودیو (صفحه اول)", label: "پس‌زمینه فریم استیکی OpeningFrame",
kind: "single", kind: "single",
pageKey: "home", pageKey: "home",
aspectRatio: "4:3",
itemCount: 1,
recommendedWidth: 1400,
},
{
key: "about-hero",
label: "تصویر اصلی درباره ما",
kind: "single",
pageKey: "about",
aspectRatio: "16:9", aspectRatio: "16:9",
itemCount: 1, itemCount: 1,
recommendedWidth: 1920, recommendedWidth: 1920,
}, },
{ {
key: "about-gallery", key: "instagram-row",
label: "گالری درباره ما", label: "فریم صفحه اینستاگرام (اسکرین‌شات موبایل)",
kind: "list", kind: "single",
pageKey: "about", pageKey: "home",
aspectRatio: "1:1", aspectRatio: "9:16",
itemCount: 3, itemCount: 1,
recommendedWidth: 900, recommendedWidth: 720,
}, },
{ {
key: "contact-image", key: "brands",
label: "تصویر تماس با ما", label: "لوگوی برندها / همکاران (قابل تکرار)",
kind: "single", kind: "list",
pageKey: "contact", pageKey: "home",
aspectRatio: "4:5", aspectRatio: "3:2",
itemCount: 1, itemCount: null,
recommendedWidth: 1200, recommendedWidth: 480,
},
{
key: "about-us-image",
label: "تصاویر درباره ما (۲ تصویر)",
kind: "list",
pageKey: "about",
aspectRatio: "16:9",
itemCount: 2,
recommendedWidth: 1600,
}, },
]; ];