Initial HOM arch studio website (home, about, contact) on Meshkee

This commit is contained in:
dev
2026-10-11 22:48:58 +03:30
commit 73971bf6d9
47 changed files with 3743 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
NEXT_PUBLIC_WEBSITE_DOMAIN=homgroup.com
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
+31
View File
@@ -0,0 +1,31 @@
# dependencies
/node_modules
/.pnp
.pnp.*
# next.js
/.next/
/out/
# production
/build
# source assets (optimized copies live in public/images)
/assets
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files
.env*
!.env.example
# typescript
*.tsbuildinfo
next-env.d.ts
+79
View File
@@ -0,0 +1,79 @@
# HOM arch studio — AI / Project Context
Persian (fa, RTL) brochure website for **استودیو معماری هوم** (architecture + interior design group).
Keep this file current whenever routes, slots, data sources or design rules change.
## Source of truth
- Live domain: `https://homgroup.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.com`)
- Backend docs: https://api.meshkee.com/docs/website (OpenAPI: `/docs/website/openapi.json`)
- API base: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_MESHKEE_API_BASE`)
- Tenant: `GET /tenants/homgroup.com` — **not registered yet** (404 as of 2026-10-11). Every Meshkee read has a local fallback, so the site renders fully without the tenant.
- Prompt reference: `https://api.meshkee.com/api/v1/public/ai-prompts/studio-hom/basic-create/36caa331616c4b1cd382f8cb`
- Original brief: homepage + about + contact only; `lang=fa`, `dir=rtl`, flat design, palette from the logo.
## Stack
- Next.js 15 App Router + React 19 + TypeScript, no Tailwind — all styles in `src/app/globals.css`
- `output: "standalone"` in `next.config.ts` (**required** for the Meshkee websites VM; deploy drops `node_modules`)
- `npm run build` runs `prebuild` → `scripts/generate-sitemap-config.mjs` (scans `src/app` for static pages)
- `npm run lint` = `tsc --noEmit`
- Font: IranYekan (local, `public/fonts/`, weights 300/400/500/700) via `next/font/local` → `--font-yekan`
## Design language (flat)
- Palette: bg `#ffffff`, surface `#f4f3f1`, text `#111111`, muted `#6b6b6b`, hairline `#e2e0dc`, accent red `#e3002b` (the logo dot)
- Flat only: no shadows, no border-radius, 1px hairlines, solid fills. Red appears as a small square “dot” (eyebrows, hero title) and on hover.
- Latin labels use `.latin` (uppercase, letter-spaced, LTR). One dark section (`.section--dark`) for the process.
- Header is sticky, white, hairline bottom; mobile burger menu under 900px.
- Logo: `public/images/logo.png` (cropped from `assets/logo/logo.jpeg`); favicon `src/app/icon.png`
## Routes
| Route | Purpose |
| --- | --- |
| `/` | Hero (title + CMS hero image), intro, 4 services, 6 selected projects, studio block, 4-step process (dark), stats, CTA |
| `/about` | Page hero image, story (3 paragraphs from `business-info.about` or fallback), 3-image gallery, values, services list, stats, CTA |
| `/contact` | Facts (phones, email, Instagram, address), contact image, form → `POST /api/contact` |
| `/api/contact` | Proxies to `POST /tenants/{domain}/contact-submissions` (`title`, `name`, `text` required; `email`, `cellNumber` optional) |
| `/meshkee/static-image-slots` | Slot catalog (`STATIC_IMAGE_SLOTS`) for the dashboard “Refresh slots” |
| `/meshkee/sitemap-config.json` | Static pages for the Meshkee-generated sitemap |
No `/login`, `/register`, `/cart`, `/checkout`, no local `sitemap.xml` / `robots.txt` (nginx proxies those to the API).
## Meshkee integration (`src/lib/`)
- `config.ts` — domain / API base / portal URLs
- `meshkee.ts` — `meshkeeGet` (ISR 300s, returns fallback on any error), `getBusinessInfo`, `getFavicon`
- `static-images.ts` — slot catalog + `getPageSlots(pageKey)` (parses `{ items: [{ key, aspectRatio, images[] }] }`), `slotImages`, `slotRatio`
- `content.ts` — resolves name / logo / phones / emails / address / Instagram / about from `business-info` with fallbacks from `src/data/site.ts`; `parseHeadTag`, `stripScripts`, digit helpers
- Root layout: `metaTags` → `<CmsMeta>` in `<head>`; `schema.jsonLd` → `<JsonLd>` when `schema.enabled`; `trustBadges[].embedHtml` → footer (scripts stripped)
- Favicon: `GET /website/favicon` → `metadata.icons`; falls back to `src/app/icon.png`
### Static image slots
Fetch per page: `GET /tenants/{domain}/website/static-images?pageKey=…`. Empty/fail → local images from `gallery` in `src/data/site.ts`.
`url` → `<img>`, `linkUrl` → wrap in `<a>`, `titleFa`/`titleEn`/`subtext` → captions where the UI shows them.
Use the API `aspectRatio` first, catalog second. **If a frame’s size or ratio changes in development, update `STATIC_IMAGE_SLOTS` in the same change.**
| key | page | kind | itemCount | aspectRatio | width | UI |
| --- | --- | --- | --- | --- | --- | --- |
| `hero` | home | single | 1 | `16:9` | 1920 | Hero frame; `titleFa` shown in the caption row |
| `projects` | home | list | null | `4:5` | 1200 | Selected projects grid; `titleFa` = title, `titleEn` = Latin subtitle, `subtext` = meta (e.g. category · year). When empty → 6 local `projects` |
| `studio-image` | home | single | 1 | `4:3` | 1400 | “نگاه ما” block image |
| `about-hero` | about | single | 1 | `16:9` | 1920 | About page hero |
| `about-gallery` | about | list | 3 | `1:1` | 900 | 3-up square gallery |
| `contact-image` | contact | single | 1 | `4:5` | 1200 | Image under the contact facts |
## Local content / fallbacks (`src/data/site.ts`)
- `site` — names (`استودیو معماری هوم` / `HOM arch studio`), tagline, description, placeholder phone `۰۹۱۲ ۰۰۰ ۰۰۰۰`, email `info@homgroup.com`, Instagram `@hom.archstudio`, address `تهران`, 3 about paragraphs. **Replace placeholders once the business profile exists in Meshkee** (they are overridden automatically by `business-info` when present).
- `services` (4), `processSteps` (4), `stats` (4), `projects` (6), `gallery` (image paths), `NAV_LINKS`
- Images: `public/images/*.jpg` — resized (≤1800px, q82) from `assets/images/` (originals kept out of git). Mapping: `facade-sunset*` ← IMG_1455/1456/1458, `courtyard-brick*` ← IMG_0356/0357, `kitchen-cream*` ← IMG_0374/0375, `study-terracotta*` ← IMG_0376/0377, `kitchen-oak*` ← IMG_0394/0396, `facade-louver` ← IMG_0400, `garden-gate` ← IMG_1037
## SEO
- Unique `<title>` / description per page (`metadata` in each `page.tsx`; template `%s | {name}`)
- One `<h1>` per page; content images have `alt`
- Static pages published through `/meshkee/sitemap-config.json`; do not add `app/sitemap.ts` / `app/robots.ts`
## Components (`src/components/`)
`Header` (client; sticky, burger), `Footer`, `CmsMeta` (+ `JsonLd`), `SlotImage` (flat frame for slot/fallback images), `ContactForm` (client; posts to `/api/contact`, Persian status messages)
## Deploy
Standard Meshkee websites VM flow: push → Super Admin **Deploy**. Deploy log must say `standalone build detected`; PM2 script `.next/standalone/server.js`.
## Changelog
- 2026-10-11 — Initial build: home / about / contact, Meshkee business-info + static-image slots + contact form, standalone runtime, sitemap-config generator.
+28
View File
@@ -0,0 +1,28 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Leaner production runtime: deploy uses .next/standalone and drops node_modules.
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.meshkee.com",
},
{
protocol: "https",
hostname: "c804387.parspack.net",
},
{
protocol: "https",
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
},
{
protocol: "https",
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
},
],
},
};
export default nextConfig;
+1003
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "hom-arch-studio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"sitemap-config": "node scripts/generate-sitemap-config.mjs",
"prebuild": "node scripts/generate-sitemap-config.mjs",
"build": "next build",
"start": "next start",
"lint": "tsc --noEmit"
},
"dependencies": {
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.15.21",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"typescript": "^5.8.3"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

+36
View File
@@ -0,0 +1,36 @@
// Scans src/app for public static pages and writes src/lib/sitemap-config.generated.json.
// Runs on `prebuild`; served by GET /meshkee/sitemap-config.json.
import { readdirSync, statSync, writeFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("..", import.meta.url));
const appDir = join(root, "src", "app");
const domain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.com";
const EXCLUDE = /^(api|meshkee|login|register|cart|checkout|account|admin)(\/|$)/;
const PRIORITY = { "/": 1.0, "/about": 0.6, "/contact": 0.5 };
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
else if (entry === "page.tsx" || entry === "page.ts") out.push(full);
}
return out;
}
const staticPages = walk(appDir)
.map((file) => relative(appDir, file).split(sep).slice(0, -1).join("/"))
.filter((route) => !route.includes("[") && !EXCLUDE.test(route))
.map((route) => `/${route}`.replace(/\/+/g, "/"))
.sort((a, b) => (a === "/" ? -1 : b === "/" ? 1 : a.localeCompare(b)))
.map((path) => ({
path,
...(path === "/" ? { changefreq: "weekly" } : {}),
priority: PRIORITY[path] ?? 0.5,
}));
const config = { baseUrl: `https://${domain}`, staticPages };
writeFileSync(join(root, "src", "lib", "sitemap-config.generated.json"), `${JSON.stringify(config, null, 2)}\n`);
console.log(`sitemap-config: ${staticPages.map((page) => page.path).join(", ")}`);
+143
View File
@@ -0,0 +1,143 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SlotImage } from "@/components/SlotImage";
import { gallery, services, site, stats } from "@/data/site";
import { resolveAbout, resolveName } from "@/lib/content";
import { getBusinessInfo } from "@/lib/meshkee";
import { getPageSlots, slotImages, slotRatio } from "@/lib/static-images";
export const metadata: Metadata = {
title: "درباره ما",
description:
"با استودیو معماری هوم آشنا شوید؛ گروهی از معماران و طراحان داخلی که با تمرکز بر سادگی، تناسب و کیفیت اجرا، فضاهای مسکونی و تجاری را طراحی و اجرا می‌کنند.",
};
const values = [
{
title: "سادگی",
text: "حذف اضافات تا آنچه می‌ماند، ضروری و خوانا باشد. سادگی برای ما نتیجه دقت است، نه کم‌کاری.",
},
{
title: "تناسب",
text: "هر طرح در نسبت با زمین، نور، بودجه و شیوه زندگی کارفرما شکل می‌گیرد؛ نه بر اساس یک الگوی ثابت.",
},
{
title: "اجرای دقیق",
text: "طرح فقط روی کاغذ معنا ندارد. جزئیات اجرایی و نظارت مستمر، تضمین می‌کند آنچه ساخته می‌شود همان طرح باشد.",
},
];
export default async function AboutPage() {
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("about")]);
const name = resolveName(info);
const about = resolveAbout(info);
const hero = slotImages("about-hero", slots, [{ url: gallery.aboutHero, titleFa: "خانه حیاط‌دار" }])[0];
const images = slotImages(
"about-gallery",
slots,
gallery.aboutGallery.map((url, index) => ({ url, titleFa: `نمونه کار ${index + 1}` })),
);
return (
<>
<section className="page-hero">
<div className="container">
<p className="eyebrow">درباره ما</p>
<h1>{name}؛ گروه معماری و طراحی داخلی</h1>
</div>
</section>
<div className="container">
<SlotImage image={hero} alt={`نمونه پروژه ${name}`} ratio={slotRatio("about-hero", slots)} className="page-hero__frame" priority />
</div>
<section className="section">
<div className="container split">
<div>
<p className="eyebrow">داستان ما</p>
<h2 className="title">معماری از گوش دادن شروع می‌شود</h2>
</div>
<div className="about-text">
{about.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
</div>
</section>
<section className="section--tight">
<div className="container about-gallery">
{images.slice(0, 3).map((image, index) => (
<SlotImage key={`${image.url}-${index}`} image={image} alt={`نمونه کار ${name}`} ratio={slotRatio("about-gallery", slots)} />
))}
</div>
</section>
<section className="section section--surface">
<div className="container">
<p className="eyebrow">ارزش‌ها</p>
<h2 className="title">آنچه در هر پروژه به آن پایبندیم</h2>
<div className="values">
{values.map((value) => (
<div key={value.title} className="value">
<h3>{value.title}</h3>
<p>{value.text}</p>
</div>
))}
</div>
</div>
</section>
<section className="section">
<div className="container split">
<div>
<p className="eyebrow">خدمات</p>
<h2 className="title">از نما تا جزئیات داخلی</h2>
</div>
<div className="about-text">
<p>
خدمات {site.shortFa} چهار حوزه اصلی را پوشش می‌دهد و در هر پروژه، بسته به نیاز شما، یک یا چند مورد از آن‌ها
کنار هم قرار می‌گیرند.
</p>
<ul style={{ margin: 0, paddingInlineStart: 20, display: "grid", gap: 10 }}>
{services.map((service) => (
<li key={service.index}>
<strong style={{ color: "var(--fg)", fontWeight: 500 }}>{service.titleFa}</strong> — {service.text}
</li>
))}
</ul>
</div>
</div>
</section>
<section className="section--tight">
<div className="container">
<div className="stats">
{stats.map((stat) => (
<div key={stat.label} className="stat">
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
))}
</div>
</div>
</section>
<section className="section">
<div className="container cta">
<div>
<p className="eyebrow">همکاری</p>
<h2>آماده‌ایم درباره پروژه شما صحبت کنیم.</h2>
</div>
<Link href="/contact" className="btn">
تماس با ما
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M4 12h16M13 5l7 7-7 7" />
</svg>
</Link>
</div>
</section>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
type Body = {
title?: string;
name?: string;
email?: string;
cellNumber?: string;
text?: string;
};
/** Proxies the contact form to Meshkee `POST /tenants/{domain}/contact-submissions`. */
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as Body | null;
if (!body?.title?.trim() || !body.name?.trim() || !body.text?.trim()) {
return NextResponse.json({ message: "نام، موضوع و متن پیام الزامی است." }, { status: 400 });
}
try {
const response = await fetch(
`${meshkeeApiBase}/tenants/${encodeURIComponent(websiteDomain)}/contact-submissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: body.title.trim(),
name: body.name.trim(),
email: body.email?.trim() || undefined,
cellNumber: body.cellNumber?.trim() || undefined,
text: body.text.trim(),
}),
cache: "no-store",
},
);
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
} catch {
return NextResponse.json({ message: "ارتباط با سرور برقرار نشد. دوباره تلاش کنید." }, { status: 502 });
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import { ContactForm } from "@/components/ContactForm";
import { SlotImage } from "@/components/SlotImage";
import { gallery } from "@/data/site";
import {
resolveAddress,
resolveEmails,
resolveInstagram,
resolveName,
resolvePhones,
} from "@/lib/content";
import { getBusinessInfo } from "@/lib/meshkee";
import { getPageSlots, slotImages, slotRatio } from "@/lib/static-images";
export const metadata: Metadata = {
title: "تماس با ما",
description:
"برای مشاوره طراحی معماری، طراحی داخلی یا طراحی نما با استودیو معماری هوم تماس بگیرید یا فرم درخواست پروژه را ارسال کنید.",
};
export default async function ContactPage() {
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("contact")]);
const name = resolveName(info);
const phones = resolvePhones(info);
const emails = resolveEmails(info);
const instagram = resolveInstagram(info);
const address = resolveAddress(info);
const image = slotImages("contact-image", slots, [{ url: gallery.contact, titleFa: "آشپزخانه کرم" }])[0];
return (
<>
<section className="page-hero">
<div className="container">
<p className="eyebrow">تماس با ما</p>
<h1>درباره پروژه‌تان صحبت کنیم</h1>
</div>
</section>
<section className="section--tight">
<div className="container contact-grid">
<div className="contact-facts">
<p className="lead">
پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس می‌گیرد.
</p>
<div className="fact fact--phones">
<span>تلفن</span>
{phones.map((phone) => (
<a key={phone.href} href={phone.href}>
{phone.display}
</a>
))}
</div>
<div className="fact">
<span>ایمیل</span>
{emails.map((email) => (
<a key={email} href={`mailto:${email}`} dir="ltr" style={{ textAlign: "right" }}>
{email}
</a>
))}
</div>
<div className="fact">
<span>اینستاگرام</span>
<a href={instagram.href} target="_blank" rel="noreferrer" dir="ltr" style={{ textAlign: "right" }}>
{instagram.handle}
</a>
</div>
<div className="fact">
<span>آدرس</span>
<strong>{address}</strong>
</div>
<SlotImage image={image} alt={`نمونه پروژه ${name}`} ratio={slotRatio("contact-image", slots)} className="contact-frame" />
</div>
<div>
<h2 className="title" style={{ marginBottom: 32 }}>
فرم درخواست پروژه
</h2>
<ContactForm />
</div>
</div>
</section>
</>
);
}
+1038
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { CmsMeta, JsonLd } from "@/components/CmsMeta";
import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header";
import { site } from "@/data/site";
import { siteUrl } from "@/lib/config";
import {
resolveAddress,
resolveEmails,
resolveInstagram,
resolveLogo,
resolveName,
resolvePhones,
} from "@/lib/content";
import { getBusinessInfo, getFavicon } from "@/lib/meshkee";
import "./globals.css";
const yekan = localFont({
src: [
{ path: "../../public/fonts/iran-yekan-light.ttf", weight: "300", style: "normal" },
{ path: "../../public/fonts/iran-yekan-regular.ttf", weight: "400", style: "normal" },
{ path: "../../public/fonts/iran-yekan-medium.ttf", weight: "500", style: "normal" },
{ path: "../../public/fonts/iran-yekan-bold.ttf", weight: "700", style: "normal" },
],
variable: "--font-yekan",
display: "swap",
});
export async function generateMetadata(): Promise<Metadata> {
const [favicon, info] = await Promise.all([getFavicon(), getBusinessInfo()]);
const name = resolveName(info);
const icons = favicon.faviconUrl ? [{ url: favicon.faviconUrl, type: "image/png" }] : undefined;
return {
metadataBase: new URL(siteUrl),
title: {
default: `${name} | ${site.tagline}`,
template: `%s | ${name}`,
},
description: site.description,
icons,
openGraph: {
type: "website",
locale: "fa_IR",
siteName: name,
title: `${name} | ${site.tagline}`,
description: site.description,
images: [{ url: "/images/facade-sunset.jpg", alt: name }],
},
};
}
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const info = await getBusinessInfo();
const name = resolveName(info);
const logo = resolveLogo(info);
const phones = resolvePhones(info);
const instagram = resolveInstagram(info);
const jsonLd = info.schema?.enabled ? info.schema.jsonLd : null;
return (
<html lang="fa" dir="rtl" className={yekan.variable}>
<head>
<CmsMeta tags={info.metaTags ?? []} />
</head>
<body>
<JsonLd data={jsonLd} />
<Header logo={logo} name={name} phone={phones[0] ?? null} instagram={instagram} />
<main>{children}</main>
<Footer
logo={logo}
name={name}
address={resolveAddress(info)}
phones={phones}
emails={resolveEmails(info)}
instagram={instagram}
badges={info.trustBadges ?? []}
/>
</body>
</html>
);
}
@@ -0,0 +1,18 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export function OPTIONS() {
return new Response(null, { status: 204, headers });
}
/** Static pages for the Meshkee-generated sitemap (`/sitemap.xml` is proxied by nginx). */
export function GET() {
return Response.json(sitemapConfig, { headers });
}
@@ -0,0 +1,17 @@
import { STATIC_IMAGE_SLOTS } from "@/lib/static-images";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
};
export function OPTIONS() {
return new Response(null, { status: 204, headers });
}
/** Slot catalog read by the Meshkee dashboard “Refresh slots” button. */
export function GET() {
return Response.json({ slots: STATIC_IMAGE_SLOTS }, { headers });
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "صفحه پیدا نشد",
description: "صفحه‌ای که دنبال آن بودید وجود ندارد.",
robots: { index: false },
};
export default function NotFound() {
return (
<section className="container notfound">
<p className="eyebrow" style={{ justifyContent: "center" }}>
۴۰۴
</p>
<h1>صفحه مورد نظر پیدا نشد</h1>
<p className="lead" style={{ marginInline: "auto" }}>
احتمالاً آدرس اشتباه است یا این صفحه جابه‌جا شده.
</p>
<div>
<Link href="/" className="btn">
بازگشت به صفحه اصلی
</Link>
</div>
</section>
);
}
+215
View File
@@ -0,0 +1,215 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SlotImage } from "@/components/SlotImage";
import { gallery, processSteps, projects, services, site, stats } from "@/data/site";
import { resolveAbout, resolveName } from "@/lib/content";
import { getBusinessInfo } from "@/lib/meshkee";
import { getPageSlots, slotImages, slotRatio } from "@/lib/static-images";
export const metadata: Metadata = {
description:
"استودیو معماری هوم؛ طراحی معماری، طراحی داخلی، طراحی نما و اجرای پروژه‌های مسکونی و تجاری با نگاهی مینیمال و دقیق.",
};
const ArrowIcon = () => (
<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>
);
export default async function HomePage() {
const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]);
const name = resolveName(info);
const about = resolveAbout(info);
const hero = slotImages("hero", slots, [{ url: gallery.hero, titleFa: "نمای آجری در غروب" }])[0];
const studio = slotImages("studio-image", slots, [{ url: gallery.studio, titleFa: "ورودی سبز" }])[0];
const projectSlots = slots.projects?.images ?? [];
return (
<>
{/* Hero */}
<section className="hero">
<div className="container">
<div className="hero__grid">
<h1 className="hero__title">
معماری ساده،
<br />
<em>زندگی روشن</em>
<span className="hero__dot" aria-hidden="true" />
</h1>
<div className="hero__aside">
<p>
{name} یک گروه معماری و طراحی داخلی است؛ از طراحی نما و پلان تا جزئیات داخلی و اجرای نهایی.
</p>
<Link href="/contact" className="btn">
شروع یک پروژه
<ArrowIcon />
</Link>
</div>
</div>
<SlotImage image={hero} alt="نمونه پروژه استودیو معماری هوم" ratio={slotRatio("hero", slots)} className="hero__frame" priority />
<div className="hero__caption">
<span>{hero.titleFa || hero.titleEn || "پروژه منتخب"}</span>
<span className="latin">{site.nameEn}</span>
</div>
</div>
</section>
{/* Intro */}
<section className="section">
<div className="container split">
<div>
<p className="eyebrow">استودیو</p>
<h2 className="intro__text">{about[0]}</h2>
</div>
<div className="intro__meta">
<p>{about[1] ?? site.about[1]}</p>
<div>
<strong>تمرکز ما</strong>
<span>معماری مسکونی، طراحی داخلی، طراحی نما</span>
</div>
<Link href="/about" className="text-link">
بیشتر درباره هوم
</Link>
</div>
</div>
</section>
{/* Services */}
<section className="section section--surface">
<div className="container">
<div className="section-head">
<div>
<p className="eyebrow">خدمات</p>
<h2 className="title">چه کاری انجام می‌دهیم</h2>
</div>
</div>
<div className="services">
{services.map((service) => (
<article key={service.index} className="service">
<span className="service__index">{service.index}</span>
<div>
<h3>{service.titleFa}</h3>
<span className="latin">{service.titleEn}</span>
</div>
<p>{service.text}</p>
</article>
))}
</div>
</div>
</section>
{/* Projects */}
<section className="section">
<div className="container">
<div className="section-head">
<div>
<p className="eyebrow">پروژه‌ها</p>
<h2 className="title">پروژه‌های منتخب</h2>
</div>
<p className="lead" style={{ maxWidth: "34ch" }}>
گزیده‌ای از کارهای اخیر در معماری، طراحی داخلی و نما.
</p>
</div>
<div className="projects">
{projectSlots.length
? projectSlots.map((image, index) => (
<div key={`${image.url}-${index}`} className="project">
<SlotImage image={image} alt={image.titleFa || `پروژه ${index + 1}`} ratio={slotRatio("projects", slots)} />
<div className="project__meta">
<h3>{image.titleFa || image.titleEn || `پروژه ${index + 1}`}</h3>
{image.subtext ? <span>{image.subtext}</span> : null}
</div>
{image.titleFa && image.titleEn ? <p className="project__sub latin">{image.titleEn}</p> : null}
</div>
))
: projects.map((project) => (
<article key={project.slug} className="project">
<div className="frame" style={{ aspectRatio: slotRatio("projects", slots) }}>
<img src={project.image} alt={`${project.titleFa} — ${project.category}`} loading="lazy" decoding="async" />
</div>
<div className="project__meta">
<h3>{project.titleFa}</h3>
<span>
{project.category} · {project.year}
</span>
</div>
<p className="project__sub latin">{project.titleEn}</p>
</article>
))}
</div>
</div>
</section>
{/* Studio */}
<section className="section section--line-top">
<div className="container split split--equal">
<SlotImage image={studio} alt="ورودی یکی از پروژه‌های استودیو هوم" ratio={slotRatio("studio-image", slots)} className="studio__frame" />
<div className="studio__body">
<div>
<p className="eyebrow">نگاه ما</p>
<h2 className="title">فضایی که با شما زندگی می‌کند</h2>
</div>
<p>
ما طراحی را از نیاز واقعی آغاز می‌کنیم؛ نه از فرم. نور، مسیر حرکت، متریال و تناسب، ابزار ما برای ساختن فضایی هستند که
سال‌ها بعد هم آرام و خوانا بماند.
</p>
<p>{about[2] ?? site.about[2]}</p>
<Link href="/about" className="btn btn--ghost">
درباره استودیو
<ArrowIcon />
</Link>
</div>
</div>
</section>
{/* Process */}
<section className="section section--dark">
<div className="container">
<p className="eyebrow eyebrow--light">فرآیند کار</p>
<h2 className="title">از ایده تا تحویل، در چهار گام</h2>
<div className="process">
{processSteps.map((item) => (
<div key={item.step} className="process__item">
<span className="process__step">گام {item.step}</span>
<h3>{item.titleFa}</h3>
<p>{item.text}</p>
</div>
))}
</div>
</div>
</section>
{/* Stats */}
<section className="section--tight">
<div className="container">
<div className="stats">
{stats.map((stat) => (
<div key={stat.label} className="stat">
<strong>{stat.value}</strong>
<span>{stat.label}</span>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="section">
<div className="container cta">
<div>
<p className="eyebrow">شروع همکاری</p>
<h2>پروژه‌ای در ذهن دارید؟ درباره‌اش صحبت کنیم.</h2>
</div>
<Link href="/contact" className="btn">
تماس با ما
<ArrowIcon />
</Link>
</div>
</section>
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { parseHeadTag } from "@/lib/content";
export type CmsMetaTag = {
html?: string;
name?: string;
content?: string;
property?: string;
httpEquiv?: string;
};
/** Site-wide meta tags from Website → Tags & Badges (`business-info.metaTags`). */
export function CmsMeta({ tags }: { tags: CmsMetaTag[] }) {
return (
<>
{tags.map((tag, index) => {
if (tag.html) {
const parsed = parseHeadTag(tag.html);
if (!parsed) return null;
if (parsed.tag === "link") return <link key={index} {...parsed.attrs} />;
return <meta key={index} {...parsed.attrs} />;
}
if (tag.name && tag.content != null) {
return <meta key={index} name={tag.name} content={tag.content} />;
}
if (tag.property && tag.content != null) {
return <meta key={index} property={tag.property} content={tag.content} />;
}
if (tag.httpEquiv && tag.content != null) {
return <meta key={index} httpEquiv={tag.httpEquiv} content={tag.content} />;
}
return null;
})}
</>
);
}
export function JsonLd({ data }: { data: Record<string, unknown> | null | undefined }) {
if (!data) return null;
return (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useState, type FormEvent } from "react";
type Status = { tone: "idle" | "pending" | "success" | "error"; message: string };
export function ContactForm() {
const [status, setStatus] = useState<Status>({ tone: "idle", message: "" });
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const payload = {
name: String(data.get("name") ?? "").trim(),
cellNumber: String(data.get("cellNumber") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
title: String(data.get("title") ?? "").trim(),
text: String(data.get("text") ?? "").trim(),
};
if (!payload.name || !payload.title || !payload.text) {
setStatus({ tone: "error", message: "لطفاً نام، موضوع و متن پیام را کامل کنید." });
return;
}
setStatus({ tone: "pending", message: "در حال ارسال…" });
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { message?: string | string[] };
const message = Array.isArray(body.message) ? body.message.join("، ") : body.message;
// Only surface server messages that are already localized; otherwise use a generic one.
throw new Error(message && /[\u0600-\u06FF]/.test(message) ? message : "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید.");
}
form.reset();
setStatus({ tone: "success", message: "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم." });
} catch (error) {
setStatus({
tone: "error",
message: error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید.",
});
}
}
return (
<form className="form" onSubmit={onSubmit} noValidate>
<div className="form__row">
<div className="field">
<label htmlFor="contact-name">نام و نام خانوادگی *</label>
<input id="contact-name" name="name" type="text" autoComplete="name" required />
</div>
<div className="field">
<label htmlFor="contact-cell">شماره تماس</label>
<input
id="contact-cell"
name="cellNumber"
type="tel"
inputMode="tel"
autoComplete="tel"
dir="ltr"
placeholder="0912 000 0000"
/>
</div>
</div>
<div className="form__row">
<div className="field">
<label htmlFor="contact-email">ایمیل</label>
<input id="contact-email" name="email" type="email" autoComplete="email" dir="ltr" />
</div>
<div className="field">
<label htmlFor="contact-title">موضوع *</label>
<input id="contact-title" name="title" type="text" required placeholder="مثلاً طراحی داخلی واحد مسکونی" />
</div>
</div>
<div className="field">
<label htmlFor="contact-text">توضیحات پروژه *</label>
<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>
);
}
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link";
import { NAV_LINKS, site } from "@/data/site";
import { stripScripts, toFaDigits, type PhoneLink } from "@/lib/content";
type Props = {
logo: string;
name: string;
address: string;
phones: PhoneLink[];
emails: string[];
instagram: { href: string; handle: string };
badges: { kind?: string; embedHtml?: string }[];
};
export function Footer({ logo, name, address, phones, emails, instagram, badges }: Props) {
const year = toFaDigits(new Date().toLocaleDateString("fa-IR-u-nu-latn", { year: "numeric" }));
const safeBadges = badges.filter((badge) => badge.embedHtml?.trim());
return (
<footer className="footer">
<div className="container">
<div className="footer__grid">
<div className="footer__brand">
<img src={logo} alt={name} width={172} height={66} />
<p>{site.description}</p>
</div>
<div>
<h3>صفحات</h3>
<ul>
{NAV_LINKS.map((link) => (
<li key={link.href}>
<Link href={link.href}>{link.label}</Link>
</li>
))}
</ul>
</div>
<div>
<h3>خدمات</h3>
<ul>
<li>طراحی معماری</li>
<li>طراحی داخلی</li>
<li>طراحی نما</li>
<li>نظارت و اجرا</li>
</ul>
</div>
<div>
<h3>تماس</h3>
<ul className="footer__contact">
<li>{address}</li>
{phones.map((phone) => (
<li key={phone.href}>
<a href={phone.href}>{phone.display}</a>
</li>
))}
{emails.map((email) => (
<li key={email}>
<a href={`mailto:${email}`}>{email}</a>
</li>
))}
<li>
<a href={instagram.href} target="_blank" rel="noreferrer">
اینستاگرام {instagram.handle}
</a>
</li>
</ul>
</div>
</div>
{safeBadges.length ? (
<div className="footer__badges">
{safeBadges.map((badge, index) => (
<div
key={`${badge.kind ?? "badge"}-${index}`}
dangerouslySetInnerHTML={{ __html: stripScripts(badge.embedHtml ?? "") }}
/>
))}
</div>
) : null}
<div className="footer__bottom">
<span>
© {year} {name}. تمامی حقوق محفوظ است.
</span>
<span className="latin">{site.nameEn}</span>
</div>
</div>
</footer>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { NAV_LINKS } from "@/data/site";
import type { PhoneLink } from "@/lib/content";
type Props = {
logo: string;
name: string;
phone: PhoneLink | null;
instagram: { href: string; handle: string };
};
export function Header({ logo, name, phone, instagram }: Props) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [pathname]);
const isActive = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
return (
<header className="header">
<div className="container header__inner">
<Link href="/" className="header__logo" aria-label={name}>
<img src={logo} alt={name} width={172} height={66} />
</Link>
<nav className="nav" aria-label="منوی اصلی">
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}>
{link.label}
</Link>
))}
</nav>
<div className="header__tools">
{phone ? (
<a className="header__phone" href={phone.href}>
{phone.display}
</a>
) : null}
<button
type="button"
className="header__burger"
aria-label={open ? "بستن منو" : "باز کردن منو"}
aria-expanded={open}
aria-controls="mobile-nav"
onClick={() => setOpen((value) => !value)}
>
<span />
</button>
</div>
</div>
<div id="mobile-nav" className="mobile-nav" data-open={open}>
{NAV_LINKS.map((link) => (
<Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}>
{link.label}
</Link>
))}
<div className="mobile-nav__meta">
{phone ? <a href={phone.href}>{phone.display}</a> : null}
<a href={instagram.href} target="_blank" rel="noreferrer">
{instagram.handle}
</a>
</div>
</div>
</header>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { CSSProperties } from "react";
import type { StaticImage } from "@/lib/static-images";
type Props = {
image: StaticImage;
alt: string;
ratio?: string;
className?: string;
priority?: boolean;
sizes?: string;
};
/**
* Flat image frame fed by a Meshkee static-image slot (or a local fallback).
* `linkUrl` wraps the frame in an anchor; `ratio` is a CSS aspect-ratio value.
*/
export function SlotImage({ image, alt, ratio, className, priority }: Props) {
const style: CSSProperties | undefined = ratio ? { aspectRatio: ratio } : undefined;
const frame = (
<div className={`frame ${className ?? ""}`.trim()} style={style}>
<img
src={image.url ?? ""}
alt={image.titleFa || image.titleEn || alt}
loading={priority ? "eager" : "lazy"}
decoding="async"
fetchPriority={priority ? "high" : undefined}
/>
</div>
);
if (image.linkUrl) {
const external = /^https?:\/\//.test(image.linkUrl);
return (
<a href={image.linkUrl} target={external ? "_blank" : undefined} rel={external ? "noreferrer" : undefined}>
{frame}
</a>
);
}
return frame;
}
+168
View File
@@ -0,0 +1,168 @@
export type Project = {
slug: string;
titleFa: string;
titleEn: string;
category: string;
location: string;
year: string;
image: string;
/** CSS aspect-ratio used in the homepage grid */
ratio: "4:5" | "1:1" | "5:4";
};
export const NAV_LINKS = [
{ href: "/", label: "خانه" },
{ href: "/about", label: "درباره ما" },
{ href: "/contact", label: "تماس با ما" },
];
export const site = {
nameFa: "استودیو معماری هوم",
nameEn: "HOM arch studio",
shortFa: "هوم",
tagline: "معماری و طراحی داخلی",
description:
"استودیو معماری هوم؛ گروه معماری و طراحی داخلی. طراحی نما، معماری مسکونی، طراحی داخلی و اجرای پروژه‌های ساختمانی با نگاهی مینیمال، دقیق و کاربردی.",
logo: "/images/logo.png",
address: "تهران",
email: "info@homgroup.com",
phones: [{ display: "۰۹۱۲ ۰۰۰ ۰۰۰۰", href: "tel:+989120000000" }],
instagram: { href: "https://instagram.com/hom.archstudio", handle: "@hom.archstudio" },
about: [
"استودیو معماری هوم یک گروه معماری و طراحی داخلی است که با تمرکز بر سادگی، تناسب و کیفیت اجرا، فضاهایی برای زندگی و کار طراحی می‌کند.",
"ما باور داریم معماری خوب از گوش دادن شروع می‌شود؛ از شناخت دقیق نیاز کارفرما، زمین و بستر پروژه. حاصل این شناخت، طرحی است که هم خوانا و آرام است و هم در جزئیات اجرایی، دقیق و پایدار.",
"از نخستین اسکیس تا تحویل نهایی، تیم طراحی و اجرای هوم در کنار شماست تا طرح روی کاغذ، همان‌طور که تصور شده ساخته شود.",
],
};
export const services = [
{
index: "01",
titleFa: "طراحی معماری",
titleEn: "Architecture",
text: "طراحی ساختمان‌های مسکونی و تجاری از مرحله ایده تا نقشه‌های اجرایی، با رعایت ضوابط و تناسب با بستر پروژه.",
},
{
index: "02",
titleFa: "طراحی داخلی",
titleEn: "Interior Design",
text: "طراحی فضاهای داخلی، چیدمان، نورپردازی و انتخاب متریال؛ آشپزخانه، نشیمن، اتاق کار و فضاهای تجاری.",
},
{
index: "03",
titleFa: "طراحی نما",
titleEn: "Facade Design",
text: "طراحی و بازطراحی نمای ساختمان با متریال‌های آجر، سنگ، چوب و فلز؛ همراه با مدل‌سازی و رندر واقع‌گرایانه.",
},
{
index: "04",
titleFa: "نظارت و اجرا",
titleEn: "Build & Supervision",
text: "مدیریت و نظارت بر اجرای پروژه تا تحویل نهایی، تا آنچه طراحی شده دقیقاً همان‌طور ساخته شود.",
},
];
export const processSteps = [
{
step: "۱",
titleFa: "شناخت",
text: "جلسه آشنایی، بازدید از محل و تعریف دقیق نیازها، بودجه و زمان‌بندی پروژه.",
},
{
step: "۲",
titleFa: "ایده و طرح اولیه",
text: "مطالعه بستر، ارائه کانسپت و گزینه‌های طراحی به همراه تصاویر سه‌بعدی.",
},
{
step: "۳",
titleFa: "طراحی تفصیلی",
text: "تهیه نقشه‌های اجرایی، جزئیات، جدول متریال و برآورد هزینه.",
},
{
step: "۴",
titleFa: "اجرا و تحویل",
text: "هماهنگی پیمانکاران، نظارت مستمر بر کیفیت اجرا و تحویل پروژه.",
},
];
export const stats = [
{ value: "۱۰+", label: "سال تجربه" },
{ value: "۸۰+", label: "پروژه طراحی‌شده" },
{ value: "۳۰+", label: "پروژه اجراشده" },
{ value: "۱۰۰٪", label: "تعهد به جزئیات" },
];
export const projects: Project[] = [
{
slug: "sunset-brick-villa",
titleFa: "ویلای آجری",
titleEn: "Brick Residence",
category: "طراحی نما",
location: "تهران",
year: "۱۴۰۳",
image: "/images/facade-sunset.jpg",
ratio: "4:5",
},
{
slug: "cream-kitchen",
titleFa: "آشپزخانه کرم",
titleEn: "Cream Kitchen",
category: "طراحی داخلی",
location: "تهران",
year: "۱۴۰۳",
image: "/images/kitchen-cream.jpg",
ratio: "5:4",
},
{
slug: "louver-facade",
titleFa: "نمای لوور",
titleEn: "Louver Facade",
category: "طراحی نما",
location: "کرج",
year: "۱۴۰۲",
image: "/images/facade-louver.jpg",
ratio: "1:1",
},
{
slug: "terracotta-study",
titleFa: "اتاق کار تراکوتا",
titleEn: "Terracotta Study",
category: "طراحی داخلی",
location: "تهران",
year: "۱۴۰۲",
image: "/images/study-terracotta.jpg",
ratio: "5:4",
},
{
slug: "courtyard-house",
titleFa: "خانه حیاط‌دار",
titleEn: "Courtyard House",
category: "معماری",
location: "شیراز",
year: "۱۴۰۲",
image: "/images/courtyard-brick.jpg",
ratio: "4:5",
},
{
slug: "oak-kitchen",
titleFa: "آشپزخانه بلوط",
titleEn: "Oak Kitchen",
category: "طراحی داخلی",
location: "تهران",
year: "۱۴۰۱",
image: "/images/kitchen-oak.jpg",
ratio: "1:1",
},
];
export const gallery = {
hero: "/images/facade-sunset.jpg",
studio: "/images/garden-gate.jpg",
aboutHero: "/images/courtyard-brick.jpg",
aboutGallery: [
"/images/kitchen-oak-2.jpg",
"/images/facade-sunset-detail.jpg",
"/images/study-terracotta-2.jpg",
],
contact: "/images/kitchen-cream-portrait.jpg",
};
+8
View File
@@ -0,0 +1,8 @@
export const websiteDomain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.com";
export const meshkeeApiBase =
process.env.NEXT_PUBLIC_MESHKEE_API_BASE || "https://api.meshkee.com/api/v1";
export const siteUrl = `https://${websiteDomain}`;
export const customerPortalUrl = `https://customer.${websiteDomain}`;
+91
View File
@@ -0,0 +1,91 @@
import { site } from "@/data/site";
import type { BusinessInfo } from "@/lib/meshkee";
const faDigits = "۰۱۲۳۴۵۶۷۸۹";
export function toLatinDigits(value: string) {
return value.replace(/[۰-۹]/g, (digit) => String(faDigits.indexOf(digit)));
}
export function toFaDigits(value: string | number) {
return String(value).replace(/\d/g, (digit) => faDigits[Number(digit)]);
}
function textOf(value: unknown) {
if (typeof value === "string") return value.trim();
if (!value || typeof value !== "object") return "";
const record = value as Record<string, unknown>;
for (const key of ["value", "number", "phone", "address", "fullAddress", "text", "email", "title"]) {
if (typeof record[key] === "string" && (record[key] as string).trim()) {
return (record[key] as string).trim();
}
}
return "";
}
export type PhoneLink = { href: string; display: string };
export function resolvePhones(info: BusinessInfo): PhoneLink[] {
const fromApi = (info.phoneNumbers ?? []).map(textOf).filter(Boolean);
if (!fromApi.length) return [...site.phones];
return fromApi.map((display) => ({
display,
href: `tel:${toLatinDigits(display).replace(/[^\d+]/g, "")}`,
}));
}
export function resolveAddress(info: BusinessInfo) {
const fromApi = (info.addresses ?? []).map(textOf).filter(Boolean);
return fromApi[0] || site.address;
}
export function resolveEmails(info: BusinessInfo) {
const fromApi = (info.emails ?? []).map(textOf).filter(Boolean);
return fromApi.length ? fromApi : [site.email];
}
export function resolveInstagram(info: BusinessInfo) {
const raw = info.socialMedia?.instagram?.trim();
if (!raw) return site.instagram;
const href = raw.startsWith("http") ? raw : `https://instagram.com/${raw.replace(/^@/, "")}`;
const handle = raw.startsWith("http")
? site.instagram.handle
: raw.startsWith("@")
? raw
: `@${raw}`;
return { href, handle };
}
export function resolveAbout(info: BusinessInfo) {
const about = info.about?.trim();
if (!about) return [...site.about];
const parts = about.split(/\n+/).map((part) => part.trim()).filter(Boolean);
return parts.length ? parts : [about];
}
export function resolveName(info: BusinessInfo) {
return info.nameFa?.trim() || site.nameFa;
}
export function resolveLogo(info: BusinessInfo) {
return info.logoUrl || site.logo;
}
export function stripScripts(html: string) {
return html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "");
}
export function parseHeadTag(html: string) {
const source = html.trim();
const match = source.match(/^<(meta|link)\s+([^>]*?)\/?\s*>$/i);
if (!match) return null;
const attrs: Record<string, string> = {};
const attrRe = /([:@\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let found = attrRe.exec(match[2]);
while (found) {
attrs[found[1]] = found[2] ?? found[3] ?? "";
found = attrRe.exec(match[2]);
}
if (!Object.keys(attrs).length) return null;
return { tag: match[1].toLowerCase() as "meta" | "link", attrs };
}
+78
View File
@@ -0,0 +1,78 @@
import { cache } from "react";
import { meshkeeApiBase, websiteDomain } from "@/lib/config";
export async function meshkeeGet<T>(path: string, fallback: T, revalidate = 300): Promise<T> {
try {
const response = await fetch(`${meshkeeApiBase}${path}`, { next: { revalidate } });
if (!response.ok) return fallback;
return (await response.json()) as T;
} catch {
return fallback;
}
}
export function tenantPath(suffix: string) {
return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`;
}
export type FaviconInfo = {
faviconUrl: string | null;
logoUrl: string | null;
logoDarkUrl: string | null;
hasDedicatedFavicon: boolean;
};
export type BusinessInfo = {
name?: string;
nameFa?: string;
about?: string;
vision?: string;
logoUrl?: string | null;
logoDarkUrl?: string | null;
emails?: unknown[];
phoneNumbers?: unknown[];
socialMedia?: {
instagram?: string;
telegram?: string;
whatsapp?: string;
youtube?: string;
aparat?: string;
linkedin?: string;
};
addresses?: unknown[];
workingHours?: unknown;
schema?: {
enabled?: boolean;
jsonLd?: Record<string, unknown> | null;
};
metaTags?: {
html?: string;
name?: string;
content?: string;
property?: string;
httpEquiv?: string;
}[];
trustBadges?: { kind?: string; embedHtml?: string }[];
};
const emptyBusinessInfo: BusinessInfo = {
metaTags: [],
trustBadges: [],
phoneNumbers: [],
emails: [],
addresses: [],
};
export const getBusinessInfo = cache(async () =>
meshkeeGet<BusinessInfo>(tenantPath("/website/business-info"), emptyBusinessInfo),
);
export const getFavicon = cache(async () =>
meshkeeGet<FaviconInfo>(tenantPath("/website/favicon"), {
faviconUrl: null,
logoUrl: null,
logoDarkUrl: null,
hasDedicatedFavicon: false,
}),
);
+18
View File
@@ -0,0 +1,18 @@
{
"baseUrl": "https://homgroup.com",
"staticPages": [
{
"path": "/",
"changefreq": "weekly",
"priority": 1
},
{
"path": "/about",
"priority": 0.6
},
{
"path": "/contact",
"priority": 0.5
}
]
}
+121
View File
@@ -0,0 +1,121 @@
import { cache } from "react";
import { meshkeeGet, tenantPath } from "@/lib/meshkee";
export type StaticImage = {
url?: string | null;
titleFa?: string | null;
titleEn?: string | null;
subtext?: string | null;
linkUrl?: string | null;
};
export type StaticImageSlot = {
key: string;
label: string;
kind: "single" | "list";
pageKey: string;
aspectRatio: string;
itemCount: number | null;
recommendedWidth: number;
};
/**
* Catalog served at `GET /meshkee/static-image-slots`.
* Keep `aspectRatio` / `recommendedWidth` in sync with the on-page frames —
* if a frame’s size or ratio changes in development, update the matching entry here.
*/
export const STATIC_IMAGE_SLOTS: StaticImageSlot[] = [
{
key: "hero",
label: "تصویر اصلی صفحه اول",
kind: "single",
pageKey: "home",
aspectRatio: "16:9",
itemCount: 1,
recommendedWidth: 1920,
},
{
key: "projects",
label: "پروژه‌های منتخب صفحه اول",
kind: "list",
pageKey: "home",
aspectRatio: "4:5",
itemCount: null,
recommendedWidth: 1200,
},
{
key: "studio-image",
label: "تصویر معرفی استودیو (صفحه اول)",
kind: "single",
pageKey: "home",
aspectRatio: "4:3",
itemCount: 1,
recommendedWidth: 1400,
},
{
key: "about-hero",
label: "تصویر اصلی درباره ما",
kind: "single",
pageKey: "about",
aspectRatio: "16:9",
itemCount: 1,
recommendedWidth: 1920,
},
{
key: "about-gallery",
label: "گالری درباره ما",
kind: "list",
pageKey: "about",
aspectRatio: "1:1",
itemCount: 3,
recommendedWidth: 900,
},
{
key: "contact-image",
label: "تصویر تماس با ما",
kind: "single",
pageKey: "contact",
aspectRatio: "4:5",
itemCount: 1,
recommendedWidth: 1200,
},
];
type StaticImagesResponse = {
items?: {
key: string;
aspectRatio?: string | null;
images?: (StaticImage & { sortOrder?: number })[];
}[];
};
export type PageSlots = Record<string, { images: StaticImage[]; aspectRatio: string | null }>;
/** `GET /tenants/{domain}/website/static-images?pageKey=…` grouped by slot key. */
export const getPageSlots = cache(async (pageKey: string): Promise<PageSlots> => {
const data = await meshkeeGet<StaticImagesResponse>(
tenantPath(`/website/static-images?pageKey=${encodeURIComponent(pageKey)}`),
{},
);
const grouped: PageSlots = {};
for (const slot of data.items ?? []) {
const images = (slot.images ?? [])
.filter((image) => image?.url)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
if (images.length) grouped[slot.key] = { images, aspectRatio: slot.aspectRatio ?? null };
}
return grouped;
});
/** CSS `aspect-ratio` for a slot: API value first, then the local catalog. */
export function slotRatio(key: string, slots?: PageSlots) {
const fromApi = slots?.[key]?.aspectRatio;
const fromCatalog = STATIC_IMAGE_SLOTS.find((entry) => entry.key === key)?.aspectRatio;
return (fromApi || fromCatalog || "4:3").replace(":", " / ");
}
/** Images for a slot, or the local fallback list when CMS has none. */
export function slotImages(key: string, slots: PageSlots, fallback: StaticImage[]) {
return slots[key]?.images?.length ? slots[key].images : fallback;
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}