commit 73971bf6d98a8929795d950e12a8cbd189022908 Author: dev Date: Sun Oct 11 22:48:58 2026 +0330 Initial HOM arch studio website (home, about, contact) on Meshkee diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0e07d5 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_WEBSITE_DOMAIN=homgroup.com +NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e151a1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..5dbc5e7 --- /dev/null +++ b/CONTEXT.md @@ -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` → `` in ``; `schema.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` → ``, `linkUrl` → wrap in ``, `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 `` / 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. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..5394c53 --- /dev/null +++ b/next.config.ts @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9f1d7e8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1003 @@ +{ + "name": "hom-arch-studio", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hom-arch-studio", + "version": "0.1.0", + "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" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz", + "integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz", + "integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz", + "integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz", + "integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz", + "integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz", + "integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz", + "integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz", + "integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz", + "integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz", + "integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.27", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.27", + "@next/swc-darwin-x64": "15.5.27", + "@next/swc-linux-arm64-gnu": "15.5.27", + "@next/swc-linux-arm64-musl": "15.5.27", + "@next/swc-linux-x64-gnu": "15.5.27", + "@next/swc-linux-x64-musl": "15.5.27", + "@next/swc-win32-arm64-msvc": "15.5.27", + "@next/swc-win32-x64-msvc": "15.5.27", + "sharp": "^0.34.3 || ^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c0925d0 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/fonts/iran-yekan-bold.ttf b/public/fonts/iran-yekan-bold.ttf new file mode 100644 index 0000000..6d98764 Binary files /dev/null and b/public/fonts/iran-yekan-bold.ttf differ diff --git a/public/fonts/iran-yekan-light.ttf b/public/fonts/iran-yekan-light.ttf new file mode 100644 index 0000000..b6114ee Binary files /dev/null and b/public/fonts/iran-yekan-light.ttf differ diff --git a/public/fonts/iran-yekan-medium.ttf b/public/fonts/iran-yekan-medium.ttf new file mode 100644 index 0000000..cc9d9b4 Binary files /dev/null and b/public/fonts/iran-yekan-medium.ttf differ diff --git a/public/fonts/iran-yekan-regular.ttf b/public/fonts/iran-yekan-regular.ttf new file mode 100644 index 0000000..c662408 Binary files /dev/null and b/public/fonts/iran-yekan-regular.ttf differ diff --git a/public/images/courtyard-brick-2.jpg b/public/images/courtyard-brick-2.jpg new file mode 100644 index 0000000..eb079e0 Binary files /dev/null and b/public/images/courtyard-brick-2.jpg differ diff --git a/public/images/courtyard-brick.jpg b/public/images/courtyard-brick.jpg new file mode 100644 index 0000000..5dc993a Binary files /dev/null and b/public/images/courtyard-brick.jpg differ diff --git a/public/images/facade-louver.jpg b/public/images/facade-louver.jpg new file mode 100644 index 0000000..095675a Binary files /dev/null and b/public/images/facade-louver.jpg differ diff --git a/public/images/facade-sunset-2.jpg b/public/images/facade-sunset-2.jpg new file mode 100644 index 0000000..1ba232c Binary files /dev/null and b/public/images/facade-sunset-2.jpg differ diff --git a/public/images/facade-sunset-detail.jpg b/public/images/facade-sunset-detail.jpg new file mode 100644 index 0000000..4927669 Binary files /dev/null and b/public/images/facade-sunset-detail.jpg differ diff --git a/public/images/facade-sunset.jpg b/public/images/facade-sunset.jpg new file mode 100644 index 0000000..888b925 Binary files /dev/null and b/public/images/facade-sunset.jpg differ diff --git a/public/images/garden-gate.jpg b/public/images/garden-gate.jpg new file mode 100644 index 0000000..78a3039 Binary files /dev/null and b/public/images/garden-gate.jpg differ diff --git a/public/images/kitchen-cream-portrait.jpg b/public/images/kitchen-cream-portrait.jpg new file mode 100644 index 0000000..5156c53 Binary files /dev/null and b/public/images/kitchen-cream-portrait.jpg differ diff --git a/public/images/kitchen-cream.jpg b/public/images/kitchen-cream.jpg new file mode 100644 index 0000000..c49c1ca Binary files /dev/null and b/public/images/kitchen-cream.jpg differ diff --git a/public/images/kitchen-oak-2.jpg b/public/images/kitchen-oak-2.jpg new file mode 100644 index 0000000..c24133a Binary files /dev/null and b/public/images/kitchen-oak-2.jpg differ diff --git a/public/images/kitchen-oak.jpg b/public/images/kitchen-oak.jpg new file mode 100644 index 0000000..aec6a6d Binary files /dev/null and b/public/images/kitchen-oak.jpg differ diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..3ab22f9 Binary files /dev/null and b/public/images/logo.png differ diff --git a/public/images/study-terracotta-2.jpg b/public/images/study-terracotta-2.jpg new file mode 100644 index 0000000..3c2eb6b Binary files /dev/null and b/public/images/study-terracotta-2.jpg differ diff --git a/public/images/study-terracotta.jpg b/public/images/study-terracotta.jpg new file mode 100644 index 0000000..96777f1 Binary files /dev/null and b/public/images/study-terracotta.jpg differ diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs new file mode 100644 index 0000000..d6570ff --- /dev/null +++ b/scripts/generate-sitemap-config.mjs @@ -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(", ")}`); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..790ffd0 --- /dev/null +++ b/src/app/about/page.tsx @@ -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> + </> + ); +} diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 0000000..c2c7ef6 --- /dev/null +++ b/src/app/api/contact/route.ts @@ -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 }); + } +} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 0000000..4ed2fdd --- /dev/null +++ b/src/app/contact/page.tsx @@ -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> + </> + ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..98de346 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,1038 @@ +:root { + --bg: #ffffff; + --surface: #f4f3f1; + --fg: #111111; + --muted: #6b6b6b; + --line: #e2e0dc; + --line-strong: #111111; + --accent: #e3002b; + --header-h: 76px; + --gutter: clamp(20px, 5vw, 72px); + --max: 1440px; + --font-sans: var(--font-yekan), "IRANYekan", "Vazirmatn", system-ui, -apple-system, "Segoe UI", Tahoma, sans-serif; + --font-latin: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.9; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +img { + display: block; + max-width: 100%; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font: inherit; + color: inherit; + background: none; + border: 0; + cursor: pointer; +} + +h1, +h2, +h3, +h4, +p { + margin: 0; +} + +h1, +h2, +h3 { + font-weight: 500; + line-height: 1.4; + letter-spacing: -0.01em; +} + +.latin { + font-family: var(--font-latin); + direction: ltr; + letter-spacing: 0.18em; + text-transform: uppercase; + font-size: 11px; + font-weight: 500; +} + +.container { + width: 100%; + max-width: var(--max); + margin-inline: auto; + padding-inline: var(--gutter); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* ---------- Header ---------- */ + +.header { + position: sticky; + top: 0; + z-index: 50; + background: var(--bg); + border-bottom: 1px solid var(--line); +} + +.header__inner { + height: var(--header-h); + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.header__logo img { + height: 30px; + width: auto; +} + +.nav { + display: flex; + align-items: center; + gap: 36px; + font-size: 15px; +} + +.nav a { + position: relative; + padding-block: 6px; + color: var(--fg); +} + +.nav a::after { + content: ""; + position: absolute; + inset-inline: 0; + bottom: 0; + height: 1px; + background: var(--fg); + transform: scaleX(0); + transform-origin: right; + transition: transform 0.25s ease; +} + +.nav a:hover::after, +.nav a[aria-current="page"]::after { + transform: scaleX(1); +} + +.header__tools { + display: flex; + align-items: center; + gap: 24px; +} + +.header__phone { + font-size: 14px; + color: var(--muted); + direction: ltr; +} + +.header__phone:hover { + color: var(--fg); +} + +.header__burger { + display: none; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; +} + +.header__burger span, +.header__burger span::before, +.header__burger span::after { + display: block; + width: 22px; + height: 1.5px; + background: var(--fg); + content: ""; + position: relative; + transition: transform 0.25s ease, opacity 0.25s ease; +} + +.header__burger span::before { + position: absolute; + top: -7px; +} + +.header__burger span::after { + position: absolute; + top: 7px; +} + +.header__burger[aria-expanded="true"] span { + background: transparent; +} + +.header__burger[aria-expanded="true"] span::before { + transform: translateY(7px) rotate(45deg); +} + +.header__burger[aria-expanded="true"] span::after { + transform: translateY(-7px) rotate(-45deg); +} + +.mobile-nav { + display: none; + border-top: 1px solid var(--line); + background: var(--bg); +} + +.mobile-nav[data-open="true"] { + display: block; +} + +.mobile-nav a { + display: block; + padding: 16px var(--gutter); + border-bottom: 1px solid var(--line); + font-size: 17px; +} + +.mobile-nav__meta { + padding: 20px var(--gutter); + color: var(--muted); + font-size: 14px; + display: flex; + flex-direction: column; + gap: 6px; +} + +@media (max-width: 900px) { + :root { + --header-h: 64px; + } + + .nav, + .header__phone { + display: none; + } + + .header__burger { + display: flex; + } +} + +/* ---------- Common blocks ---------- */ + +.section { + padding-block: clamp(64px, 9vw, 128px); +} + +.section--tight { + padding-block: clamp(40px, 6vw, 80px); +} + +.section--surface { + background: var(--surface); +} + +.section--dark { + background: var(--fg); + color: #fff; +} + +.section--line-top { + border-top: 1px solid var(--line); +} + +.eyebrow { + display: flex; + align-items: center; + gap: 12px; + color: var(--muted); + margin-bottom: 20px; +} + +.eyebrow::before { + content: ""; + width: 8px; + height: 8px; + background: var(--accent); + flex: none; +} + +.eyebrow--light { + color: rgba(255, 255, 255, 0.6); +} + +.title { + font-size: clamp(26px, 3.4vw, 44px); + font-weight: 500; + line-height: 1.45; + max-width: 22ch; +} + +.lead { + font-size: clamp(16px, 1.3vw, 19px); + color: var(--muted); + max-width: 60ch; +} + +.section--dark .lead { + color: rgba(255, 255, 255, 0.68); +} + +.split { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + gap: clamp(32px, 6vw, 96px); + align-items: start; +} + +.split--equal { + grid-template-columns: 1fr 1fr; + align-items: center; +} + +.split--reverse { + grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); +} + +@media (max-width: 900px) { + .split, + .split--equal, + .split--reverse { + grid-template-columns: 1fr; + } +} + +.btn { + display: inline-flex; + align-items: center; + gap: 14px; + padding: 14px 26px; + border: 1px solid var(--fg); + background: var(--fg); + color: #fff; + font-size: 15px; + transition: background 0.2s ease, color 0.2s ease; +} + +.btn:hover { + background: var(--accent); + border-color: var(--accent); +} + +.btn--ghost { + background: transparent; + color: var(--fg); +} + +.btn--ghost:hover { + background: var(--fg); + border-color: var(--fg); + color: #fff; +} + +.btn--light { + background: #fff; + border-color: #fff; + color: var(--fg); +} + +.btn--light:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.btn svg { + width: 16px; + height: 16px; + transform: scaleX(-1); +} + +.frame { + position: relative; + overflow: hidden; + background: var(--surface); +} + +.frame img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.text-link { + display: inline-flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--fg); + padding-bottom: 2px; + font-size: 15px; +} + +.text-link:hover { + color: var(--accent); + border-color: var(--accent); +} + +/* ---------- Hero ---------- */ + +.hero { + padding-top: clamp(48px, 7vw, 96px); +} + +.hero__grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 32px; + padding-bottom: clamp(36px, 5vw, 64px); +} + +.hero__title { + font-size: clamp(34px, 6vw, 84px); + font-weight: 500; + line-height: 1.25; + letter-spacing: -0.02em; + max-width: 14ch; +} + +.hero__title em { + font-style: normal; + color: var(--muted); +} + +.hero__dot { + display: inline-block; + width: 0.28em; + height: 0.28em; + background: var(--accent); + margin-inline-start: 0.1em; + vertical-align: baseline; +} + +.hero__aside { + display: flex; + flex-direction: column; + gap: 28px; + align-items: flex-start; + max-width: 340px; + padding-bottom: 10px; +} + +.hero__aside p { + color: var(--muted); + font-size: 15px; +} + +.hero__frame { + aspect-ratio: 16 / 9; + max-height: 82vh; +} + +.hero__caption { + display: flex; + justify-content: space-between; + gap: 24px; + padding-block: 16px; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 13px; +} + +@media (max-width: 900px) { + .hero__grid { + grid-template-columns: 1fr; + align-items: start; + } + + .hero__frame { + aspect-ratio: 4 / 3; + } +} + +/* ---------- Intro ---------- */ + +.intro__text { + font-size: clamp(20px, 2.2vw, 30px); + font-weight: 400; + line-height: 1.75; + max-width: 30ch; +} + +.intro__meta { + display: grid; + gap: 18px; + color: var(--muted); +} + +.intro__meta strong { + display: block; + color: var(--fg); + font-weight: 500; +} + +/* ---------- Services ---------- */ + +.services { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-top: 1px solid var(--line); + border-inline-start: 1px solid var(--line); +} + +.service { + padding: 32px 28px 40px; + border-inline-end: 1px solid var(--line); + border-bottom: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 18px; + min-height: 300px; + transition: background 0.2s ease; +} + +.service:hover { + background: var(--surface); +} + +.service__index { + color: var(--accent); + font-family: var(--font-latin); + font-size: 13px; + direction: ltr; + text-align: right; +} + +.service h3 { + font-size: 20px; +} + +.service .latin { + color: var(--muted); + letter-spacing: 0.14em; +} + +.service p { + color: var(--muted); + font-size: 15px; + margin-top: auto; +} + +@media (max-width: 1000px) { + .services { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 560px) { + .services { + grid-template-columns: 1fr; + } + + .service { + min-height: 0; + } +} + +/* ---------- Projects ---------- */ + +.section-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: clamp(32px, 4vw, 56px); +} + +.projects { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: clamp(16px, 2.5vw, 40px) clamp(16px, 2vw, 28px); +} + +.project { + display: block; +} + +.project .frame { + aspect-ratio: 4 / 5; +} + +.project .frame img { + transition: transform 0.6s ease; +} + +.project:hover .frame img { + transform: scale(1.03); +} + +.project__meta { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding-top: 14px; +} + +.project__meta h3 { + font-size: 17px; +} + +.project__meta span { + color: var(--muted); + font-size: 13px; +} + +.project__sub { + color: var(--muted); + font-size: 13px; +} + +@media (max-width: 900px) { + .projects { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 560px) { + .projects { + grid-template-columns: 1fr; + } +} + +/* ---------- Studio ---------- */ + +.studio__frame { + aspect-ratio: 4 / 3; +} + +.studio__body { + display: flex; + flex-direction: column; + gap: 28px; + align-items: flex-start; +} + +.studio__body p { + color: var(--muted); +} + +/* ---------- Process ---------- */ + +.process { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 40px 32px; + margin-top: clamp(32px, 4vw, 56px); +} + +.process__item { + border-top: 1px solid rgba(255, 255, 255, 0.22); + padding-top: 22px; + display: grid; + gap: 12px; +} + +.process__step { + color: var(--accent); + font-size: 14px; +} + +.process__item h3 { + font-size: 19px; + color: #fff; +} + +.process__item p { + color: rgba(255, 255, 255, 0.66); + font-size: 15px; +} + +@media (max-width: 900px) { + .process { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 560px) { + .process { + grid-template-columns: 1fr; + } +} + +/* ---------- Stats ---------- */ + +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + border-top: 1px solid var(--line); +} + +.stat { + padding: 32px 0; + border-bottom: 1px solid var(--line); + display: grid; + gap: 4px; +} + +.stat strong { + font-size: clamp(30px, 3.5vw, 46px); + font-weight: 500; + line-height: 1.2; +} + +.stat span { + color: var(--muted); + font-size: 14px; +} + +@media (max-width: 720px) { + .stats { + grid-template-columns: repeat(2, 1fr); + gap: 0 24px; + } +} + +/* ---------- CTA ---------- */ + +.cta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 32px; + flex-wrap: wrap; +} + +.cta h2 { + font-size: clamp(24px, 3vw, 40px); + max-width: 22ch; +} + +/* ---------- Page hero (about / contact) ---------- */ + +.page-hero { + padding-top: clamp(48px, 7vw, 96px); + padding-bottom: clamp(32px, 5vw, 64px); +} + +.page-hero h1 { + font-size: clamp(32px, 5vw, 64px); + font-weight: 500; + line-height: 1.3; + max-width: 16ch; +} + +.page-hero__frame { + aspect-ratio: 16 / 9; + max-height: 70vh; +} + +/* ---------- About ---------- */ + +.about-text { + display: grid; + gap: 20px; + font-size: clamp(16px, 1.3vw, 19px); + color: var(--muted); +} + +.about-text p:first-child { + color: var(--fg); + font-size: clamp(20px, 2vw, 28px); + line-height: 1.7; +} + +.about-gallery { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: clamp(12px, 2vw, 28px); +} + +.about-gallery .frame { + aspect-ratio: 1 / 1; +} + +@media (max-width: 720px) { + .about-gallery { + grid-template-columns: 1fr; + } +} + +.values { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 32px; + margin-top: clamp(32px, 4vw, 56px); +} + +.value { + border-top: 1px solid var(--line-strong); + padding-top: 20px; + display: grid; + gap: 10px; +} + +.value h3 { + font-size: 19px; +} + +.value p { + color: var(--muted); + font-size: 15px; +} + +@media (max-width: 720px) { + .values { + grid-template-columns: 1fr; + } +} + +/* ---------- Contact ---------- */ + +.contact-grid { + display: grid; + grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + gap: clamp(32px, 6vw, 96px); + align-items: start; +} + +.contact-facts { + display: grid; + gap: 28px; +} + +.fact { + border-top: 1px solid var(--line); + padding-top: 16px; + display: grid; + gap: 4px; +} + +.fact span { + color: var(--muted); + font-size: 13px; +} + +.fact a, +.fact strong { + font-size: 17px; + font-weight: 500; +} + +.fact a:hover { + color: var(--accent); +} + +.fact--phones a { + display: block; + direction: ltr; + text-align: right; +} + +.contact-frame { + aspect-ratio: 4 / 5; + max-height: 520px; +} + +.form { + display: grid; + gap: 20px; +} + +.form__row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 640px) { + .form__row { + grid-template-columns: 1fr; + } +} + +.field { + display: grid; + gap: 8px; +} + +.field label { + font-size: 14px; + color: var(--muted); +} + +.field input, +.field textarea { + width: 100%; + font: inherit; + font-size: 16px; + color: var(--fg); + background: transparent; + border: 0; + border-bottom: 1px solid var(--line-strong); + padding: 10px 0; + outline: none; + border-radius: 0; + transition: border-color 0.2s ease; +} + +.field input:focus, +.field textarea:focus { + border-color: var(--accent); +} + +.field textarea { + min-height: 140px; + resize: vertical; +} + +.form__footer { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} + +.form__status { + font-size: 14px; + color: var(--muted); +} + +.form__status[data-tone="success"] { + color: #137a3a; +} + +.form__status[data-tone="error"] { + color: var(--accent); +} + +/* ---------- Footer ---------- */ + +.footer { + border-top: 1px solid var(--line); + padding-block: clamp(40px, 6vw, 72px) 28px; +} + +.footer__grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1.4fr; + gap: 40px; +} + +.footer__brand img { + height: 30px; + width: auto; + margin-bottom: 18px; +} + +.footer__brand p { + color: var(--muted); + font-size: 14px; + max-width: 36ch; +} + +.footer h3 { + font-size: 14px; + color: var(--muted); + margin-bottom: 14px; + font-weight: 400; +} + +.footer ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; + font-size: 15px; +} + +.footer ul a:hover { + color: var(--accent); +} + +.footer__contact li { + color: var(--fg); +} + +.footer__contact a[href^="tel"] { + direction: ltr; + display: inline-block; +} + +.footer__badges { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-top: 32px; +} + +.footer__bottom { + margin-top: 48px; + padding-top: 20px; + border-top: 1px solid var(--line); + display: flex; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + color: var(--muted); + font-size: 13px; +} + +.footer__bottom .latin { + letter-spacing: 0.14em; +} + +@media (max-width: 900px) { + .footer__grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 560px) { + .footer__grid { + grid-template-columns: 1fr; + } +} + +/* ---------- Not found ---------- */ + +.notfound { + min-height: 60vh; + display: grid; + place-content: center; + text-align: center; + gap: 20px; +} + +.notfound h1 { + font-size: clamp(28px, 4vw, 48px); +} diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000..88be601 Binary files /dev/null and b/src/app/icon.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..dd6d0cd --- /dev/null +++ b/src/app/layout.tsx @@ -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> + ); +} diff --git a/src/app/meshkee/sitemap-config.json/route.ts b/src/app/meshkee/sitemap-config.json/route.ts new file mode 100644 index 0000000..f07bb62 --- /dev/null +++ b/src/app/meshkee/sitemap-config.json/route.ts @@ -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 }); +} diff --git a/src/app/meshkee/static-image-slots/route.ts b/src/app/meshkee/static-image-slots/route.ts new file mode 100644 index 0000000..62f669b --- /dev/null +++ b/src/app/meshkee/static-image-slots/route.ts @@ -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 }); +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..7bf474e --- /dev/null +++ b/src/app/not-found.tsx @@ -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> + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..7f5583e --- /dev/null +++ b/src/app/page.tsx @@ -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> + </> + ); +} diff --git a/src/components/CmsMeta.tsx b/src/components/CmsMeta.tsx new file mode 100644 index 0000000..38bc1a4 --- /dev/null +++ b/src/components/CmsMeta.tsx @@ -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) }} /> + ); +} diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx new file mode 100644 index 0000000..8cdffac --- /dev/null +++ b/src/components/ContactForm.tsx @@ -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> + ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..488489a --- /dev/null +++ b/src/components/Footer.tsx @@ -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> + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..30d3f66 --- /dev/null +++ b/src/components/Header.tsx @@ -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> + ); +} diff --git a/src/components/SlotImage.tsx b/src/components/SlotImage.tsx new file mode 100644 index 0000000..9d933a2 --- /dev/null +++ b/src/components/SlotImage.tsx @@ -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; +} diff --git a/src/data/site.ts b/src/data/site.ts new file mode 100644 index 0000000..99066d9 --- /dev/null +++ b/src/data/site.ts @@ -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", +}; diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..3989368 --- /dev/null +++ b/src/lib/config.ts @@ -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}`; diff --git a/src/lib/content.ts b/src/lib/content.ts new file mode 100644 index 0000000..2483f6a --- /dev/null +++ b/src/lib/content.ts @@ -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 }; +} diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts new file mode 100644 index 0000000..ce2a541 --- /dev/null +++ b/src/lib/meshkee.ts @@ -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, + }), +); diff --git a/src/lib/sitemap-config.generated.json b/src/lib/sitemap-config.generated.json new file mode 100644 index 0000000..815e1c2 --- /dev/null +++ b/src/lib/sitemap-config.generated.json @@ -0,0 +1,18 @@ +{ + "baseUrl": "https://homgroup.com", + "staticPages": [ + { + "path": "/", + "changefreq": "weekly", + "priority": 1 + }, + { + "path": "/about", + "priority": 0.6 + }, + { + "path": "/contact", + "priority": 0.5 + } + ] +} diff --git a/src/lib/static-images.ts b/src/lib/static-images.ts new file mode 100644 index 0000000..6421fe9 --- /dev/null +++ b/src/lib/static-images.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c133409 --- /dev/null +++ b/tsconfig.json @@ -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"] +}