Initial HOM arch studio website (home, about, contact) on Meshkee
@@ -0,0 +1,2 @@
|
|||||||
|
NEXT_PUBLIC_WEBSITE_DOMAIN=homgroup.com
|
||||||
|
NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 814 KiB |
|
After Width: | Height: | Size: 948 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 620 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 613 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 486 KiB |
|
After Width: | Height: | Size: 407 KiB |
@@ -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(", ")}`);
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 32 KiB |
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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}`;
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"baseUrl": "https://homgroup.com",
|
||||||
|
"staticPages": [
|
||||||
|
{
|
||||||
|
"path": "/",
|
||||||
|
"changefreq": "weekly",
|
||||||
|
"priority": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/about",
|
||||||
|
"priority": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/contact",
|
||||||
|
"priority": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||