diff --git a/.env.example b/.env.example index b0e07d5..271a7c7 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ -NEXT_PUBLIC_WEBSITE_DOMAIN=homgroup.com +NEXT_PUBLIC_WEBSITE_DOMAIN=homgroup.ir NEXT_PUBLIC_MESHKEE_API_BASE=https://api.meshkee.com/api/v1 +# Optional — Maps JavaScript API key. Hides POI/place icons on the contact map. +# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY= diff --git a/CONTEXT.md b/CONTEXT.md index 5dbc5e7..d97e37d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,79 +1,67 @@ # HOM arch studio — AI / Project Context -Persian (fa, RTL) brochure website for **استودیو معماری هوم** (architecture + interior design group). +Persian (fa, RTL) brochure website for **استودیو معماری هوم** (architecture + interior design). Keep this file current whenever routes, slots, data sources or design rules change. ## Source of truth -- Live domain: `https://homgroup.com` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.com`) -- Backend docs: https://api.meshkee.com/docs/website (OpenAPI: `/docs/website/openapi.json`) -- API base: `https://api.meshkee.com/api/v1` (`NEXT_PUBLIC_MESHKEE_API_BASE`) -- Tenant: `GET /tenants/homgroup.com` — **not registered yet** (404 as of 2026-10-11). Every Meshkee read has a local fallback, so the site renders fully without the tenant. -- Prompt reference: `https://api.meshkee.com/api/v1/public/ai-prompts/studio-hom/basic-create/36caa331616c4b1cd382f8cb` -- Original brief: homepage + about + contact only; `lang=fa`, `dir=rtl`, flat design, palette from the logo. +- Live domain: `https://homgroup.ir` (`NEXT_PUBLIC_WEBSITE_DOMAIN`, default `homgroup.ir`) +- Backend docs: https://api.meshkee.com/docs/website +- API base: `https://api.meshkee.com/api/v1` +- Tenant: `GET /tenants/homgroup.ir` → business id `53` +- Customer portal: `https://customer.homgroup.ir` (no storefront login pages) +- Business dashboard: `https://business.homgroup.ir` +- Placeholders prompt: `…/ai-prompts/studio-hom/placeholders/576804987514877ec206af60` ## Stack -- Next.js 15 App Router + React 19 + TypeScript, no Tailwind — all styles in `src/app/globals.css` -- `output: "standalone"` in `next.config.ts` (**required** for the Meshkee websites VM; deploy drops `node_modules`) -- `npm run build` runs `prebuild` → `scripts/generate-sitemap-config.mjs` (scans `src/app` for static pages) -- `npm run lint` = `tsc --noEmit` -- Font: IranYekan (local, `public/fonts/`, weights 300/400/500/700) via `next/font/local` → `--font-yekan` +- Next.js 15 App Router + React 19 + TypeScript; styles in `src/app/globals.css` (no Tailwind) +- `output: "standalone"` required for Meshkee websites VM +- Fonts: IranYekan (FA) + Montserrat (EN / `.latin`) -## Design language (flat) -- Palette: bg `#ffffff`, surface `#f4f3f1`, text `#111111`, muted `#6b6b6b`, hairline `#e2e0dc`, accent red `#e3002b` (the logo dot) -- Flat only: no shadows, no border-radius, 1px hairlines, solid fills. Red appears as a small square “dot” (eyebrows, hero title) and on hover. -- Latin labels use `.latin` (uppercase, letter-spaced, LTR). One dark section (`.section--dark`) for the process. -- Header is sticky, white, hairline bottom; mobile burger menu under 900px. -- Logo: `public/images/logo.png` (cropped from `assets/logo/logo.jpeg`); favicon `src/app/icon.png` +## Design +- Flat palette: bg `#fff`, surface `#f4f3f1`, fg `#111`, muted `#6b6b6b`, line `#e2e0dc`, accent `#e3002b` +- Sticky header + account menu; dark map band on contact; stats band with pattern + +## Static image slots (`STATIC_IMAGE_SLOTS` → `/meshkee/static-image-slots`) +Fetch: `GET /tenants/{domain}/website/static-images?pageKey=…`. Match by **key**. Use all images if `kind=list`, first if `single`. `url` → ``, `linkUrl` → wrap link, `titleFa` / `titleEn` / `subtext` per UI. Empty/fail → local placeholder. Prefer API `aspectRatio`. **If a frame’s size or ratio changes in development, update the matching catalog entry in the same change.** + +| key | pageKey | kind | itemCount | aspectRatio | width | UI | +| --- | --- | --- | --- | --- | --- | --- | +| `slider` | home | list | null | `16:9` | 1920 | Full-viewport `HeroSlider` | +| `four-category` | home | list | 4 | `3:4` | 900 | Four hover-expand service panels (`titleFa` / `titleEn` / `subtext`) | +| `project-images` | home | list | null | `3:4` (masonry free height) | 1200 | Selected projects masonry | +| `sticky-frame-bg` | home | single | 1 | `16:9` | 1920 | `OpeningFrame` background | +| `instagram-row` | home | single | 1 | `9:16` | 720 | `InstagramFollow` phone frame (also used on contact) | +| `brands` | home | list | null | `3:2` | 480 | Partner logos row (hidden when empty) | +| `about-us-image` | about | list | 2 | `16:9` | 1600 | About hero (1st) + secondary (2nd) | + +## Tags & badges (CMS) +From `GET /tenants/{domain}/website/business-info`: +1. `metaTags: [{ name, content }]` → root `` + `generateMetadata.other` +2. `trustBadges: [{ kind, embedHtml }]` → footer `dangerouslySetInnerHTML` (scripts stripped). Empty → omit. Do not hardcode eNamad/Samandehi. + +## Auth +Shared cookies `meshkee_customer_*`; login → customer portal; `AccountMenu` shows `{name} عزیز` + پروفایل من / خروج / مدیریت وبسایت (admin + handoff). ## Routes -| Route | Purpose | +| Route | Notes | | --- | --- | -| `/` | Hero (title + CMS hero image), intro, 4 services, 6 selected projects, studio block, 4-step process (dark), stats, CTA | -| `/about` | Page hero image, story (3 paragraphs from `business-info.about` or fallback), 3-image gallery, values, services list, stats, CTA | -| `/contact` | Facts (phones, email, Instagram, address), contact image, form → `POST /api/contact` | -| `/api/contact` | Proxies to `POST /tenants/{domain}/contact-submissions` (`title`, `name`, `text` required; `email`, `cellNumber` optional) | -| `/meshkee/static-image-slots` | Slot catalog (`STATIC_IMAGE_SLOTS`) for the dashboard “Refresh slots” | -| `/meshkee/sitemap-config.json` | Static pages for the Meshkee-generated sitemap | +| `/` | Slider, intro, four-category, project-images, sticky frame, process, stats, brands, Instagram, CTA | +| `/portfolio`, `/portfolio/[slug]` | Meshkee portfolios (+ local fallback); lightbox + zoom + similar | +| `/projects*` | Redirect → `/portfolio` | +| `/blog`, `/blog/[slug]` | Meshkee blogs (+ local stubs) | +| `/services*` | Local specialties | +| `/about` | `about-us-image` (2), story, values, stats, CTA | +| `/contact` | Facts + form (API + toast) + Instagram + dark map | +| `/api/contact`, `/api/auth/me`, `/api/auth/handoff` | Proxies | +| `/meshkee/*` | Slot catalog + sitemap-config | -No `/login`, `/register`, `/cart`, `/checkout`, no local `sitemap.xml` / `robots.txt` (nginx proxies those to the API). - -## Meshkee integration (`src/lib/`) -- `config.ts` — domain / API base / portal URLs -- `meshkee.ts` — `meshkeeGet` (ISR 300s, returns fallback on any error), `getBusinessInfo`, `getFavicon` -- `static-images.ts` — slot catalog + `getPageSlots(pageKey)` (parses `{ items: [{ key, aspectRatio, images[] }] }`), `slotImages`, `slotRatio` -- `content.ts` — resolves name / logo / phones / emails / address / Instagram / about from `business-info` with fallbacks from `src/data/site.ts`; `parseHeadTag`, `stripScripts`, digit helpers -- Root layout: `metaTags` → `` 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) +## Components +`Header`/`AccountMenu`, `Footer`, `Providers`, `CmsMeta`, `SlotImage`, `SectionTitle`, `OpeningFrame`, `HeroSlider`, `InstagramFollow`, `MediaGallery`, `ContactForm` ## Deploy -Standard Meshkee websites VM flow: push → Super Admin **Deploy**. Deploy log must say `standalone build detected`; PM2 script `.next/standalone/server.js`. +Standalone build on Meshkee websites VM; PM2 → `.next/standalone/server.js`. ## Changelog -- 2026-10-11 — Initial build: home / about / contact, Meshkee business-info + static-image slots + contact form, standalone runtime, sitemap-config generator. +- 2026-10-12 — Placeholders prompt: remapped slots (`slider`, `four-category`, `project-images`, `sticky-frame-bg`, `instagram-row`, `brands`, `about-us-image`); brands row; metaTags → `metadata.other`. +- 2026-10-12 — Backend prompt: auth, portfolio/blog, contact toast, Instagram frame, domain `homgroup.ir`. +- 2026-10-11 — Initial site scaffold. diff --git a/public/images/hom-instagram.jpeg b/public/images/hom-instagram.jpeg new file mode 100644 index 0000000..562c31f Binary files /dev/null and b/public/images/hom-instagram.jpeg differ diff --git a/public/videos/hom-video.mp4 b/public/videos/hom-video.mp4 new file mode 100644 index 0000000..482d358 Binary files /dev/null and b/public/videos/hom-video.mp4 differ diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs index d6570ff..6afda79 100644 --- a/scripts/generate-sitemap-config.mjs +++ b/scripts/generate-sitemap-config.mjs @@ -9,7 +9,13 @@ const appDir = join(root, "src", "app"); const domain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.com"; const EXCLUDE = /^(api|meshkee|login|register|cart|checkout|account|admin)(\/|$)/; -const PRIORITY = { "/": 1.0, "/about": 0.6, "/contact": 0.5 }; +const PRIORITY = { + "/": 1.0, + "/projects": 0.8, + "/services": 0.8, + "/about": 0.6, + "/contact": 0.5, +}; function walk(dir, out = []) { for (const entry of readdirSync(dir)) { diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 790ffd0..2023ba8 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { SectionTitle } from "@/components/SectionTitle"; import { SlotImage } from "@/components/SlotImage"; import { gallery, services, site, stats } from "@/data/site"; import { resolveAbout, resolveName } from "@/lib/content"; @@ -17,14 +18,32 @@ const values = [ { title: "سادگی", text: "حذف اضافات تا آنچه می‌ماند، ضروری و خوانا باشد. سادگی برای ما نتیجه دقت است، نه کم‌کاری.", + icon: ( + <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> + <circle cx="20" cy="20" r="11" /> + </svg> + ), }, { title: "تناسب", text: "هر طرح در نسبت با زمین، نور، بودجه و شیوه زندگی کارفرما شکل می‌گیرد؛ نه بر اساس یک الگوی ثابت.", + icon: ( + <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> + <rect x="8" y="8" width="24" height="24" /> + <path d="M8 18h24M18 8v24" /> + </svg> + ), }, { title: "اجرای دقیق", text: "طرح فقط روی کاغذ معنا ندارد. جزئیات اجرایی و نظارت مستمر، تضمین می‌کند آنچه ساخته می‌شود همان طرح باشد.", + icon: ( + <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> + <path d="M12 28 20 10l8 18" /> + <path d="M15 22h10" /> + <circle cx="20" cy="30" r="2" fill="currentColor" stroke="none" /> + </svg> + ), }, ]; @@ -32,31 +51,43 @@ export default async function AboutPage() { const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("about")]); const name = resolveName(info); const about = resolveAbout(info); - const hero = slotImages("about-hero", slots, [{ url: gallery.aboutHero, titleFa: "خانه حیاط‌دار" }])[0]; - const images = slotImages( - "about-gallery", - slots, - gallery.aboutGallery.map((url, index) => ({ url, titleFa: `نمونه کار ${index + 1}` })), - ); + const aboutImages = slotImages("about-us-image", slots, gallery.aboutImages); + const hero = aboutImages[0]; + const galleryImages = aboutImages.slice(1); return ( <> <section className="page-hero"> <div className="container"> - <p className="eyebrow">درباره ما</p> - <h1>{name}؛ گروه معماری و طراحی داخلی</h1> + <SectionTitle + en="about" + title={`${name}؛`} + titleMuted="گروه معماری و طراحی داخلی" + as="h1" + className="section-title--inline" + /> </div> </section> <div className="container"> - <SlotImage image={hero} alt={`نمونه پروژه ${name}`} ratio={slotRatio("about-hero", slots)} className="page-hero__frame" priority /> + <SlotImage + image={hero} + alt={hero.titleFa || hero.titleEn || `نمونه پروژه ${name}`} + ratio={slotRatio("about-us-image", slots)} + className="page-hero__frame" + priority + /> </div> <section className="section"> <div className="container split"> <div> - <p className="eyebrow">داستان ما</p> - <h2 className="title">معماری از گوش دادن شروع می‌شود</h2> + <SectionTitle + en="story" + title="معماری از گوش دادن" + titleMuted="شروع می‌شود" + className="section-title--compact" + /> </div> <div className="about-text"> {about.map((paragraph, index) => ( @@ -66,23 +97,32 @@ export default async function AboutPage() { </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> + {galleryImages.length ? ( + <section className="section--tight"> + <div className="container about-gallery about-gallery--pair"> + {galleryImages.slice(0, 1).map((image, index) => ( + <SlotImage + key={`${image.url}-${index}`} + image={image} + alt={image.titleFa || image.titleEn || `نمونه کار ${name}`} + ratio={slotRatio("about-us-image", slots)} + /> + ))} + </div> + </section> + ) : null} <section className="section section--surface"> <div className="container"> - <p className="eyebrow">ارزش‌ها</p> - <h2 className="title">آنچه در هر پروژه به آن پایبندیم</h2> + <SectionTitle en="values" title="آنچه در هر پروژه" titleMuted="به آن پایبندیم" /> <div className="values"> {values.map((value) => ( <div key={value.title} className="value"> - <h3>{value.title}</h3> - <p>{value.text}</p> + <span className="value__icon">{value.icon}</span> + <div className="value__body"> + <h3>{value.title}</h3> + <p>{value.text}</p> + </div> </div> ))} </div> @@ -92,18 +132,22 @@ export default async function AboutPage() { <section className="section"> <div className="container split"> <div> - <p className="eyebrow">خدمات</p> - <h2 className="title">از نما تا جزئیات داخلی</h2> + <SectionTitle + en="services" + title="از نما تا" + titleMuted="جزئیات داخلی" + className="section-title--compact" + /> </div> <div className="about-text"> <p> خدمات {site.shortFa} چهار حوزه اصلی را پوشش می‌دهد و در هر پروژه، بسته به نیاز شما، یک یا چند مورد از آن‌ها کنار هم قرار می‌گیرند. </p> - <ul style={{ margin: 0, paddingInlineStart: 20, display: "grid", gap: 10 }}> + <ul> {services.map((service) => ( <li key={service.index}> - <strong style={{ color: "var(--fg)", fontWeight: 500 }}>{service.titleFa}</strong> — {service.text} + <strong>{service.titleFa}</strong> — {service.text} </li> ))} </ul> @@ -111,31 +155,36 @@ export default async function AboutPage() { </div> </section> - <section className="section--tight"> - <div className="container"> - <div className="stats"> - {stats.map((stat) => ( - <div key={stat.label} className="stat"> - <strong>{stat.value}</strong> - <span>{stat.label}</span> - </div> - ))} - </div> + <section className="stats-band" aria-label="آمار استودیو"> + <div className="stats"> + {stats.map((stat) => ( + <div key={stat.label} className="stat"> + <strong className="latin" lang="en" dir="ltr"> + {stat.value} + </strong> + <span>{stat.label}</span> + </div> + ))} </div> </section> - <section className="section"> + <section className="cta-band"> + <div className="cta-band__glow" aria-hidden="true" /> <div className="container cta"> - <div> - <p className="eyebrow">همکاری</p> - <h2>آماده‌ایم درباره پروژه شما صحبت کنیم.</h2> - </div> - <Link href="/contact" className="btn"> - تماس با ما - <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> + <SectionTitle + en="collaborate" + title="آماده‌ایم درباره پروژه شما" + titleMuted="صحبت کنیم." + className="section-title--inline section-title--light" + aside={ + <Link href="/contact" className="btn btn--light"> + تماس با ما + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M4 12h16M13 5l7 7-7 7" /> + </svg> + </Link> + } + /> </div> </section> </> diff --git a/src/app/api/auth/handoff/route.ts b/src/app/api/auth/handoff/route.ts new file mode 100644 index 0000000..93365f5 --- /dev/null +++ b/src/app/api/auth/handoff/route.ts @@ -0,0 +1,17 @@ +import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server"; + +export async function POST(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/handoff", { + method: "POST", + token, + }); + + if (!res.ok) { + return jsonResponse({ error: "امکان باز کردن پنل مدیریت نیست." }, res.status); + } + + return jsonResponse(res.data); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..53435fb --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,10 @@ +import { getBearerToken, jsonResponse, meshkeeFetch, unauthorizedResponse } from "@/lib/meshkee-server"; + +export async function GET(request: Request) { + const token = getBearerToken(request); + if (!token) return unauthorizedResponse(); + + const res = await meshkeeFetch("/auth/me", { token }); + if (!res.ok) return jsonResponse({ error: "Unauthorized" }, res.status); + return jsonResponse(res.data); +} diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..be3a492 --- /dev/null +++ b/src/app/blog/[slug]/page.tsx @@ -0,0 +1,107 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { SectionTitle } from "@/components/SectionTitle"; +import { + asBlogHtml, + blogHref, + blogImage, + fetchBlogById, + fetchBlogBySlug, + fetchBlogs, + formatBlogDate, + getLocalBlogPost, + getLocalBlogPosts, + type BlogPost, +} from "@/lib/blog"; + +export const revalidate = 300; + +type PageProps = { params: Promise<{ slug: string }> }; + +async function resolveBlog(slug: string): Promise<BlogPost | null> { + const decoded = decodeURIComponent(slug); + const bySlug = await fetchBlogBySlug(decoded); + if (bySlug) return bySlug; + const byId = await fetchBlogById(decoded); + if (byId) return byId; + return getLocalBlogPost(decoded); +} + +export async function generateMetadata({ params }: PageProps): Promise<Metadata> { + const { slug } = await params; + const post = await resolveBlog(slug); + if (!post) return { title: "مطلب پیدا نشد" }; + const description = post.abstract || post.title; + const image = blogImage(post); + return { + title: post.title, + description, + openGraph: { title: post.title, description, images: [{ url: image, alt: post.title }] }, + }; +} + +export default async function BlogDetailPage({ params }: PageProps) { + const { slug } = await params; + const post = await resolveBlog(slug); + if (!post) notFound(); + + const html = asBlogHtml(post.mainTextHtml || post.abstract); + const date = formatBlogDate(post.publishedAt || post.createdAt); + const related = await fetchBlogs({ page: 1, pageSize: 6 }); + const pool = related.items.length ? related.items : getLocalBlogPosts(); + const similar = pool.filter((item) => item.id !== post.id).slice(0, 3); + + return ( + <> + <section className="page-hero"> + <div className="container"> + <SectionTitle + en="article" + title={post.title} + as="h1" + className="section-title--inline" + aside={date ? <p className="page-hero__aside latin">{date}</p> : null} + /> + </div> + </section> + + <section className="section--tight"> + <div className="container blog-detail"> + <div className="blog-detail__media frame"> + <img src={blogImage(post)} alt={post.title} /> + </div> + <div className="blog-detail__copy"> + {post.abstract ? <p className="lead">{post.abstract}</p> : null} + {html ? <div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} /> : null} + <Link className="text-link" href="/blog"> + بازگشت به بلاگ + </Link> + </div> + </div> + </section> + + {similar.length ? ( + <section className="section section--surface"> + <div className="container"> + <SectionTitle en="more" title="مطالب" titleMuted="دیگر" /> + <div className="blog-grid"> + {similar.map((item) => ( + <Link key={item.id} href={blogHref(item)} className="blog-card"> + <div className="blog-card__media frame"> + <img src={blogImage(item)} alt={item.title} loading="lazy" decoding="async" /> + </div> + <div className="blog-card__body"> + <h2>{item.title}</h2> + {item.abstract ? <p>{item.abstract}</p> : null} + </div> + </Link> + ))} + </div> + </div> + </section> + ) : null} + </> + ); +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..1d6bf97 --- /dev/null +++ b/src/app/blog/page.tsx @@ -0,0 +1,80 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { SectionTitle } from "@/components/SectionTitle"; +import { site } from "@/data/site"; +import { + blogHref, + blogImage, + fetchBlogs, + formatBlogDate, + getLocalBlogPosts, +} from "@/lib/blog"; + +export const revalidate = 300; + +export const metadata: Metadata = { + title: "بلاگ", + description: `مقالات و یادداشت‌های ${site.nameFa} درباره معماری و طراحی داخلی.`, +}; + +export default async function BlogPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const { page: pageRaw } = await searchParams; + const page = Math.max(1, Number(pageRaw) || 1); + const pageSize = 12; + const result = await fetchBlogs({ page, pageSize }); + const usingApi = result.items.length > 0; + const posts = usingApi ? result.items : page === 1 ? getLocalBlogPosts() : []; + const total = usingApi ? result.total : posts.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + return ( + <> + <section className="page-hero"> + <div className="container"> + <SectionTitle en="blog" title="یادداشت‌های" titleMuted="استودیو" as="h1" className="section-title--inline" /> + </div> + </section> + + <section className="section--tight"> + <div className="container"> + {posts.length === 0 ? ( + <p className="empty-state">هنوز مطلبی منتشر نشده است.</p> + ) : ( + <div className="blog-grid"> + {posts.map((post) => { + const date = formatBlogDate(post.publishedAt || post.createdAt); + return ( + <Link key={post.id} href={blogHref(post)} className="blog-card"> + <div className="blog-card__media frame"> + <img src={blogImage(post)} alt={post.title} loading="lazy" decoding="async" /> + </div> + <div className="blog-card__body"> + {date ? <span className="blog-card__date latin">{date}</span> : null} + <h2>{post.title}</h2> + {post.abstract ? <p>{post.abstract}</p> : null} + </div> + </Link> + ); + })} + </div> + )} + + {totalPages > 1 && posts.length ? ( + <nav className="pager" aria-label="صفحه‌بندی"> + {page > 1 ? <Link href={`/blog?page=${page - 1}`}>قبلی</Link> : <span />} + <span className="latin"> + {page} / {totalPages} + </span> + {page < totalPages ? <Link href={`/blog?page=${page + 1}`}>بعدی</Link> : <span />} + </nav> + ) : null} + </div> + </section> + </> + ); +} diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index 4ed2fdd..c14570e 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,8 +1,11 @@ import type { Metadata } from "next"; +import type { ReactNode } from "react"; import { ContactForm } from "@/components/ContactForm"; -import { SlotImage } from "@/components/SlotImage"; -import { gallery } from "@/data/site"; +import { ContactMap } from "@/components/ContactMap"; +import { InstagramFollow } from "@/components/InstagramFollow"; +import { SectionTitle } from "@/components/SectionTitle"; +import { gallery, site } from "@/data/site"; import { resolveAddress, resolveEmails, @@ -19,72 +22,156 @@ export const metadata: Metadata = { "برای مشاوره طراحی معماری، طراحی داخلی یا طراحی نما با استودیو معماری هوم تماس بگیرید یا فرم درخواست پروژه را ارسال کنید.", }; +function IconPhone() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M8.5 4.5h3l1.5 4-2 1.2a12 12 0 0 0 5.3 5.3l1.2-2 4 1.5v3A2 2 0 0 1 19.5 19.5 15 15 0 0 1 4.5 4.5 2 2 0 0 1 6.5 2.5h2Z" /> + </svg> + ); +} + +function IconMail() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <rect x="3.5" y="5.5" width="17" height="13" rx="1.5" /> + <path d="m4.5 7.5 7.5 6 7.5-6" /> + </svg> + ); +} + +function IconInstagram() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <rect x="4" y="4" width="16" height="16" rx="4" /> + <circle cx="12" cy="12" r="3.5" /> + <circle cx="17.2" cy="6.8" r="0.9" fill="currentColor" stroke="none" /> + </svg> + ); +} + +function IconPin() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M12 21s6.5-5.2 6.5-10.2A6.5 6.5 0 0 0 5.5 10.8C5.5 15.8 12 21 12 21Z" /> + <circle cx="12" cy="10.5" r="2.2" /> + </svg> + ); +} + +function Fact({ + icon, + label, + children, + className = "", +}: { + icon: ReactNode; + label: string; + children: ReactNode; + className?: string; +}) { + return ( + <div className={`fact ${className}`.trim()}> + <span className="fact__icon" aria-hidden="true"> + {icon} + </span> + <div className="fact__body"> + <span className="sr-only">{label}</span> + {children} + </div> + </div> + ); +} + export default async function ContactPage() { - const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("contact")]); + const [info, homeSlots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]); const name = resolveName(info); const phones = resolvePhones(info); const emails = resolveEmails(info); const instagram = resolveInstagram(info); const address = resolveAddress(info); - const image = slotImages("contact-image", slots, [{ url: gallery.contact, titleFa: "آشپزخانه کرم" }])[0]; + const instagramImage = slotImages("instagram-row", homeSlots, [ + { + url: gallery.instagram, + titleFa: "ما را در اینستاگرام دنبال کنید", + titleEn: "Follow us", + subtext: "پروژه‌های تازه و جزئیات اجرا را در صفحه اینستاگرام ببینید.", + linkUrl: instagram.href, + }, + ])[0]; + const instagramRatio = slotRatio("instagram-row", homeSlots); + const { lat, lng, zoom } = site.map; + const mapLink = `https://www.google.com/maps?q=${encodeURIComponent(`${lat},${lng}`)}&z=${zoom}&hl=fa`; return ( <> - <section className="page-hero"> - <div className="container"> - <p className="eyebrow">تماس با ما</p> - <h1>درباره پروژه‌تان صحبت کنیم</h1> + <section className="contact-hero"> + <div className="contact-hero__glow" aria-hidden="true" /> + <div className="container contact-hero__inner"> + <SectionTitle + en="contact" + title="درباره پروژه‌تان" + titleMuted="صحبت کنیم" + as="h1" + className="section-title--inline" + aside={ + <p className="contact-hero__lead"> + پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس می‌گیرد. + </p> + } + /> </div> </section> - <section className="section--tight"> + <section className="section"> <div className="container contact-grid"> <div className="contact-facts"> - <p className="lead"> - پیام بگذارید یا تماس بگیرید؛ تیم {name} در اولین فرصت برای هماهنگی جلسه مشاوره با شما تماس می‌گیرد. - </p> - - <div className="fact fact--phones"> - <span>تلفن</span> + <Fact icon={<IconPhone />} label="تلفن" className="fact--phones"> {phones.map((phone) => ( - <a key={phone.href} href={phone.href}> + <a key={phone.href} href={phone.href} className="latin" dir="ltr"> {phone.display} </a> ))} - </div> + </Fact> - <div className="fact"> - <span>ایمیل</span> + <Fact icon={<IconMail />} label="ایمیل"> {emails.map((email) => ( - <a key={email} href={`mailto:${email}`} dir="ltr" style={{ textAlign: "right" }}> + <a key={email} href={`mailto:${email}`} className="latin" dir="ltr"> {email} </a> ))} - </div> + </Fact> - <div className="fact"> - <span>اینستاگرام</span> - <a href={instagram.href} target="_blank" rel="noreferrer" dir="ltr" style={{ textAlign: "right" }}> + <Fact icon={<IconInstagram />} label="اینستاگرام"> + <a href={instagram.href} target="_blank" rel="noreferrer" className="latin" dir="ltr"> {instagram.handle} </a> - </div> + </Fact> - <div className="fact"> - <span>آدرس</span> + <Fact icon={<IconPin />} label="آدرس"> <strong>{address}</strong> - </div> - - <SlotImage image={image} alt={`نمونه پروژه ${name}`} ratio={slotRatio("contact-image", slots)} className="contact-frame" /> + </Fact> </div> - <div> - <h2 className="title" style={{ marginBottom: 32 }}> - فرم درخواست پروژه - </h2> + <div className="contact-form-panel"> + <SectionTitle + en="inquiry" + title="فرم درخواست" + titleMuted="پروژه" + className="section-title--compact" + /> <ContactForm /> </div> </div> </section> + + <InstagramFollow + handle={instagram.handle} + href={instagram.href} + image={instagramImage} + ratio={instagramRatio} + /> + + <ContactMap lat={lat} lng={lng} zoom={zoom} address={address} mapLink={mapLink} /> </> ); } diff --git a/src/app/globals.css b/src/app/globals.css index 98de346..304454a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -10,7 +10,7 @@ --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; + --font-latin: var(--font-montserrat, "Montserrat"), "Helvetica Neue", Helvetica, Arial, sans-serif; } *, @@ -120,17 +120,22 @@ h3 { .nav { display: flex; align-items: center; - gap: 36px; + gap: 28px; font-size: 15px; } -.nav a { +.nav > a, +.nav__trigger { position: relative; + display: inline-flex; + align-items: center; + gap: 6px; padding-block: 6px; color: var(--fg); } -.nav a::after { +.nav > a::after, +.nav__trigger::after { content: ""; position: absolute; inset-inline: 0; @@ -142,11 +147,65 @@ h3 { transition: transform 0.25s ease; } -.nav a:hover::after, -.nav a[aria-current="page"]::after { +.nav > a:hover::after, +.nav > a[aria-current="page"]::after, +.nav__item:hover .nav__trigger::after, +.nav__trigger[aria-current="page"]::after { transform: scaleX(1); } +.nav__caret { + width: 10px; + height: 10px; + opacity: 0.55; + transition: transform 0.2s ease; +} + +.nav__item { + position: relative; +} + +.nav__item:hover .nav__caret, +.nav__item:focus-within .nav__caret { + transform: rotate(180deg); +} + +.nav__menu { + position: absolute; + top: calc(100% + 10px); + inset-inline-start: 0; + min-width: 220px; + padding: 10px 0; + background: #fff; + border: 1px solid var(--line); + opacity: 0; + visibility: hidden; + transform: translateY(6px); + transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease; + z-index: 60; +} + +.nav__item:hover .nav__menu, +.nav__item:focus-within .nav__menu { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.nav__menu a { + display: block; + padding: 10px 18px; + font-size: 14px; + color: var(--fg); + white-space: nowrap; +} + +.nav__menu a:hover, +.nav__menu a[aria-current="page"] { + background: var(--surface); + color: var(--accent); +} + .header__tools { display: flex; align-items: center; @@ -154,7 +213,11 @@ h3 { } .header__phone { - font-size: 14px; + font-family: var(--font-latin); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: none; color: var(--muted); direction: ltr; } @@ -165,31 +228,43 @@ h3 { .header__burger { display: none; - width: 40px; - height: 40px; + width: 44px; + height: 44px; align-items: center; justify-content: center; + flex: none; + margin: 0; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + color: var(--fg); } -.header__burger span, -.header__burger span::before, -.header__burger span::after { +.header__burger span { + position: relative; display: block; width: 22px; height: 1.5px; - background: var(--fg); + background: currentColor; + transition: background 0.2s ease; +} + +.header__burger span::before, +.header__burger span::after { content: ""; - position: relative; - transition: transform 0.25s ease, opacity 0.25s ease; + position: absolute; + inset-inline: 0; + height: 1.5px; + background: currentColor; + transition: transform 0.25s ease; } .header__burger span::before { - position: absolute; top: -7px; } .header__burger span::after { - position: absolute; top: 7px; } @@ -207,12 +282,10 @@ h3 { .mobile-nav { display: none; - border-top: 1px solid var(--line); - background: var(--bg); } -.mobile-nav[data-open="true"] { - display: block; +.mobile-nav__links { + display: grid; } .mobile-nav a { @@ -222,13 +295,66 @@ h3 { font-size: 17px; } +.mobile-nav__group { + border-bottom: 1px solid var(--line); +} + +.mobile-nav__row { + display: flex; + align-items: stretch; +} + +.mobile-nav__row > a { + flex: 1; + border-bottom: 0; +} + +.mobile-nav__toggle { + width: 52px; + display: grid; + place-items: center; + margin: 0; + padding: 0; + border: 0; + border-inline-start: 1px solid var(--line); + background: transparent; + color: var(--fg); + cursor: pointer; +} + +.mobile-nav__toggle svg { + width: 12px; + height: 12px; + transition: transform 0.2s ease; +} + +.mobile-nav__group[data-open="true"] .mobile-nav__toggle svg { + transform: rotate(180deg); +} + +.mobile-nav__sub a { + padding-inline-start: calc(var(--gutter) + 14px); + font-size: 15px; + color: var(--muted); + background: var(--surface); +} + +.mobile-nav__sub a[aria-current="page"] { + color: var(--accent); +} + .mobile-nav__meta { - padding: 20px var(--gutter); + padding: 20px var(--gutter) 28px; color: var(--muted); font-size: 14px; display: flex; flex-direction: column; - gap: 6px; + gap: 10px; +} + +.mobile-nav__cta { + margin-top: 8px; + width: fit-content; } @media (max-width: 900px) { @@ -238,11 +364,35 @@ h3 { .nav, .header__phone { - display: none; + display: none !important; } .header__burger { - display: flex; + display: inline-flex; + } + + .mobile-nav { + display: none; + border-top: 1px solid var(--line); + background: var(--bg); + max-height: calc(100svh - var(--header-h)); + overflow: auto; + overscroll-behavior: contain; + } + + .mobile-nav[data-open="true"] { + display: block; + } + + .header[data-nav-open="true"] { + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.06); + } +} + +@media (min-width: 901px) { + .mobile-nav, + .mobile-nav[data-open="true"] { + display: none !important; } } @@ -269,6 +419,148 @@ h3 { border-top: 1px solid var(--line); } +/* Logo-inspired section title: latin label + geometric rail + FA heading */ +.section-title { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: clamp(20px, 3vw, 48px); + margin-bottom: clamp(28px, 4vw, 52px); +} + +.section-title__main { + min-width: 0; + flex: 1; +} + +.section-title__rail { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + max-width: 520px; +} + +.section-title__en { + flex: none; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: lowercase; +} + +.section-title__track { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 48px; +} + +.section-title__line { + flex: 1; + height: 1px; + background: var(--fg); + opacity: 0.22; +} + +.section-title__line--tail { + flex: 0 0 28px; +} + +.section-title__capsule { + flex: none; + width: 18px; + height: 8px; + border: 1.5px solid var(--fg); + border-radius: 999px; + opacity: 0.55; +} + +.section-title__dot { + flex: none; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} + +.section-title__fa { + font-size: clamp(18px, 2.2vw, 28px); + font-weight: 400; + line-height: 1.55; + max-width: none; + margin: 0; + white-space: nowrap; +} + +.section-title__fa em { + font-style: normal; + color: var(--muted); +} + +.section-title__aside { + flex: none; + max-width: 34ch; + padding-bottom: 6px; +} + +.section-title--light .section-title__en { + color: rgba(255, 255, 255, 0.55); +} + +.section-title--light .section-title__line { + background: #fff; + opacity: 0.28; +} + +.section-title--light .section-title__capsule { + border-color: #fff; + opacity: 0.55; +} + +.section-title--compact { + margin-bottom: 20px; +} + +.section-title--compact .section-title__fa { + font-size: clamp(18px, 2vw, 24px); + max-width: none; +} + +.section-title--story { + margin-bottom: 0; +} + +.section-title--story .section-title__fa { + font-size: clamp(15px, 1.25vw, 18px); + font-weight: 400; + line-height: 1.8; + max-width: none; + width: 100%; + white-space: normal; + text-align: justify; + text-align-last: right; +} + +.section-title--story .section-title__rail { + margin-bottom: 14px; + max-width: 100%; +} + +.section-title--light .section-title__fa { + color: #fff; +} + +.section-title--light .section-title__fa em { + color: rgba(255, 255, 255, 0.55); +} + +.section-title--inline { + margin-bottom: 0; +} + +/* Legacy aliases kept for pages not yet migrated */ .eyebrow { display: flex; align-items: center; @@ -297,7 +589,8 @@ h3 { } .lead { - font-size: clamp(16px, 1.3vw, 19px); + font-size: clamp(14px, 1.1vw, 16px); + line-height: 1.85; color: var(--muted); max-width: 60ch; } @@ -402,34 +695,132 @@ h3 { border-color: var(--accent); } -/* ---------- Hero ---------- */ +/* ---------- Hero slider (full-viewport) ---------- */ -.hero { - padding-top: clamp(48px, 7vw, 96px); +@keyframes hero-fade-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.hero__grid { +@keyframes hero-slide-in { + from { + opacity: 0; + transform: scale(1.04); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Reveal the white bar from the physical right edge toward the left. */ +@keyframes hero-bar-rtl { + from { + clip-path: inset(0 0 0 100%); + } + to { + clip-path: inset(0 0 0 0); + } +} + +.hero-slider { + position: relative; + width: 100%; + background: var(--fg); +} + +.hero-slider__viewport { + position: relative; + width: 100%; + height: calc(100svh - var(--header-h)); + min-height: 520px; + overflow: hidden; +} + +.hero-slider__slide { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; + transition: opacity 1s ease; +} + +.hero-slider__slide[data-active="true"] { + opacity: 1; + pointer-events: auto; + z-index: 1; +} + +.hero-slider__slide[data-active="true"] .hero-slider__image { + animation: hero-slide-in 1.15s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.hero-slider__slide a { + display: block; + width: 100%; + height: 100%; +} + +.hero-slider__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.hero-slider__bar { + position: absolute; + z-index: 2; + inset-inline: var(--gutter); + bottom: clamp(20px, 3.5vw, 40px); + max-width: var(--max); + width: calc(100% - (var(--gutter) * 2)); + margin-inline: auto; + background: #fff; + overflow: hidden; + clip-path: inset(0 0 0 100%); + /* Fast open, then a long soft settle toward the left edge. */ + animation: hero-bar-rtl 1.05s cubic-bezier(0.05, 0.9, 0.12, 1) both; + animation-delay: 0.18s; +} + +.hero-slider__panel { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - gap: 32px; - padding-bottom: clamp(36px, 5vw, 64px); + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr) auto; + align-items: center; + gap: clamp(20px, 3vw, 48px); + padding-block: clamp(22px, 2.8vw, 36px); + padding-inline: clamp(20px, 3vw, 40px); + max-width: none; } -.hero__title { - font-size: clamp(34px, 6vw, 84px); +.hero-slider__seq { + --seq: 1; + opacity: 0; + animation: hero-fade-up 0.65s cubic-bezier(0.22, 1, 0.36, 1) both; + /* after the bar width reveal (~0.18 + 0.85), then title → lead → actions */ + animation-delay: calc(0.7s + (var(--seq) * 0.16s)); +} + +.hero-slider__title { + font-size: clamp(22px, 2.6vw, 36px); font-weight: 500; - line-height: 1.25; + line-height: 1.35; letter-spacing: -0.02em; - max-width: 14ch; + margin: 0; } -.hero__title em { +.hero-slider__title em { font-style: normal; color: var(--muted); } -.hero__dot { +.hero-slider__dot { display: inline-block; width: 0.28em; height: 0.28em; @@ -438,133 +829,331 @@ h3 { 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 { +.hero-slider__lead { color: var(--muted); font-size: 15px; + line-height: 1.75; + margin: 0; + max-width: 48ch; } -.hero__frame { - aspect-ratio: 16 / 9; - max-height: 82vh; -} - -.hero__caption { +.hero-slider__actions { display: flex; - justify-content: space-between; - gap: 24px; - padding-block: 16px; - border-bottom: 1px solid var(--line); - color: var(--muted); - font-size: 13px; + flex-direction: column; + align-items: flex-end; + gap: 14px; + flex: none; +} + +.hero-slider__dots { + display: flex; + gap: 8px; + align-items: center; +} + +.hero-slider__dot-btn { + width: 22px; + height: 3px; + padding: 0; + background: var(--line); + transition: background 0.2s ease; +} + +.hero-slider__dot-btn[data-active="true"], +.hero-slider__dot-btn:hover { + background: var(--fg); } @media (max-width: 900px) { - .hero__grid { + .hero-slider__panel { grid-template-columns: 1fr; align-items: start; + gap: 16px; } - .hero__frame { - aspect-ratio: 4 / 3; + .hero-slider__actions { + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + } + + .hero-slider__lead { + max-width: none; + } +} + +@media (max-width: 640px) { + .hero-slider__viewport { + min-height: 560px; + } +} + +@media (prefers-reduced-motion: reduce) { + .hero-slider__slide, + .hero-slider__slide[data-active="true"] .hero-slider__image, + .hero-slider__bar, + .hero-slider__seq { + animation: none !important; + transition: none !important; + opacity: 1 !important; + transform: none !important; + clip-path: none !important; } } /* ---------- Intro ---------- */ +.intro { + display: grid; + /* RTL: first column lands on the right — logo is rightmost */ + grid-template-columns: minmax(220px, 1fr) minmax(0, 0.95fr) minmax(0, 1.15fr); + gap: clamp(28px, 4vw, 64px); + align-items: stretch; +} + +.intro__copy { + min-width: 0; +} + .intro__text { - font-size: clamp(20px, 2.2vw, 30px); + font-size: clamp(15px, 1.25vw, 18px); font-weight: 400; - line-height: 1.75; - max-width: 30ch; + line-height: 1.8; + max-width: 28ch; + text-align: justify; + text-align-last: right; } .intro__meta { display: grid; - gap: 18px; + gap: 16px; color: var(--muted); + font-size: clamp(14px, 1.1vw, 16px); + line-height: 1.85; + align-content: start; + min-width: 0; +} + +.intro__meta > p { + margin: 0; + text-align: justify; + text-align-last: right; } .intro__meta strong { display: block; color: var(--fg); + font-size: clamp(15px, 1.15vw, 17px); 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); +.intro__meta .text-link { + margin-top: 8px; + justify-self: start; } -.service { - padding: 32px 28px 40px; - border-inline-end: 1px solid var(--line); - border-bottom: 1px solid var(--line); +.intro__brand { display: flex; - flex-direction: column; - gap: 18px; - min-height: 300px; - transition: background 0.2s ease; + align-items: center; + justify-content: center; + align-self: stretch; + min-height: 100%; } -.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; +.intro__logo { + width: min(100%, 380px); + height: auto; + display: block; } @media (max-width: 1000px) { - .services { - grid-template-columns: repeat(2, 1fr); + .intro { + grid-template-columns: 1fr 1fr; + } + + .intro__brand { + grid-column: 1 / -1; + justify-content: center; + padding-block: 12px; + } + + .intro__logo { + width: min(320px, 70vw); } } -@media (max-width: 560px) { - .services { +@media (max-width: 640px) { + .intro { grid-template-columns: 1fr; } - .service { + .intro__text { + max-width: none; + } + + .intro__logo { + width: min(280px, 80vw); + } +} + +/* ---------- Services (hover-expand image panels) ---------- */ + +.service-panels { + display: flex; + height: min(68vh, 640px); + min-height: 420px; + gap: 3px; + background: var(--fg); +} + +.service-panel { + position: relative; + flex: 1 1 0; + min-width: 0; + overflow: hidden; + cursor: pointer; + color: #fff; + transition: flex 0.65s cubic-bezier(0.05, 0.9, 0.12, 1); +} + +.service-panel:hover, +.service-panel:focus-within { + flex: 3.2 1 0; +} + +.service-panel__image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + transform: scale(1.04); + transition: transform 0.8s cubic-bezier(0.22, 1, 0.36, 1); +} + +.service-panel:hover .service-panel__image, +.service-panel:focus-within .service-panel__image { + transform: scale(1); +} + +.service-panel__shade { + position: absolute; + inset: 0; + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.78) 0%, + rgba(0, 0, 0, 0.28) 45%, + rgba(0, 0, 0, 0.08) 100% + ); + transition: background 0.45s ease; +} + +.service-panel:hover .service-panel__shade, +.service-panel:focus-within .service-panel__shade { + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.86) 0%, + rgba(0, 0, 0, 0.4) 55%, + rgba(0, 0, 0, 0.15) 100% + ); +} + +.service-panel__body { + position: absolute; + inset-inline: 0; + bottom: 0; + z-index: 1; + padding: clamp(18px, 2.5vw, 32px); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.service-panel__index { + color: var(--accent); + font-family: var(--font-latin); + font-size: 12px; + letter-spacing: 0.14em; + direction: ltr; +} + +.service-panel__body h3 { + font-size: clamp(17px, 1.6vw, 24px); + font-weight: 500; + line-height: 1.35; + margin: 0; +} + +.service-panel__body .latin { + color: rgba(255, 255, 255, 0.72); + letter-spacing: 0.16em; + font-size: 10px; +} + +.service-panel__desc { + margin: 0; + max-width: 36ch; + font-size: 14px; + line-height: 1.85; + color: rgba(255, 255, 255, 0.82); + opacity: 0; + max-height: 0; + overflow: hidden; + transform: translateY(10px); + transition: + opacity 0.4s ease 0.12s, + max-height 0.5s ease, + transform 0.45s ease 0.1s; +} + +.service-panel:hover .service-panel__desc, +.service-panel:focus-within .service-panel__desc { + opacity: 1; + max-height: 9em; + transform: translateY(0); +} + +@media (max-width: 900px) { + .service-panels { + flex-direction: column; + height: auto; min-height: 0; + gap: 3px; + } + + .service-panel { + flex: none; + min-height: 200px; + transition: min-height 0.45s cubic-bezier(0.05, 0.9, 0.12, 1); + } + + .service-panel:hover, + .service-panel:focus-within { + flex: none; + min-height: 320px; + } +} + +@media (prefers-reduced-motion: reduce) { + .service-panel, + .service-panel__image, + .service-panel__desc { + transition: none !important; + } + + .service-panel__desc { + opacity: 1; + max-height: none; + transform: none; } } /* ---------- Projects ---------- */ .section-head { + /* Prefer SectionTitle; kept for transitional layouts */ display: flex; align-items: flex-end; justify-content: space-between; @@ -572,21 +1161,62 @@ h3 { margin-bottom: clamp(32px, 4vw, 56px); } +@media (max-width: 720px) { + .section-title { + flex-direction: column; + align-items: flex-start; + } + + .section-title__aside { + max-width: none; + } + + .section-title__fa, + .page-hero .section-title__fa { + white-space: normal; + } +} + .projects { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: clamp(16px, 2.5vw, 40px) clamp(16px, 2vw, 28px); + column-count: 3; + column-gap: 8px; } .project { display: block; + break-inside: avoid; + -webkit-column-break-inside: avoid; + page-break-inside: avoid; + margin-bottom: 8px; + border: 1px solid #efeeeb; + border-radius: 4px; + background: var(--bg); + padding: 8px; + padding-bottom: 0; + transition: border-color 0.35s ease; +} + +.project:hover { + border-color: var(--fg); } .project .frame { - aspect-ratio: 4 / 5; + /* Masonry: natural image height — no forced ratio (avoids empty gap under photo) */ + aspect-ratio: auto !important; + height: auto; + background: transparent; + line-height: 0; + overflow: hidden; + border-radius: 3px; } .project .frame img { + width: 100%; + height: auto; + display: block; + vertical-align: top; + object-fit: cover; + border-radius: 3px; transition: transform 0.6s ease; } @@ -599,7 +1229,7 @@ h3 { justify-content: space-between; align-items: baseline; gap: 16px; - padding-top: 14px; + padding: 12px 4px 0; } .project__meta h3 { @@ -614,35 +1244,953 @@ h3 { .project__sub { color: var(--muted); font-size: 13px; + padding: 4px 4px 12px; } @media (max-width: 900px) { .projects { - grid-template-columns: repeat(2, 1fr); + column-count: 2; } } @media (max-width: 560px) { .projects { + column-count: 1; + } +} + +/* ---------- Opening sticky frame (Mana Collection style) ---------- */ + +.opening-track { + position: relative; + height: 260vh; + background: #111; + color: #fff; + view-timeline-name: --opening; + view-timeline-axis: block; +} + +.opening-stage { + position: sticky; + top: var(--header-h); + z-index: 10; + height: calc(100svh - var(--header-h)); + overflow: hidden; + background: #111; +} + +.opening-media { + position: absolute; + inset: 0; + transform-origin: center; + transform: scale(1.38); +} + +.opening-media img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 30%; +} + +.opening-vignette { + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient( + circle at center, + transparent 28%, + rgba(0, 0, 0, 0.18) 68%, + rgba(0, 0, 0, 0.42) 100% + ); +} + +.opening-shade { + position: absolute; + inset: 0; + pointer-events: none; + background: rgba(0, 0, 0, 0.48); + opacity: 0; +} + +.opening-headline { + position: absolute; + inset: 0; + display: grid; + place-content: center; + text-align: center; + padding-inline: var(--gutter); + z-index: 1; +} + +.opening-headline__eyebrow { + margin: 0; + font-family: var(--font-sans); + font-size: clamp(15px, 1.35vw, 18px); + font-weight: 500; + color: rgba(255, 255, 255, 0.82); + text-shadow: 0 2px 18px rgba(0, 0, 0, 0.45); +} + +.opening-headline__title { + margin: 12px 0 0; + font-size: clamp(2.2rem, 5.8vw, 5.2rem); + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.02em; + color: #f5f5f7; + white-space: nowrap; + text-shadow: 0 8px 36px rgba(0, 0, 0, 0.4); +} + +.opening-headline__title em { + font-style: normal; + color: rgba(245, 245, 247, 0.58); +} + +.opening-headline__mark { + display: inline-block; + width: 0.28em; + height: 0.28em; + margin-inline-start: 0.1em; + background: var(--accent); + vertical-align: baseline; +} + +.opening-headline__en { + margin: 18px 0 0; + font-size: clamp(11px, 1.05vw, 13px); + font-weight: 400; + letter-spacing: 0.42em; + text-indent: 0.42em; + color: rgba(255, 255, 255, 0.62); + text-transform: uppercase; + text-shadow: 0 2px 16px rgba(0, 0, 0, 0.35); +} + +.opening-copy { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: flex-end; + justify-content: center; + padding: clamp(16px, 3vw, 40px) var(--gutter); + opacity: 0; + transform: translate3d(0, 28px, 0); + pointer-events: none; +} + +/* Hom: transparent overlay + 4 icon features (not Mana big-type list) */ +.opening-panel { + width: min(1120px, 100%); + color: #fff; + padding: clamp(12px, 2vw, 24px) 0; + pointer-events: auto; +} + +.opening-panel__head { + display: grid; + gap: 14px; + margin-bottom: clamp(22px, 3vw, 32px); + max-width: 52rem; + margin-inline: auto; + text-align: center; +} + +.opening-panel__rail { + display: flex; + align-items: center; + gap: 12px; + max-width: 280px; + margin-inline: auto; +} + +.opening-panel__rail .latin { + color: rgba(255, 255, 255, 0.65); + font-size: 11px; + letter-spacing: 0.22em; +} + +.opening-panel__line { + flex: 1; + height: 1px; + background: rgba(255, 255, 255, 0.35); +} + +.opening-panel__capsule { + width: 16px; + height: 7px; + border: 1.5px solid rgba(255, 255, 255, 0.55); + border-radius: 999px; +} + +.opening-panel__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} + +.opening-panel__lead { + margin: 0; + font-size: clamp(14px, 1.1vw, 16px); + line-height: 1.85; + color: rgba(255, 255, 255, 0.82); + text-shadow: 0 2px 18px rgba(0, 0, 0, 0.35); +} + +.opening-features { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + border-top: 1px solid rgba(255, 255, 255, 0.22); + border-bottom: 1px solid rgba(255, 255, 255, 0.22); +} + +.opening-feature { + padding: clamp(18px, 2.2vw, 26px) clamp(12px, 1.5vw, 18px); + border-inline-end: 1px solid rgba(255, 255, 255, 0.18); + display: grid; + gap: 8px; + justify-items: center; + text-align: center; + align-content: start; + min-height: 100%; +} + +.opening-feature:last-child { + border-inline-end: 0; +} + +.opening-feature__icon { + width: 36px; + height: 36px; + color: #fff; + margin-bottom: 4px; +} + +.opening-feature__icon svg { + width: 100%; + height: 100%; +} + +.opening-feature h3 { + margin: 0; + font-size: 16px; + font-weight: 500; + color: #fff; +} + +.opening-feature .latin { + color: rgba(255, 255, 255, 0.55); + font-size: 10px; + letter-spacing: 0.16em; +} + +.opening-feature p { + margin: 0; + font-size: 13px; + line-height: 1.75; + color: rgba(255, 255, 255, 0.72); +} + +.opening-panel__cta { + display: flex; + justify-content: center; + margin-top: clamp(20px, 2.5vw, 28px); +} + +.opening-media, +.opening-shade, +.opening-headline, +.opening-copy { + animation-timing-function: linear; + animation-fill-mode: both; + animation-timeline: --opening; + animation-duration: auto; +} + +.opening-media { + animation-name: opening-media; + animation-range: cover 0% cover 68%; +} + +.opening-headline { + animation-name: opening-headline; + animation-range: cover 42% cover 56%; +} + +.opening-shade { + animation-name: opening-shade; + animation-range: cover 46% cover 64%; +} + +.opening-copy { + animation-name: opening-copy, opening-copy-hit; + animation-timing-function: linear, step-end; + animation-range: cover 52% cover 70%, cover 52% cover 70%; +} + +@keyframes opening-media { + from { + transform: scale(1.38); + } + to { + transform: scale(1.06); + } +} + +@keyframes opening-headline { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes opening-shade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes opening-copy { + from { + opacity: 0; + transform: translate3d(0, 28px, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes opening-copy-hit { + 0%, + 65% { + pointer-events: none; + } + 100% { + pointer-events: auto; + } +} + +@media (max-width: 900px) { + .opening-headline__title { + white-space: normal; + } + + .opening-features { + grid-template-columns: 1fr 1fr; + } + + .opening-feature:nth-child(2) { + border-inline-end: 0; + } + + .opening-feature:nth-child(1), + .opening-feature:nth-child(2) { + border-bottom: 1px solid rgba(255, 255, 255, 0.18); + } +} + +@media (max-width: 560px) { + .opening-features { + grid-template-columns: 1fr; + } + + .opening-feature { + border-inline-end: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.18); + } + + .opening-feature:last-child { + border-bottom: 0; + } + + .opening-copy { + align-items: stretch; + padding-block: 12px; + } +} + +@supports not (animation-timeline: view()) { + .opening-track { + height: auto; + } + + .opening-stage { + position: relative; + top: auto; + height: calc(100svh - var(--header-h)); + } + + .opening-media, + .opening-shade, + .opening-headline, + .opening-copy { + animation: none; + } + + .opening-media { + transform: scale(1.06); + } + + .opening-shade { + opacity: 0.55; + } + + .opening-headline { + opacity: 0; + } + + .opening-copy { + opacity: 1; + transform: none; + pointer-events: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .opening-track { + height: auto; + view-timeline-name: none; + } + + .opening-stage { + position: relative; + top: auto; + height: min(86svh, 920px); + } + + .opening-media, + .opening-shade, + .opening-headline, + .opening-copy { + animation: none; + } + + .opening-media { + transform: scale(1.06); + } + + .opening-shade { + opacity: 0.55; + } + + .opening-headline { + opacity: 0; + } + + .opening-copy { + opacity: 1; + transform: none; + pointer-events: auto; + } +} + +/* ---------- Service detail hero ---------- */ + +.service-hero { + position: relative; + min-height: min(72svh, 760px); + display: flex; + align-items: flex-end; + color: #fff; + overflow: hidden; + background: #111; +} + +.service-hero__media { + position: absolute; + inset: 0; +} + +.service-hero__media img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +.service-hero__shade { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.72) 100%), + linear-gradient(90deg, transparent 40%, rgba(0, 0, 0, 0.35) 100%); +} + +.service-hero__content { + position: relative; + z-index: 1; + width: 100%; + padding-block: clamp(48px, 8vw, 96px) clamp(36px, 6vw, 72px); + display: grid; + gap: 16px; + max-width: var(--max); +} + +.service-hero__index { + margin: 0; + font-size: clamp(2.4rem, 6vw, 5rem); + font-weight: 500; + letter-spacing: 0.04em; + line-height: 1; + color: rgba(255, 255, 255, 0.28); +} + +.service-hero .section-title { + margin-bottom: 0; +} + +.service-hero .section-title__fa { + font-size: clamp(26px, 4vw, 46px); + white-space: normal; + color: #fff; +} + +.service-hero__text { + margin: 0; + max-width: 48ch; + font-size: clamp(14px, 1.15vw, 16px); + line-height: 1.85; + color: rgba(255, 255, 255, 0.78); + text-align: justify; + text-align-last: right; +} + +/* ---------- Service sticky frame (side card — distinct from OpeningFrame) ---------- */ + +.service-frame-track { + position: relative; + height: 240vh; + background: #111; + color: #fff; + view-timeline-name: --service-frame; + view-timeline-axis: block; +} + +.service-frame-stage { + position: sticky; + top: var(--header-h); + z-index: 10; + height: calc(100svh - var(--header-h)); + overflow: hidden; + background: #111; +} + +.service-frame-media { + position: absolute; + inset: 0; + transform-origin: center; + transform: scale(1.32); +} + +.service-frame-media img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +.service-frame-vignette { + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.55) 0%, transparent 58%); +} + +.service-frame-shade { + position: absolute; + inset: 0; + pointer-events: none; + background: rgba(0, 0, 0, 0.5); + opacity: 0; +} + +.service-frame-headline { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + padding-inline: var(--gutter); + max-width: min(520px, 48vw); + margin-inline-start: auto; +} + +.service-frame-headline__index { + margin: 0; + font-size: clamp(3rem, 8vw, 6.5rem); + font-weight: 500; + letter-spacing: 0.06em; + line-height: 0.9; + color: rgba(255, 255, 255, 0.22); +} + +.service-frame-headline__title { + margin: 12px 0 0; + font-size: clamp(1.8rem, 4vw, 3.4rem); + font-weight: 500; + line-height: 1.2; + color: #f5f5f7; + text-shadow: 0 8px 36px rgba(0, 0, 0, 0.4); +} + +.service-frame-headline__en { + margin: 14px 0 0; + font-size: clamp(11px, 1vw, 13px); + letter-spacing: 0.32em; + text-indent: 0.32em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.58); +} + +.service-frame-copy { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: stretch; + justify-content: flex-start; + padding: clamp(16px, 2.5vw, 32px) var(--gutter); + opacity: 0; + transform: translate3d(0, 24px, 0); + pointer-events: none; +} + +.service-frame-card { + width: min(420px, 100%); + align-self: center; + background: rgba(17, 17, 17, 0.78); + border: 1px solid rgba(255, 255, 255, 0.16); + backdrop-filter: blur(14px); + padding: clamp(20px, 2.5vw, 28px); + color: #fff; + pointer-events: auto; + display: grid; + gap: 18px; +} + +.service-frame-card__rail { + display: flex; + align-items: center; + gap: 12px; +} + +.service-frame-card__rail .latin { + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + letter-spacing: 0.22em; +} + +.service-frame-card__line { + flex: 1; + height: 1px; + background: rgba(255, 255, 255, 0.28); +} + +.service-frame-card__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} + +.service-frame-card__lead { + margin: 0; + font-size: clamp(14px, 1.1vw, 16px); + line-height: 1.85; + color: rgba(255, 255, 255, 0.82); + text-align: justify; + text-align-last: right; +} + +.service-frame-steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0; + border-top: 1px solid rgba(255, 255, 255, 0.16); +} + +.service-frame-step { + display: grid; + grid-template-columns: auto 1fr; + gap: 14px; + align-items: start; + padding-block: 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.service-frame-step__num { + font-size: 13px; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.4); + padding-top: 3px; +} + +.service-frame-step h3 { + margin: 0 0 4px; + font-size: 15px; + font-weight: 500; + color: #fff; +} + +.service-frame-step p { + margin: 0; + font-size: 13px; + line-height: 1.7; + color: rgba(255, 255, 255, 0.62); +} + +.service-frame-card__cta { + display: flex; + justify-content: flex-start; +} + +.service-frame-media, +.service-frame-shade, +.service-frame-headline, +.service-frame-copy { + animation-timing-function: linear; + animation-fill-mode: both; + animation-timeline: --service-frame; + animation-duration: auto; +} + +.service-frame-media { + animation-name: service-frame-media; + animation-range: cover 0% cover 68%; +} + +.service-frame-headline { + animation-name: service-frame-headline; + animation-range: cover 40% cover 56%; +} + +.service-frame-shade { + animation-name: service-frame-shade; + animation-range: cover 44% cover 64%; +} + +.service-frame-copy { + animation-name: service-frame-copy, service-frame-copy-hit; + animation-timing-function: linear, step-end; + animation-range: cover 50% cover 70%, cover 50% cover 70%; +} + +@keyframes service-frame-media { + from { + transform: scale(1.32); + } + to { + transform: scale(1.05); + } +} + +@keyframes service-frame-headline { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes service-frame-shade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes service-frame-copy { + from { + opacity: 0; + transform: translate3d(0, 24px, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes service-frame-copy-hit { + 0%, + 65% { + pointer-events: none; + } + 100% { + pointer-events: auto; + } +} + +.service-related { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: clamp(16px, 2vw, 28px); + margin-top: clamp(28px, 4vw, 48px); +} + +.service-related__card { + display: grid; + gap: 12px; + min-width: 0; +} + +.service-related__media { + aspect-ratio: 4 / 5; + overflow: hidden; + background: var(--surface); +} + +.service-related__media img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.6s ease; +} + +.service-related__card:hover .service-related__media img { + transform: scale(1.04); +} + +.service-related__meta { + display: grid; + gap: 4px; +} + +.service-related__meta .latin { + font-size: 12px; + color: var(--muted); + letter-spacing: 0.14em; +} + +.service-related__meta h3 { + margin: 0; + font-size: 17px; + font-weight: 500; +} + +.service-related__meta p { + margin: 0; + font-size: 12px; + color: var(--muted); +} + +@media (max-width: 900px) { + .service-frame-headline { + max-width: none; + margin-inline: 0; + align-items: center; + text-align: center; + padding-bottom: 18vh; + } + + .service-frame-copy { + align-items: flex-end; + justify-content: center; + } + + .service-frame-card { + width: min(520px, 100%); + } + + .service-related { grid-template-columns: 1fr; } } -/* ---------- Studio ---------- */ +@supports not (animation-timeline: view()) { + .service-frame-track { + height: auto; + } -.studio__frame { - aspect-ratio: 4 / 3; + .service-frame-stage { + position: relative; + top: auto; + height: auto; + min-height: calc(100svh - var(--header-h)); + } + + .service-frame-media, + .service-frame-shade, + .service-frame-headline, + .service-frame-copy { + animation: none; + } + + .service-frame-media { + transform: scale(1.05); + } + + .service-frame-shade { + opacity: 0.55; + } + + .service-frame-headline { + opacity: 0; + } + + .service-frame-copy { + position: relative; + opacity: 1; + transform: none; + pointer-events: auto; + padding-block: clamp(28px, 5vw, 48px); + } } -.studio__body { - display: flex; - flex-direction: column; - gap: 28px; - align-items: flex-start; -} +@media (prefers-reduced-motion: reduce) { + .service-frame-track { + height: auto; + view-timeline-name: none; + } -.studio__body p { - color: var(--muted); + .service-frame-stage { + position: relative; + top: auto; + height: auto; + min-height: min(86svh, 920px); + } + + .service-frame-media, + .service-frame-shade, + .service-frame-headline, + .service-frame-copy { + animation: none; + } + + .service-frame-media { + transform: scale(1.05); + } + + .service-frame-shade { + opacity: 0.55; + } + + .service-frame-headline { + opacity: 0; + } + + .service-frame-copy { + position: relative; + opacity: 1; + transform: none; + pointer-events: auto; + } } /* ---------- Process ---------- */ @@ -655,25 +2203,48 @@ h3 { } .process__item { + position: relative; border-top: 1px solid rgba(255, 255, 255, 0.22); padding-top: 22px; display: grid; gap: 12px; + overflow: hidden; + isolation: isolate; } -.process__step { - color: var(--accent); - font-size: 14px; +.process__num { + position: absolute; + inset-inline-end: -0.05em; + top: 0.15em; + z-index: 0; + /* Force Montserrat — IranYekan remaps ASCII 1–9 to Persian glyphs (0 often stays Latin) */ + font-family: var(--font-montserrat, "Montserrat"), "Montserrat", sans-serif; + font-size: clamp(4.5rem, 8vw, 7.5rem); + font-weight: 500; + line-height: 0.85; + letter-spacing: -0.04em; + text-transform: none; + color: #fff; + opacity: 0.07; + pointer-events: none; + user-select: none; + unicode-bidi: isolate; +} + +.process__item h3, +.process__item p { + position: relative; + z-index: 1; } .process__item h3 { - font-size: 19px; + font-size: clamp(15px, 1.25vw, 18px); color: #fff; } .process__item p { color: rgba(255, 255, 255, 0.66); - font-size: 15px; + font-size: clamp(14px, 1.1vw, 16px); } @media (max-width: 900px) { @@ -690,23 +2261,47 @@ h3 { /* ---------- Stats ---------- */ +.stats-band { + width: 100%; + border-block: 1px solid var(--line); + background-color: var(--surface); + background-image: + linear-gradient(rgba(17, 17, 17, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(17, 17, 17, 0.035) 1px, transparent 1px); + background-size: 28px 28px; + background-position: center; +} + .stats { display: grid; grid-template-columns: repeat(4, 1fr); - border-top: 1px solid var(--line); + width: 100%; + max-width: var(--max); + margin-inline: auto; + padding-inline: var(--gutter); } .stat { - padding: 32px 0; - border-bottom: 1px solid var(--line); + padding: clamp(36px, 5vw, 56px) 16px; display: grid; - gap: 4px; + gap: 8px; + justify-items: center; + text-align: center; + border-inline-start: 1px solid rgba(17, 17, 17, 0.08); +} + +.stat:first-child { + border-inline-start: 0; } .stat strong { + font-family: var(--font-montserrat, "Montserrat"), "Montserrat", sans-serif; font-size: clamp(30px, 3.5vw, 46px); font-weight: 500; line-height: 1.2; + letter-spacing: 0.04em; + text-transform: none; + unicode-bidi: isolate; } .stat span { @@ -717,23 +2312,85 @@ h3 { @media (max-width: 720px) { .stats { grid-template-columns: repeat(2, 1fr); - gap: 0 24px; + } + + .stat:nth-child(2n + 1) { + border-inline-start: 0; + } + + .stat:nth-child(n + 3) { + border-top: 1px solid rgba(17, 17, 17, 0.08); } } /* ---------- CTA ---------- */ -.cta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 32px; - flex-wrap: wrap; +.cta-band { + position: relative; + overflow: hidden; + background: + linear-gradient(135deg, rgba(227, 0, 43, 0.14) 0%, transparent 42%), + linear-gradient(0deg, #141414, #111); + color: #fff; + padding-block: clamp(48px, 7vw, 88px); + border-block: 1px solid rgba(255, 255, 255, 0.08); } -.cta h2 { - font-size: clamp(24px, 3vw, 40px); +.cta-band__glow { + position: absolute; + inset-inline-start: -12%; + bottom: -45%; + width: min(560px, 70vw); + height: min(560px, 70vw); + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.07) 0%, transparent 68%); + pointer-events: none; +} + +.cta-band::after { + content: "HOM"; + position: absolute; + inset-inline-end: var(--gutter); + top: 50%; + transform: translateY(-50%); + font-family: var(--font-latin); + font-size: clamp(4.5rem, 14vw, 11rem); + font-weight: 600; + letter-spacing: 0.08em; + line-height: 1; + color: rgba(255, 255, 255, 0.04); + pointer-events: none; + user-select: none; +} + +.cta { + position: relative; + z-index: 1; + display: block; +} + +.cta .section-title { + align-items: center; + margin-bottom: 0; + gap: clamp(24px, 4vw, 56px); +} + +.cta .section-title__fa { + font-size: clamp(20px, 2.6vw, 32px); max-width: 22ch; + white-space: normal; + line-height: 1.45; +} + +.cta .section-title__aside { + max-width: none; +} + +@media (max-width: 720px) { + .cta-band::after { + opacity: 0.7; + inset-inline-end: 4%; + } } /* ---------- Page hero (about / contact) ---------- */ @@ -743,11 +2400,17 @@ h3 { 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 h1, +.page-hero .section-title__fa { + font-size: clamp(22px, 3vw, 36px); + font-weight: 400; + line-height: 1.4; + max-width: none; + white-space: nowrap; +} + +.page-hero .section-title__rail { + max-width: 420px; } .page-hero__frame { @@ -755,29 +2418,69 @@ h3 { max-height: 70vh; } +@media (max-width: 900px) { + .page-hero .section-title__fa, + .contact-hero .section-title__fa { + white-space: normal; + } + + .header__tools { + gap: 12px; + } +} + /* ---------- About ---------- */ .about-text { display: grid; - gap: 20px; - font-size: clamp(16px, 1.3vw, 19px); + gap: 16px; + font-size: clamp(14px, 1.1vw, 16px); + line-height: 1.85; color: var(--muted); } +.about-text p { + text-align: justify; + text-align-last: right; +} + .about-text p:first-child { color: var(--fg); - font-size: clamp(20px, 2vw, 28px); - line-height: 1.7; + font-size: clamp(15px, 1.25vw, 18px); + line-height: 1.8; +} + +.about-text ul { + margin: 0; + padding-inline-start: 18px; + display: grid; + gap: 8px; + font-size: inherit; +} + +.about-text li { + text-align: justify; + text-align-last: right; +} + +.about-text li strong { + color: var(--fg); + font-weight: 500; } .about-gallery { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(2, 1fr); gap: clamp(12px, 2vw, 28px); } +.about-gallery--pair { + grid-template-columns: 1fr; + max-width: 920px; +} + .about-gallery .frame { - aspect-ratio: 1 / 1; + width: 100%; } @media (max-width: 720px) { @@ -786,6 +2489,34 @@ h3 { } } +.brands__grid { + list-style: none; + margin: clamp(28px, 4vw, 48px) 0 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: clamp(16px, 2.5vw, 32px); + align-items: center; +} + +.brands__logo { + background: transparent; +} + +.brands__logo img { + object-fit: contain; + padding: 12px; +} + +.brands__caption { + display: grid; + gap: 4px; + margin: 10px 0 0; + text-align: center; + font-size: 13px; + color: var(--muted); +} + .values { display: grid; grid-template-columns: repeat(3, 1fr); @@ -794,22 +2525,47 @@ h3 { } .value { - border-top: 1px solid var(--line-strong); + border-top: 1px solid var(--line); padding-top: 20px; + display: flex; + align-items: stretch; + gap: 18px; +} + +.value__icon { + flex: none; + align-self: stretch; + color: var(--fg); +} + +.value__icon svg { + display: block; + height: 100%; + width: auto; + aspect-ratio: 1; +} + +.value__body { display: grid; gap: 10px; + min-width: 0; + flex: 1; } .value h3 { + margin: 0; font-size: 19px; } .value p { + margin: 0; color: var(--muted); font-size: 15px; + text-align: justify; + text-align-last: right; } -@media (max-width: 720px) { +@media (max-width: 900px) { .values { grid-template-columns: 1fr; } @@ -817,60 +2573,183 @@ h3 { /* ---------- Contact ---------- */ +.contact-hero { + position: relative; + overflow: hidden; + background: + linear-gradient(160deg, rgba(227, 0, 43, 0.06) 0%, transparent 42%), + linear-gradient(0deg, var(--surface), var(--surface)); + border-bottom: 1px solid var(--line); + padding-top: clamp(48px, 7vw, 88px); + padding-bottom: clamp(36px, 5vw, 64px); +} + +.contact-hero__glow { + position: absolute; + inset-inline-end: -8%; + top: -30%; + width: min(520px, 55vw); + height: min(520px, 55vw); + border-radius: 50%; + background: radial-gradient(circle, rgba(17, 17, 17, 0.06) 0%, transparent 68%); + pointer-events: none; +} + +.contact-hero__inner { + position: relative; + z-index: 1; +} + +.contact-hero .section-title__fa { + font-size: clamp(24px, 3.4vw, 40px); + font-weight: 400; + line-height: 1.35; + max-width: none; + white-space: nowrap; +} + +.contact-hero .section-title__rail { + max-width: 420px; +} + +.contact-hero .section-title__aside { + max-width: 42ch; +} + +.contact-hero__lead { + margin: 0; + font-size: clamp(14px, 1.2vw, 16px); + line-height: 1.85; + color: var(--muted); + text-align: justify; + text-align-last: right; +} + +.contact-map { + position: relative; + width: 100%; + background: #111; + border-block: 1px solid rgba(255, 255, 255, 0.08); +} + +.contact-map--dark .contact-map__frame { + filter: invert(1) hue-rotate(180deg) brightness(0.92) contrast(0.92) saturate(0.55); +} + +.contact-map__frame { + display: block; + width: 100%; + height: clamp(280px, 42vw, 460px); + border: 0; +} + +.contact-map__frame--google { + background: #1d1d1d; +} + +.contact-map__open { + position: absolute; + inset-inline-start: var(--gutter); + bottom: 16px; + z-index: 1; + padding: 8px 14px; + background: #111; + border: 1px solid rgba(255, 255, 255, 0.28); + font-size: 13px; + color: #fff; + transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease; +} + +.contact-map__open:hover { + border-color: #fff; + background: #fff; + color: #111; +} + .contact-grid { display: grid; - grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + grid-template-columns: minmax(0, 4.5fr) minmax(0, 7.5fr); gap: clamp(32px, 6vw, 96px); align-items: start; } .contact-facts { display: grid; - gap: 28px; + gap: 0; } .fact { + display: flex; + align-items: center; + gap: 16px; border-top: 1px solid var(--line); - padding-top: 16px; - display: grid; - gap: 4px; + padding-block: 18px; } -.fact span { - color: var(--muted); - font-size: 13px; +.fact:last-child { + border-bottom: 1px solid var(--line); +} + +.fact__icon { + flex: none; + width: 22px; + height: 22px; + color: var(--fg); +} + +.fact__icon svg { + display: block; + width: 100%; + height: 100%; +} + +.fact__body { + display: grid; + gap: 4px; + min-width: 0; } .fact a, .fact strong { - font-size: 17px; + font-size: 16px; font-weight: 500; } +.fact a.latin { + font-family: var(--font-latin); + font-size: 15px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: none; +} + .fact a:hover { color: var(--accent); } +.fact a[dir="ltr"] { + text-align: right; +} + .fact--phones a { display: block; direction: ltr; text-align: right; } -.contact-frame { - aspect-ratio: 4 / 5; - max-height: 520px; +.contact-form-panel { + min-width: 0; } .form { display: grid; - gap: 20px; + gap: 14px; } .form__row { display: grid; grid-template-columns: 1fr 1fr; - gap: 20px; + gap: 14px; } @media (max-width: 640px) { @@ -880,38 +2759,70 @@ h3 { } .field { - display: grid; - gap: 8px; + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + background: var(--surface); + border: 1px solid var(--line); + padding: 0 14px; + transition: border-color 0.2s ease, background-color 0.2s ease; } -.field label { - font-size: 14px; +.field--wide { + align-items: flex-start; + padding-block: 12px; +} + +.field:focus-within { + border-color: var(--fg); + background: var(--bg); +} + +.field__icon { + flex: none; + width: 18px; + height: 18px; + margin-top: 2px; color: var(--muted); } +.field__icon svg { + display: block; + width: 100%; + height: 100%; +} + +.field:focus-within .field__icon { + color: var(--fg); +} + .field input, .field textarea { width: 100%; + min-width: 0; font: inherit; - font-size: 16px; + font-size: 15px; color: var(--fg); background: transparent; border: 0; - border-bottom: 1px solid var(--line-strong); - padding: 10px 0; + padding: 14px 0; outline: none; border-radius: 0; - transition: border-color 0.2s ease; -} - -.field input:focus, -.field textarea:focus { - border-color: var(--accent); + box-shadow: none; } .field textarea { min-height: 140px; resize: vertical; + line-height: 1.7; + padding-block: 2px 4px; +} + +.field input::placeholder, +.field textarea::placeholder { + color: var(--muted); + opacity: 0.85; } .form__footer { @@ -919,6 +2830,7 @@ h3 { align-items: center; gap: 24px; flex-wrap: wrap; + margin-top: 8px; } .form__status { @@ -934,6 +2846,16 @@ h3 { color: var(--accent); } +@media (max-width: 900px) { + .contact-grid { + grid-template-columns: 1fr; + } + + .contact-hero .section-title__fa { + white-space: normal; + } +} + /* ---------- Footer ---------- */ .footer { @@ -1036,3 +2958,634 @@ h3 { .notfound h1 { font-size: clamp(28px, 4vw, 48px); } + +/* ---------- Instagram follow (Ayoubi-style frame) ---------- */ + +.follow { + padding-block: clamp(56px, 8vw, 96px); + border-top: 1px solid var(--line); + background: var(--bg); +} + +.follow__grid { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + direction: ltr; + align-items: center; + gap: clamp(28px, 5vw, 64px); +} + +.follow__copy { + direction: rtl; + display: grid; + gap: 14px; + justify-items: start; +} + +.follow__kicker { + margin: 0; + color: var(--muted); + font-size: 12px; +} + +.follow__copy h2 { + margin: 0; + font-size: clamp(1.45rem, 2.4vw, 2rem); + font-weight: 500; + line-height: 1.45; +} + +.follow__lead { + margin: 0; + max-width: 34ch; + color: var(--muted); + font-size: 15px; + line-height: 1.85; +} + +.follow__photo { + position: relative; + justify-self: start; + width: min(100%, 18.5rem); + margin: 1.25rem 1.25rem 1.25rem 0; +} + +.follow__photo::before { + content: ""; + position: absolute; + z-index: 0; + top: 1.15rem; + right: -1.15rem; + bottom: -1.15rem; + left: 1.15rem; + border: 1px solid var(--accent); +} + +.follow__photo .follow__frame, +.follow__photo a { + position: relative; + z-index: 1; + display: block; +} + +.follow__photo img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +@media (max-width: 900px) { + .follow__grid { + grid-template-columns: 1fr; + direction: rtl; + justify-items: center; + } + + .follow__copy { + justify-items: center; + text-align: center; + } + + .follow__photo { + justify-self: center; + margin: 1.25rem 0 0; + } +} + +/* ---------- Details frame (home video split) ---------- */ + +.details-frame { + padding-block: clamp(56px, 8vw, 96px); + border-top: 1px solid var(--line); + background: var(--bg); +} + +.details-frame__grid { + display: grid; + grid-template-columns: minmax(0, 34rem) minmax(0, 26rem); + gap: clamp(20px, 3vw, 36px); + direction: ltr; + align-items: center; + justify-content: center; +} + +.details-frame__copy { + direction: rtl; + display: grid; + gap: 18px; + justify-items: start; + align-content: center; +} + +.details-frame__copy .section-title { + margin-bottom: 0; + width: 100%; +} + +.details-frame__copy .section-title__fa { + white-space: normal; + max-width: 14ch; +} + +.details-frame__body { + margin: 0; + max-width: 42ch; + color: var(--muted); + font-size: clamp(15px, 1.1vw, 17px); + line-height: 1.9; +} + +.details-frame__photo { + position: relative; + width: 100%; + margin: 1.35rem 1.35rem 1.35rem 0; +} + +.details-frame__photo::before { + content: ""; + position: absolute; + z-index: 0; + top: 1.25rem; + right: -1.25rem; + bottom: -1.25rem; + left: 1.25rem; + border: 1px solid var(--accent); +} + +.details-frame__frame { + position: relative; + z-index: 1; + display: block; + overflow: hidden; + background: var(--surface); +} + +.details-frame__video { + display: block; + width: 100%; + height: auto; + aspect-ratio: 9 / 16; + object-fit: contain; + background: #111; +} + +.details-frame__play { + position: absolute; + inset-inline-end: 1rem; + bottom: 1rem; + z-index: 2; + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + border: 1px solid rgba(255, 255, 255, 0.55); + border-radius: 50%; + background: rgba(17, 17, 17, 0.45); + color: #fff; + cursor: pointer; + transition: background 0.2s ease, opacity 0.2s ease, transform 0.2s ease; +} + +.details-frame__play svg { + width: 1.1rem; + height: 1.1rem; +} + +.details-frame__play:hover { + background: rgba(17, 17, 17, 0.7); + transform: scale(1.04); +} + +.details-frame__play--playing { + opacity: 0; +} + +.details-frame__frame:hover .details-frame__play--playing, +.details-frame__play--playing:focus-visible { + opacity: 1; +} + +@media (max-width: 900px) { + .details-frame__grid { + grid-template-columns: 1fr; + direction: rtl; + justify-items: center; + } + + .details-frame__copy { + order: 1; + width: 100%; + justify-items: center; + text-align: center; + } + + .details-frame__copy .section-title { + justify-content: center; + } + + .details-frame__copy .section-title__main { + display: grid; + justify-items: center; + text-align: center; + } + + .details-frame__copy .section-title__fa { + max-width: none; + } + + .details-frame__body { + max-width: 42ch; + text-align: center; + } + + .details-frame__photo { + order: 2; + width: min(100%, 22rem); + justify-self: center; + margin: 1.25rem 0 0; + } + + .details-frame__play--playing { + opacity: 0.85; + } +} + +/* ---------- Account menu ---------- */ + +.account-menu { + position: relative; + display: inline-flex; + align-items: center; +} + +.account-menu--login, +.account-menu--loading, +.account-menu__trigger { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fg); + font-size: 14px; + background: transparent; + border: 0; + padding: 0; + cursor: pointer; +} + +.account-menu--loading { + opacity: 0.4; +} + +.account-menu--login:hover, +.account-menu__trigger:hover { + color: var(--accent); +} + +.account-menu__name { + max-width: 9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-menu__dropdown { + position: absolute; + top: calc(100% + 10px); + inset-inline-end: 0; + min-width: 180px; + background: #fff; + border: 1px solid var(--line); + z-index: 70; +} + +.account-menu__item { + display: block; + width: 100%; + padding: 12px 16px; + border: 0; + background: transparent; + color: var(--fg); + font-size: 14px; + text-align: right; + cursor: pointer; +} + +.account-menu__item:hover { + background: var(--surface); + color: var(--accent); +} + +.account-menu__logout { + color: var(--accent); +} + +/* ---------- Portfolio / blog detail ---------- */ + +.portfolio-detail, +.blog-detail { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); + gap: clamp(28px, 4vw, 56px); + align-items: start; +} + +.portfolio-detail__copy, +.blog-detail__copy { + display: grid; + gap: 18px; + align-content: start; +} + +.portfolio-detail__copy .lead, +.blog-detail__copy .lead { + margin: 0; + font-size: clamp(16px, 1.3vw, 18px); + line-height: 1.85; + color: var(--muted); +} + +.rich-text { + display: grid; + gap: 14px; + font-size: 15px; + line-height: 1.9; + color: var(--fg); +} + +.rich-text p { + margin: 0; +} + +.page-hero__aside { + display: grid; + gap: 6px; + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.projects--related { + margin-top: clamp(28px, 4vw, 40px); +} + +.empty-state { + margin: 0; + padding: 48px 0; + text-align: center; + color: var(--muted); +} + +.pager { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid var(--line); + font-size: 14px; +} + +.pager a:hover { + color: var(--accent); +} + +.blog-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; +} + +.blog-card { + display: grid; + gap: 14px; + border: 1px solid #efeeeb; + padding: 8px; + transition: border-color 0.35s ease; +} + +.blog-card:hover { + border-color: var(--fg); +} + +.blog-card__media { + aspect-ratio: 4 / 3; +} + +.blog-card__media img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.6s ease; +} + +.blog-card:hover .blog-card__media img { + transform: scale(1.03); +} + +.blog-card__body { + display: grid; + gap: 8px; + padding: 4px 4px 10px; +} + +.blog-card__date { + color: var(--muted); + font-size: 11px; +} + +.blog-card h2 { + margin: 0; + font-size: 18px; + font-weight: 500; +} + +.blog-card p { + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.7; +} + +.blog-detail__media { + aspect-ratio: 16 / 10; +} + +.blog-detail__media img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* ---------- Media gallery + lightbox ---------- */ + +.media-gallery__stage { + position: relative; + border: 1px solid var(--line); + line-height: 0; + cursor: zoom-in; + overflow: hidden; + background: var(--surface); +} + +.media-gallery__stage > img { + display: block; + width: 100%; + height: auto; +} + +.media-gallery__zoom { + display: none; + position: absolute; + inset: 0; + background-repeat: no-repeat; + background-size: 220%; + pointer-events: none; +} + +@media (hover: hover) and (min-width: 901px) { + .media-gallery__zoom { + display: block; + } +} + +.media-gallery__nav, +.media-lightbox__nav { + position: absolute; + top: 50%; + inset-inline-start: 0.6rem; + translate: 0 -50%; + width: 2.2rem; + height: 2.2rem; + border: 1px solid rgba(255, 255, 255, 0.35); + background: rgba(0, 0, 0, 0.55); + color: #fff; + cursor: pointer; +} + +.media-gallery__nav--next, +.media-lightbox__nav--next { + inset-inline-start: auto; + inset-inline-end: 0.6rem; +} + +.media-gallery__thumbs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0.75rem 0 0; + padding: 0; + list-style: none; +} + +.media-gallery__thumbs button { + width: 4rem; + height: 3.2rem; + padding: 0; + border: 1px solid var(--line); + background: var(--surface); + cursor: pointer; +} + +.media-gallery__thumbs button.is-active { + border-color: var(--fg); +} + +.media-gallery__thumbs img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.media-lightbox { + position: fixed; + inset: 0; + z-index: 90; + display: flex; + flex-direction: column; + background: rgba(0, 0, 0, 0.94); +} + +.media-lightbox__bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + color: rgba(255, 255, 255, 0.7); +} + +.media-lightbox__bar button { + border: 1px solid rgba(255, 255, 255, 0.35); + background: transparent; + color: #fff; + padding: 0.35rem 0.8rem; + cursor: pointer; +} + +.media-lightbox__body { + position: relative; + flex: 1; + min-height: 0; +} + +.media-lightbox__body img { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* ---------- Toast ---------- */ + +.toast-stack { + position: fixed; + inset-inline: 0; + bottom: 1.25rem; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + pointer-events: none; + padding-inline: 1rem; +} + +.toast { + pointer-events: auto; + max-width: 22rem; + border: 1px solid var(--fg); + background: #fff; + color: var(--fg); + padding: 0.8rem 1.1rem; + font-size: 0.92rem; +} + +.toast--success { + border-color: var(--fg); +} + +.toast--error { + border-color: var(--accent); + color: var(--accent); +} + +@media (max-width: 900px) { + .portfolio-detail, + .blog-detail { + grid-template-columns: 1fr; + } + + .blog-grid { + grid-template-columns: 1fr 1fr; + } + +} + +@media (max-width: 560px) { + .blog-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dd6d0cd..02b764c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,11 @@ import type { Metadata } from "next"; +import { Montserrat } from "next/font/google"; import localFont from "next/font/local"; import { CmsMeta, JsonLd } from "@/components/CmsMeta"; import { Footer } from "@/components/Footer"; import { Header } from "@/components/Header"; +import { Providers } from "@/components/Providers"; import { site } from "@/data/site"; import { siteUrl } from "@/lib/config"; import { @@ -14,7 +16,7 @@ import { resolveName, resolvePhones, } from "@/lib/content"; -import { getBusinessInfo, getFavicon } from "@/lib/meshkee"; +import { getBusinessId, getBusinessInfo, getFavicon } from "@/lib/meshkee"; import "./globals.css"; @@ -29,10 +31,24 @@ const yekan = localFont({ display: "swap", }); +const montserrat = Montserrat({ + subsets: ["latin"], + weight: ["400", "500", "600"], + variable: "--font-montserrat", + display: "swap", +}); + export async function generateMetadata(): Promise<Metadata> { const [favicon, info] = await Promise.all([getFavicon(), getBusinessInfo()]); const name = resolveName(info); - const icons = favicon.faviconUrl ? [{ url: favicon.faviconUrl, type: "image/png" }] : undefined; + const icons = favicon.faviconUrl + ? [{ url: favicon.faviconUrl, type: "image/png" }] + : [{ url: "/icon.png", type: "image/png" }]; + const otherMeta = Object.fromEntries( + (info.metaTags ?? []) + .filter((tag) => tag.name && tag.content != null && !tag.html) + .map((tag) => [tag.name as string, tag.content as string]), + ); return { metadataBase: new URL(siteUrl), @@ -42,6 +58,7 @@ export async function generateMetadata(): Promise<Metadata> { }, description: site.description, icons, + ...(Object.keys(otherMeta).length ? { other: otherMeta } : {}), openGraph: { type: "website", locale: "fa_IR", @@ -54,7 +71,7 @@ export async function generateMetadata(): Promise<Metadata> { } export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { - const info = await getBusinessInfo(); + const [info, businessId] = await Promise.all([getBusinessInfo(), getBusinessId()]); const name = resolveName(info); const logo = resolveLogo(info); const phones = resolvePhones(info); @@ -62,23 +79,25 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac const jsonLd = info.schema?.enabled ? info.schema.jsonLd : null; return ( - <html lang="fa" dir="rtl" className={yekan.variable}> + <html lang="fa" dir="rtl" className={`${yekan.variable} ${montserrat.variable}`}> <head> <CmsMeta tags={info.metaTags ?? []} /> </head> <body> - <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 ?? []} - /> + <Providers businessId={businessId}> + <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 ?? []} + /> + </Providers> </body> </html> ); diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 7bf474e..b85ebcd 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { SectionTitle } from "@/components/SectionTitle"; + export const metadata: Metadata = { title: "صفحه پیدا نشد", description: "صفحه‌ای که دنبال آن بودید وجود ندارد.", @@ -10,10 +12,7 @@ export const metadata: Metadata = { export default function NotFound() { return ( <section className="container notfound"> - <p className="eyebrow" style={{ justifyContent: "center" }}> - ۴۰۴ - </p> - <h1>صفحه مورد نظر پیدا نشد</h1> + <SectionTitle en="404" title="صفحه مورد نظر پیدا نشد" as="h1" className="section-title--inline" /> <p className="lead" style={{ marginInline: "auto" }}> احتمالاً آدرس اشتباه است یا این صفحه جابه‌جا شده. </p> diff --git a/src/app/page.tsx b/src/app/page.tsx index 7f5583e..ae74896 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,14 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { DetailsFrame } from "@/components/DetailsFrame"; +import { HeroSlider } from "@/components/HeroSlider"; +import { InstagramFollow } from "@/components/InstagramFollow"; +import { OpeningFrame } from "@/components/OpeningFrame"; +import { SectionTitle } from "@/components/SectionTitle"; import { SlotImage } from "@/components/SlotImage"; import { gallery, processSteps, projects, services, site, stats } from "@/data/site"; -import { resolveAbout, resolveName } from "@/lib/content"; +import { resolveAbout, resolveInstagram, resolveLogo, resolveName } from "@/lib/content"; import { getBusinessInfo } from "@/lib/meshkee"; import { getPageSlots, slotImages, slotRatio } from "@/lib/static-images"; @@ -22,47 +27,68 @@ export default async function HomePage() { const [info, slots] = await Promise.all([getBusinessInfo(), getPageSlots("home")]); const name = resolveName(info); const about = resolveAbout(info); + const logo = resolveLogo(info); + const instagram = resolveInstagram(info); - const hero = slotImages("hero", slots, [{ url: gallery.hero, titleFa: "نمای آجری در غروب" }])[0]; - const studio = slotImages("studio-image", slots, [{ url: gallery.studio, titleFa: "ورودی سبز" }])[0]; - const projectSlots = slots.projects?.images ?? []; + const heroSlides = slotImages("slider", slots, gallery.heroSlides); + const categoryImages = slotImages( + "four-category", + slots, + services.map((service) => ({ + url: service.image, + titleFa: service.titleFa, + titleEn: service.titleEn, + subtext: service.text, + linkUrl: `/services/${service.slug}`, + })), + ); + const projectSlots = slotImages( + "project-images", + slots, + projects.map((project) => ({ + url: project.image, + titleFa: project.titleFa, + titleEn: project.titleEn, + subtext: `${project.category} · ${project.year}`, + linkUrl: `/portfolio/${project.slug}`, + })), + ); + const sticky = slotImages("sticky-frame-bg", slots, [ + { url: gallery.studio, titleFa: "ورودی سبز", titleEn: "Studio", subtext: about[2] ?? site.about[2] }, + ])[0]; + const openingImage = sticky.url || gallery.studio; + const instagramImage = slotImages("instagram-row", slots, [ + { + url: gallery.instagram, + titleFa: "ما را در اینستاگرام دنبال کنید", + titleEn: "Follow us", + subtext: "پروژه‌های تازه و جزئیات اجرا را در صفحه اینستاگرام ببینید.", + linkUrl: instagram.href, + }, + ])[0]; + const instagramRatio = slotRatio("instagram-row", slots); + const brandLogos = slotImages("brands", slots, gallery.brands); + const brandRatio = slotRatio("brands", slots); return ( <> - {/* Hero */} - <section className="hero"> - <div className="container"> - <div className="hero__grid"> - <h1 className="hero__title"> - معماری ساده، - <br /> - <em>زندگی روشن</em> - <span className="hero__dot" aria-hidden="true" /> - </h1> - <div className="hero__aside"> - <p> - {name} یک گروه معماری و طراحی داخلی است؛ از طراحی نما و پلان تا جزئیات داخلی و اجرای نهایی. - </p> - <Link href="/contact" className="btn"> - شروع یک پروژه - <ArrowIcon /> - </Link> - </div> - </div> - <SlotImage image={hero} alt="نمونه پروژه استودیو معماری هوم" ratio={slotRatio("hero", slots)} className="hero__frame" priority /> - <div className="hero__caption"> - <span>{hero.titleFa || hero.titleEn || "پروژه منتخب"}</span> - <span className="latin">{site.nameEn}</span> - </div> - </div> - </section> + <HeroSlider + slides={heroSlides} + title="معماری ساده،" + titleLine2="زندگی روشن" + lead={`${name} یک گروه معماری و طراحی داخلی است؛ از طراحی نما و پلان تا جزئیات داخلی و اجرای نهایی.`} + ctaLabel="شروع یک پروژه" + ctaHref="/contact" + /> - {/* Intro */} + {/* Intro — RTL: logo (rightmost) · copy · focus (left) */} <section className="section"> - <div className="container split"> - <div> - <p className="eyebrow">استودیو</p> - <h2 className="intro__text">{about[0]}</h2> + <div className="container intro"> + <div className="intro__brand"> + <img src={logo} alt={name} className="intro__logo" width={480} height={186} /> + </div> + <div className="intro__copy"> + <SectionTitle en="studio" title={about[0]} className="section-title--compact section-title--story" /> </div> <div className="intro__meta"> <p>{about[1] ?? site.about[1]}</p> @@ -71,110 +97,130 @@ export default async function HomePage() { <span>معماری مسکونی، طراحی داخلی، طراحی نما</span> </div> <Link href="/about" className="text-link"> - بیشتر درباره هوم + بیشتر بدانید </Link> </div> </div> </section> - {/* Services */} - <section className="section section--surface"> + {/* four-category — 4 service panels */} + <section className="section section--tight"> <div className="container"> - <div className="section-head"> - <div> - <p className="eyebrow">خدمات</p> - <h2 className="title">چه کاری انجام می‌دهیم</h2> - </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> + <SectionTitle en="services" title="ما در هوم." titleMuted="چه کارهایی انجام می‌دهیم" /> + </div> + <div className="service-panels" role="list"> + {Array.from({ length: 4 }, (_, index) => { + const service = services[index]; + const cms = categoryImages[index]; + const image = cms?.url || service?.image || gallery.heroSlides[0].url; + const titleFa = cms?.titleFa || service?.titleFa || `خدمت ${index + 1}`; + const titleEn = cms?.titleEn || service?.titleEn || ""; + const desc = cms?.subtext || service?.text || ""; + const href = cms?.linkUrl || (service ? `/services/${service.slug}` : "/services"); + const external = /^https?:\/\//.test(href); + return ( + <Link + key={service?.index ?? index} + href={href} + className="service-panel" + role="listitem" + aria-label={`${titleFa}${titleEn ? ` — ${titleEn}` : ""}`} + target={external ? "_blank" : undefined} + rel={external ? "noreferrer" : undefined} + > + <img + src={image!} + alt={`${titleFa}${titleEn ? ` / ${titleEn}` : ""}`} + className="service-panel__image" + loading="lazy" + decoding="async" + /> + <div className="service-panel__shade" aria-hidden="true" /> + <div className="service-panel__body"> + <span className="service-panel__index">{service?.index ?? String(index + 1).padStart(2, "0")}</span> + <h3>{titleFa}</h3> + {titleEn ? <span className="latin">{titleEn}</span> : null} + {desc ? <p className="service-panel__desc">{desc}</p> : null} </div> - <p>{service.text}</p> - </article> - ))} - </div> + </Link> + ); + })} </div> </section> - {/* Projects */} + {/* project-images — masonry */} <section className="section"> <div className="container"> - <div className="section-head"> - <div> - <p className="eyebrow">پروژه‌ها</p> - <h2 className="title">پروژه‌های منتخب</h2> - </div> - <p className="lead" style={{ maxWidth: "34ch" }}> - گزیده‌ای از کارهای اخیر در معماری، طراحی داخلی و نما. - </p> - </div> + <SectionTitle + en="projects" + title="پروژه‌های" + titleMuted="منتخب" + aside={<p className="lead">گزیده‌ای از کارهای اخیر در معماری، طراحی داخلی و نما.</p>} + /> <div className="projects"> - {projectSlots.length - ? projectSlots.map((image, index) => ( - <div key={`${image.url}-${index}`} className="project"> - <SlotImage image={image} alt={image.titleFa || `پروژه ${index + 1}`} ratio={slotRatio("projects", slots)} /> - <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} + {projectSlots.map((image, index) => { + const title = image.titleFa || image.titleEn || `پروژه ${index + 1}`; + const media = { ...image, linkUrl: null }; + const card = ( + <> + <SlotImage image={media} alt={title} /> + <div className="project__meta"> + <h3>{title}</h3> + {image.subtext ? <span>{image.subtext}</span> : 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> - ))} + {image.titleEn ? <p className="project__sub latin">{image.titleEn}</p> : null} + </> + ); + if (image.linkUrl) { + const external = /^https?:\/\//.test(image.linkUrl); + return ( + <Link + key={`${image.url}-${index}`} + href={image.linkUrl} + className="project" + target={external ? "_blank" : undefined} + rel={external ? "noreferrer" : undefined} + > + {card} + </Link> + ); + } + return ( + <div key={`${image.url}-${index}`} className="project"> + {card} + </div> + ); + })} </div> </div> </section> - {/* Studio */} - <section className="section section--line-top"> - <div className="container split split--equal"> - <SlotImage image={studio} alt="ورودی یکی از پروژه‌های استودیو هوم" ratio={slotRatio("studio-image", slots)} className="studio__frame" /> - <div className="studio__body"> - <div> - <p className="eyebrow">نگاه ما</p> - <h2 className="title">فضایی که با شما زندگی می‌کند</h2> - </div> - <p> - ما طراحی را از نیاز واقعی آغاز می‌کنیم؛ نه از فرم. نور، مسیر حرکت، متریال و تناسب، ابزار ما برای ساختن فضایی هستند که - سال‌ها بعد هم آرام و خوانا بماند. - </p> - <p>{about[2] ?? site.about[2]}</p> - <Link href="/about" className="btn btn--ghost"> - درباره استودیو - <ArrowIcon /> - </Link> - </div> - </div> - </section> + <DetailsFrame /> + + {/* sticky-frame-bg */} + <OpeningFrame + image={openingImage} + imageAlt={sticky.titleFa || sticky.titleEn || "نگاه استودیو هوم"} + eyebrow={sticky.titleEn || "نگاه ما"} + headline="معماری ساده،" + headlineMuted="زندگی روشن" + headlineEn="SIMPLE ARCHITECTURE, BRIGHT LIVING" + body={sticky.subtext || about[2] || site.about[2]} + ctaLabel="درباره استودیو" + ctaHref="/about" + /> {/* Process */} <section className="section section--dark"> <div className="container"> - <p className="eyebrow eyebrow--light">فرآیند کار</p> - <h2 className="title">از ایده تا تحویل، در چهار گام</h2> + <SectionTitle en="process" title="از ایده تا تحویل،" titleMuted="در چهار گام" light /> <div className="process"> {processSteps.map((item) => ( <div key={item.step} className="process__item"> - <span className="process__step">گام {item.step}</span> + <span className="process__num latin" lang="en" dir="ltr" aria-hidden="true"> + {String(item.step).padStart(2, "0")} + </span> <h3>{item.titleFa}</h3> <p>{item.text}</p> </div> @@ -184,30 +230,68 @@ export default async function HomePage() { </section> {/* Stats */} - <section className="section--tight"> - <div className="container"> - <div className="stats"> - {stats.map((stat) => ( - <div key={stat.label} className="stat"> - <strong>{stat.value}</strong> - <span>{stat.label}</span> - </div> - ))} - </div> + <section className="stats-band" aria-label="آمار استودیو"> + <div className="stats"> + {stats.map((stat) => ( + <div key={stat.label} className="stat"> + <strong className="latin" lang="en" dir="ltr"> + {stat.value} + </strong> + <span>{stat.label}</span> + </div> + ))} </div> </section> - {/* CTA */} - <section className="section"> - <div className="container cta"> - <div> - <p className="eyebrow">شروع همکاری</p> - <h2>پروژه‌ای در ذهن دارید؟ درباره‌اش صحبت کنیم.</h2> + {/* brands */} + {brandLogos.length ? ( + <section className="section section--tight brands" aria-label="همکاران"> + <div className="container"> + <SectionTitle en="partners" title="برندها و" titleMuted="همکاران" /> + <ul className="brands__grid"> + {brandLogos.map((brand, index) => { + const alt = brand.titleFa || brand.titleEn || `برند ${index + 1}`; + return ( + <li key={`${brand.url}-${index}`}> + <SlotImage image={brand} alt={alt} ratio={brandRatio} className="brands__logo" /> + {brand.titleFa || brand.titleEn ? ( + <p className="brands__caption"> + {brand.titleFa ? <span>{brand.titleFa}</span> : null} + {brand.titleEn ? <span className="latin">{brand.titleEn}</span> : null} + </p> + ) : null} + </li> + ); + })} + </ul> </div> - <Link href="/contact" className="btn"> - تماس با ما - <ArrowIcon /> - </Link> + </section> + ) : null} + + {/* instagram-row */} + <InstagramFollow + handle={instagram.handle} + href={instagram.href} + image={instagramImage} + ratio={instagramRatio} + /> + + {/* CTA */} + <section className="cta-band"> + <div className="cta-band__glow" aria-hidden="true" /> + <div className="container cta"> + <SectionTitle + en="collaborate" + title="پروژه‌ای در ذهن دارید؟" + titleMuted="درباره‌اش صحبت کنیم." + className="section-title--inline section-title--light" + aside={ + <Link href="/contact" className="btn btn--light"> + تماس با ما + <ArrowIcon /> + </Link> + } + /> </div> </section> </> diff --git a/src/app/portfolio/[slug]/page.tsx b/src/app/portfolio/[slug]/page.tsx new file mode 100644 index 0000000..b89d3d6 --- /dev/null +++ b/src/app/portfolio/[slug]/page.tsx @@ -0,0 +1,126 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { MediaGallery } from "@/components/MediaGallery"; +import { SectionTitle } from "@/components/SectionTitle"; +import { + asRichHtml, + fetchPortfolioById, + fetchPortfolioBySlug, + fetchPortfolios, + getLocalPortfolio, + getLocalPortfolios, + portfolioCover, + portfolioHref, + portfolioImages, + portfolioTitle, + type PortfolioItem, +} from "@/lib/portfolio"; + +export const revalidate = 300; + +type PageProps = { params: Promise<{ slug: string }> }; + +async function resolvePortfolio(slug: string): Promise<PortfolioItem | null> { + const decoded = decodeURIComponent(slug); + const bySlug = await fetchPortfolioBySlug(decoded); + if (bySlug) return bySlug; + const byId = await fetchPortfolioById(decoded); + if (byId) return byId; + return getLocalPortfolio(decoded); +} + +export async function generateMetadata({ params }: PageProps): Promise<Metadata> { + const { slug } = await params; + const portfolio = await resolvePortfolio(slug); + if (!portfolio) return { title: "نمونه کار پیدا نشد" }; + const title = portfolioTitle(portfolio); + const description = portfolio.abstract || title; + const image = portfolioCover(portfolio); + return { + title, + description, + openGraph: { title, description, images: [{ url: image, alt: title }] }, + }; +} + +export default async function PortfolioDetailPage({ params }: PageProps) { + const { slug } = await params; + const portfolio = await resolvePortfolio(slug); + if (!portfolio) notFound(); + + const title = portfolioTitle(portfolio); + const html = asRichHtml(portfolio.mainTextHtml || portfolio.abstract); + const images = portfolioImages(portfolio); + + const related = await fetchPortfolios({ page: 1, pageSize: 8 }); + const pool = related.items.length ? related.items : getLocalPortfolios(); + const similar = pool.filter((item) => item.id !== portfolio.id).slice(0, 3); + + return ( + <> + <section className="page-hero"> + <div className="container"> + <SectionTitle + en="project" + title={title} + as="h1" + className="section-title--inline" + aside={ + portfolio.categoryName || portfolio.titleEn ? ( + <p className="page-hero__aside"> + {portfolio.categoryName ? <span>{portfolio.categoryName}</span> : null} + {portfolio.titleEn ? <span className="latin">{portfolio.titleEn}</span> : null} + </p> + ) : null + } + /> + </div> + </section> + + <section className="section--tight"> + <div className="container portfolio-detail"> + <MediaGallery images={images} title={title} /> + <div className="portfolio-detail__copy"> + {portfolio.abstract ? <p className="lead">{portfolio.abstract}</p> : null} + {html ? <div className="rich-text" dangerouslySetInnerHTML={{ __html: html }} /> : null} + {portfolio.projectUrl ? ( + <a className="text-link" href={portfolio.projectUrl} target="_blank" rel="noopener noreferrer"> + مشاهده لینک پروژه + </a> + ) : null} + <Link className="text-link" href="/portfolio"> + بازگشت به نمونه کارها + </Link> + </div> + </div> + </section> + + {similar.length ? ( + <section className="section section--surface"> + <div className="container"> + <SectionTitle en="similar" title="نمونه‌های" titleMuted="مشابه" /> + <div className="projects projects--related"> + {similar.map((item) => { + const itemTitle = portfolioTitle(item); + return ( + <Link key={item.id} href={portfolioHref(item)} className="project"> + <div className="frame"> + <img src={portfolioCover(item)} alt={itemTitle} loading="lazy" decoding="async" /> + </div> + <div className="project__meta"> + <h3>{itemTitle}</h3> + {item.categoryName ? <span>{item.categoryName}</span> : null} + </div> + {item.titleEn ? <p className="project__sub latin">{item.titleEn}</p> : null} + </Link> + ); + })} + </div> + </div> + </section> + ) : null} + </> + ); +} diff --git a/src/app/portfolio/page.tsx b/src/app/portfolio/page.tsx new file mode 100644 index 0000000..6f66a75 --- /dev/null +++ b/src/app/portfolio/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { SectionTitle } from "@/components/SectionTitle"; +import { site } from "@/data/site"; +import { + fetchPortfolios, + getLocalPortfolios, + portfolioCover, + portfolioHref, + portfolioTitle, +} from "@/lib/portfolio"; + +export const revalidate = 300; + +export const metadata: Metadata = { + title: "نمونه کارها", + description: `نمونه کارهای ${site.nameFa} در معماری، طراحی داخلی و طراحی نما.`, +}; + +export default async function PortfolioPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const { page: pageRaw } = await searchParams; + const page = Math.max(1, Number(pageRaw) || 1); + const pageSize = 12; + const result = await fetchPortfolios({ page, pageSize }); + const usingApi = result.items.length > 0; + const items = usingApi ? result.items : page === 1 ? getLocalPortfolios() : []; + const total = usingApi ? result.total : items.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + return ( + <> + <section className="page-hero"> + <div className="container"> + <SectionTitle + en="portfolio" + title="نمونه کارهای" + titleMuted="استودیو" + as="h1" + className="section-title--inline" + /> + </div> + </section> + + <section className="section--tight"> + <div className="container"> + {items.length === 0 ? ( + <p className="empty-state">هنوز نمونه کاری منتشر نشده است.</p> + ) : ( + <div className="projects"> + {items.map((item) => { + const title = portfolioTitle(item); + return ( + <Link key={item.id} href={portfolioHref(item)} className="project"> + <div className="frame"> + <img src={portfolioCover(item)} alt={title} loading="lazy" decoding="async" /> + </div> + <div className="project__meta"> + <h3>{title}</h3> + {item.categoryName || item.abstract ? ( + <span>{item.categoryName || item.abstract}</span> + ) : null} + </div> + {item.titleEn ? <p className="project__sub latin">{item.titleEn}</p> : null} + </Link> + ); + })} + </div> + )} + + {totalPages > 1 && items.length ? ( + <nav className="pager" aria-label="صفحه‌بندی"> + {page > 1 ? <Link href={`/portfolio?page=${page - 1}`}>قبلی</Link> : <span />} + <span className="latin"> + {page} / {totalPages} + </span> + {page < totalPages ? <Link href={`/portfolio?page=${page + 1}`}>بعدی</Link> : <span />} + </nav> + ) : null} + </div> + </section> + </> + ); +} diff --git a/src/app/projects/[slug]/page.tsx b/src/app/projects/[slug]/page.tsx new file mode 100644 index 0000000..72053a5 --- /dev/null +++ b/src/app/projects/[slug]/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; + +type PageProps = { params: Promise<{ slug: string }> }; + +/** Legacy route — Meshkee portfolios live at `/portfolio/[slug]`. */ +export default async function ProjectRedirect({ params }: PageProps) { + const { slug } = await params; + redirect(`/portfolio/${encodeURIComponent(slug)}`); +} diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx new file mode 100644 index 0000000..e1aa626 --- /dev/null +++ b/src/app/projects/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** Legacy route — Meshkee portfolios live at `/portfolio`. */ +export default function ProjectsRedirect() { + redirect("/portfolio"); +} diff --git a/src/app/services/[slug]/page.tsx b/src/app/services/[slug]/page.tsx new file mode 100644 index 0000000..a5c8bb6 --- /dev/null +++ b/src/app/services/[slug]/page.tsx @@ -0,0 +1,125 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { SectionTitle } from "@/components/SectionTitle"; +import { ServiceFrame } from "@/components/ServiceFrame"; +import { services, site } from "@/data/site"; + +type Props = { params: Promise<{ slug: string }> }; + +export function generateStaticParams() { + return services.map((service) => ({ slug: service.slug })); +} + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const { slug } = await params; + const service = services.find((item) => item.slug === slug); + if (!service) return { title: "تخصص" }; + return { + title: service.titleFa, + description: service.text, + openGraph: { + title: `${service.titleFa} | ${site.nameFa}`, + description: service.text, + images: [{ url: service.image, alt: service.titleFa }], + }, + }; +} + +export default async function ServiceDetailPage({ params }: Props) { + const { slug } = await params; + const service = services.find((item) => item.slug === slug); + if (!service) notFound(); + + const others = services.filter((item) => item.slug !== slug); + + return ( + <> + <section className="service-hero"> + <div className="service-hero__media"> + <img src={service.image} alt={service.titleFa} loading="eager" decoding="async" /> + <div className="service-hero__shade" aria-hidden="true" /> + </div> + <div className="container service-hero__content"> + <p className="service-hero__index latin" lang="en" dir="ltr"> + {service.index} + </p> + <SectionTitle + en={service.titleEn} + title={service.titleFa} + as="h1" + className="section-title--inline section-title--light" + /> + <p className="service-hero__text">{service.text}</p> + </div> + </section> + + <ServiceFrame + image={service.frameImage} + imageAlt={`${service.titleFa} — ${site.shortFa}`} + index={service.index} + title={service.titleFa} + titleEn={service.titleEn} + lead={service.lead} + steps={service.steps} + ctaLabel="درخواست مشاوره" + ctaHref="/contact" + /> + + <section className="section"> + <div className="container split"> + <div> + <SectionTitle + en="detail" + title="آنچه در این تخصص" + titleMuted="دریافت می‌کنید" + className="section-title--compact" + /> + </div> + <div className="about-text"> + <p>{service.lead}</p> + <p> + تیم {site.nameFa} این تخصص را از مرحله ایده تا جزئیات اجرایی همراهی می‌کند تا نتیجه نهایی دقیق و هماهنگ با + نیاز شما باشد. + </p> + <Link href="/contact" className="btn" style={{ marginTop: 4, width: "fit-content" }}> + شروع گفتگو + </Link> + </div> + </div> + </section> + + <section className="section section--surface"> + <div className="container"> + <SectionTitle + en="more" + title="بیشتر درباره" + titleMuted="خدمات ما" + aside={ + <Link href="/services" className="text-link"> + همه تخصص‌ها + </Link> + } + /> + <div className="service-related"> + {others.map((item) => ( + <Link key={item.slug} href={`/services/${item.slug}`} className="service-related__card"> + <div className="service-related__media"> + <img src={item.image} alt={item.titleFa} loading="lazy" decoding="async" /> + </div> + <div className="service-related__meta"> + <span className="latin" lang="en" dir="ltr"> + {item.index} + </span> + <h3>{item.titleFa}</h3> + <p className="latin">{item.titleEn}</p> + </div> + </Link> + ))} + </div> + </div> + </section> + </> + ); +} diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx new file mode 100644 index 0000000..b65cf3a --- /dev/null +++ b/src/app/services/page.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { SectionTitle } from "@/components/SectionTitle"; +import { services, site } from "@/data/site"; + +export const metadata: Metadata = { + title: "تخصص‌های ما", + description: `تخصص‌های ${site.nameFa}: طراحی معماری، طراحی داخلی، طراحی نما و نظارت و اجرا.`, +}; + +export default function ServicesPage() { + return ( + <> + <section className="page-hero"> + <div className="container"> + <SectionTitle + en="services" + title="ما در هوم." + titleMuted="چه کارهایی انجام می‌دهیم" + as="h1" + className="section-title--inline" + /> + </div> + </section> + + <section className="section--tight"> + <div className="container"> + <div className="projects"> + {services.map((service) => ( + <Link key={service.slug} href={`/services/${service.slug}`} className="project"> + <div className="frame"> + <img src={service.image} alt={`${service.titleFa} / ${service.titleEn}`} loading="lazy" decoding="async" /> + </div> + <div className="project__meta"> + <h3>{service.titleFa}</h3> + <span className="latin">{service.titleEn}</span> + </div> + <p className="project__sub">{service.text}</p> + </Link> + ))} + </div> + </div> + </section> + </> + ); +} diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx new file mode 100644 index 0000000..acdcd83 --- /dev/null +++ b/src/components/AccountMenu.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useEffect, useRef, useState, type MouseEvent } from "react"; + +import { businessDashboardUrl, customerPortalUrl } from "@/lib/config"; +import { useSession, userDisplayName } from "@/lib/session"; + +function UserIcon() { + return ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"> + <circle cx="12" cy="8" r="3.2" stroke="currentColor" strokeWidth="1.5" /> + <path + d="M5 19.2c1.6-3.2 4-4.7 7-4.7s5.4 1.5 7 4.7" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + /> + </svg> + ); +} + +export function AccountMenu() { + const { user, isAuthenticated, ready, isWebsiteAdmin, logout, getAccessToken } = useSession(); + const [open, setOpen] = useState(false); + const [handoffBusy, setHandoffBusy] = useState(false); + const menuRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!open) return; + const onClickOutside = (event: Event) => { + if (menuRef.current && event.target instanceof Node && !menuRef.current.contains(event.target)) { + setOpen(false); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onClickOutside); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onClickOutside); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + async function openBusinessDashboard(event: MouseEvent<HTMLAnchorElement>) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + event.preventDefault(); + if (handoffBusy) return; + + const baseUrl = businessDashboardUrl; + const popup = window.open("about:blank", "_blank"); + setHandoffBusy(true); + try { + const token = getAccessToken(); + const res = await fetch("/api/auth/handoff", { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (!res.ok) throw new Error("handoff failed"); + const data = (await res.json()) as { ticket?: string }; + const url = new URL(baseUrl); + if (data.ticket) url.searchParams.set("handoff", data.ticket); + if (popup) { + popup.opener = null; + popup.location.replace(url.toString()); + } else { + window.open(url.toString(), "_blank", "noopener,noreferrer"); + } + } catch { + if (popup) popup.location.replace(baseUrl); + else window.open(baseUrl, "_blank", "noopener,noreferrer"); + } finally { + setHandoffBusy(false); + setOpen(false); + } + } + + if (!ready) { + return ( + <span className="account-menu account-menu--loading" aria-hidden="true"> + <UserIcon /> + </span> + ); + } + + if (!isAuthenticated || !user) { + return ( + <a + href={`${customerPortalUrl}/login`} + className="account-menu account-menu--login" + aria-label="ورود" + title="ورود" + > + <UserIcon /> + </a> + ); + } + + const displayName = userDisplayName(user); + + return ( + <div ref={menuRef} className="account-menu account-menu--user"> + <button + type="button" + onClick={() => setOpen((value) => !value)} + aria-expanded={open} + aria-haspopup="menu" + className="account-menu__trigger" + aria-label={`${displayName} عزیز`} + > + <span className="account-menu__name">{displayName} عزیز</span> + </button> + + {open ? ( + <div role="menu" className="account-menu__dropdown"> + <a + role="menuitem" + href={`${customerPortalUrl}/profile`} + className="account-menu__item" + onClick={() => setOpen(false)} + > + پروفایل من + </a> + {isWebsiteAdmin ? ( + <a + role="menuitem" + href={businessDashboardUrl} + className="account-menu__item" + onClick={openBusinessDashboard} + > + {handoffBusy ? "در حال انتقال…" : "مدیریت وبسایت"} + </a> + ) : null} + <button + type="button" + role="menuitem" + className="account-menu__item account-menu__logout" + onClick={() => { + setOpen(false); + logout(); + }} + > + خروج + </button> + </div> + ) : null} + </div> + ); +} diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx index 8cdffac..e8a4cc6 100644 --- a/src/components/ContactForm.tsx +++ b/src/components/ContactForm.tsx @@ -1,11 +1,86 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent, type ReactNode } from "react"; type Status = { tone: "idle" | "pending" | "success" | "error"; message: string }; +function IconUser() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <circle cx="12" cy="8" r="3.2" /> + <path d="M5.5 19.5c1.8-3.2 4-4.8 6.5-4.8s4.7 1.6 6.5 4.8" /> + </svg> + ); +} + +function IconPhone() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M8.5 4.5h3l1.5 4-2 1.2a12 12 0 0 0 5.3 5.3l1.2-2 4 1.5v3A2 2 0 0 1 19.5 19.5 15 15 0 0 1 4.5 4.5 2 2 0 0 1 6.5 2.5h2Z" /> + </svg> + ); +} + +function IconMail() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <rect x="3.5" y="5.5" width="17" height="13" rx="1.5" /> + <path d="m4.5 7.5 7.5 6 7.5-6" /> + </svg> + ); +} + +function IconSubject() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M5 7h14M5 12h10M5 17h7" /> + </svg> + ); +} + +function IconMessage() { + return ( + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M5 6.5h14v9H9l-4 3v-12Z" /> + </svg> + ); +} + +function Field({ + id, + label, + icon, + children, + wide, +}: { + id: string; + label: string; + icon: ReactNode; + children: ReactNode; + wide?: boolean; +}) { + return ( + <div className={`field${wide ? " field--wide" : ""}`}> + <label className="sr-only" htmlFor={id}> + {label} + </label> + <span className="field__icon" aria-hidden="true"> + {icon} + </span> + {children} + </div> + ); +} + export function ContactForm() { const [status, setStatus] = useState<Status>({ tone: "idle", message: "" }); + const [toast, setToast] = useState<{ tone: "success" | "error"; message: string } | null>(null); + + useEffect(() => { + if (!toast) return; + const timer = window.setTimeout(() => setToast(null), 4200); + return () => window.clearTimeout(timer); + }, [toast]); async function onSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); @@ -20,7 +95,9 @@ export function ContactForm() { }; if (!payload.name || !payload.title || !payload.text) { - setStatus({ tone: "error", message: "لطفاً نام، موضوع و متن پیام را کامل کنید." }); + const message = "لطفاً نام، موضوع و متن پیام را کامل کنید."; + setStatus({ tone: "error", message }); + setToast({ tone: "error", message }); return; } @@ -34,69 +111,98 @@ export function ContactForm() { if (!response.ok) { const body = (await response.json().catch(() => ({}))) as { message?: string | string[] }; const message = Array.isArray(body.message) ? body.message.join("، ") : body.message; - // Only surface server messages that are already localized; otherwise use a generic one. - throw new Error(message && /[\u0600-\u06FF]/.test(message) ? message : "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید."); + throw new Error( + message && /[\u0600-\u06FF]/.test(message) + ? message + : "ارسال پیام انجام نشد. لطفاً با شماره تماس استودیو در ارتباط باشید.", + ); } form.reset(); - setStatus({ tone: "success", message: "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم." }); + const success = "پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم."; + setStatus({ tone: "success", message: success }); + setToast({ tone: "success", message: success }); } catch (error) { - setStatus({ - tone: "error", - message: error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید.", - }); + const message = error instanceof Error ? error.message : "ارسال پیام انجام نشد. دوباره تلاش کنید."; + setStatus({ tone: "error", message }); + setToast({ tone: "error", message }); } } return ( - <form className="form" onSubmit={onSubmit} noValidate> - <div className="form__row"> - <div className="field"> - <label htmlFor="contact-name">نام و نام خانوادگی *</label> - <input id="contact-name" name="name" type="text" autoComplete="name" required /> + <> + <form className="form" onSubmit={onSubmit} noValidate> + <div className="form__row"> + <Field id="contact-name" label="نام و نام خانوادگی *" icon={<IconUser />}> + <input + id="contact-name" + name="name" + type="text" + autoComplete="name" + required + placeholder="نام و نام خانوادگی *" + /> + </Field> + <Field id="contact-cell" label="شماره تماس" icon={<IconPhone />}> + <input + id="contact-cell" + name="cellNumber" + type="tel" + inputMode="tel" + autoComplete="tel" + dir="ltr" + placeholder="۰۹۱۲ ۰۰۰ ۰۰۰۰" + /> + </Field> </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 className="form__row"> + <Field id="contact-email" label="ایمیل" icon={<IconMail />}> + <input + id="contact-email" + name="email" + type="email" + autoComplete="email" + dir="ltr" + placeholder="ایمیل" + /> + </Field> + <Field id="contact-title" label="موضوع *" icon={<IconSubject />}> + <input + id="contact-title" + name="title" + type="text" + required + placeholder="موضوع *" + /> + </Field> + </div> + <Field id="contact-text" label="توضیحات پروژه *" icon={<IconMessage />} wide> + <textarea + id="contact-text" + name="text" + required + placeholder="متراژ، محل پروژه، نوع خدمات مورد نیاز و زمان‌بندی مدنظر را بنویسید." /> + </Field> + <div className="form__footer"> + <button type="submit" className="btn" disabled={status.tone === "pending"}> + ارسال پیام + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M4 12h16M13 5l7 7-7 7" /> + </svg> + </button> + <p className="form__status" data-tone={status.tone} role="status" aria-live="polite"> + {status.message} + </p> </div> - </div> - <div className="form__row"> - <div className="field"> - <label htmlFor="contact-email">ایمیل</label> - <input id="contact-email" name="email" type="email" autoComplete="email" dir="ltr" /> + </form> + + {toast ? ( + <div className="toast-stack" aria-live="polite"> + <div className={`toast toast--${toast.tone}`} role="status"> + {toast.message} + </div> </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> + ) : null} + </> ); } diff --git a/src/components/ContactMap.tsx b/src/components/ContactMap.tsx new file mode 100644 index 0000000..27d576a --- /dev/null +++ b/src/components/ContactMap.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +type Props = { + lat: number; + lng: number; + zoom: number; + address: string; + mapLink: string; +}; + +/** Dark, low-clutter Google styles — hide POI / transit icons. */ +const CLEAN_DARK_STYLES: Array<Record<string, unknown>> = [ + { elementType: "geometry", stylers: [{ color: "#1d1d1d" }] }, + { elementType: "labels.icon", stylers: [{ visibility: "off" }] }, + { elementType: "labels.text.fill", stylers: [{ color: "#8a8a8a" }] }, + { elementType: "labels.text.stroke", stylers: [{ color: "#1d1d1d" }] }, + { featureType: "administrative", elementType: "geometry", stylers: [{ visibility: "off" }] }, + { featureType: "poi", stylers: [{ visibility: "off" }] }, + { featureType: "poi.park", stylers: [{ visibility: "off" }] }, + { featureType: "road", elementType: "geometry", stylers: [{ color: "#2c2c2c" }] }, + { featureType: "road", elementType: "labels.icon", stylers: [{ visibility: "off" }] }, + { featureType: "road.arterial", elementType: "geometry", stylers: [{ color: "#3a3a3a" }] }, + { featureType: "road.highway", elementType: "geometry", stylers: [{ color: "#4a4034" }] }, + { featureType: "transit", stylers: [{ visibility: "off" }] }, + { featureType: "water", elementType: "geometry", stylers: [{ color: "#0e0e0e" }] }, +]; + +declare global { + interface Window { + google?: { + maps: { + Map: new ( + el: HTMLElement, + opts: { + center: { lat: number; lng: number }; + zoom: number; + styles?: unknown[]; + disableDefaultUI?: boolean; + zoomControl?: boolean; + mapTypeControl?: boolean; + streetViewControl?: boolean; + fullscreenControl?: boolean; + clickableIcons?: boolean; + }, + ) => unknown; + Marker: new (opts: { position: { lat: number; lng: number }; map: unknown }) => unknown; + }; + }; + } +} + +function loadGoogleMaps(apiKey: string): Promise<void> { + if (window.google?.maps) return Promise.resolve(); + + return new Promise((resolve, reject) => { + const existing = document.querySelector<HTMLScriptElement>("script[data-hom-maps]"); + if (existing) { + existing.addEventListener("load", () => resolve(), { once: true }); + existing.addEventListener("error", () => reject(new Error("maps load failed")), { once: true }); + return; + } + + const script = document.createElement("script"); + script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}`; + script.async = true; + script.defer = true; + script.dataset.homMaps = "1"; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("maps load failed")); + document.head.appendChild(script); + }); +} + +function OsmFallback({ lat, lng, address, mapLink }: Omit<Props, "zoom">) { + const delta = 0.035; + const osmSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - delta}%2C${lat - delta}%2C${lng + delta}%2C${lat + delta}&layer=mapnik&marker=${lat}%2C${lng}`; + return ( + <section className="contact-map contact-map--dark" aria-label="موقعیت روی نقشه"> + <iframe + className="contact-map__frame" + title={`نقشه ${address}`} + src={osmSrc} + loading="lazy" + referrerPolicy="no-referrer-when-downgrade" + /> + <a className="contact-map__open" href={mapLink} target="_blank" rel="noreferrer"> + مشاهده در نقشه + </a> + </section> + ); +} + +/** + * Google Map with places/transit icons hidden. + * Requires `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY` (Maps JavaScript API enabled). + * Falls back to a cleaner OSM embed when the key is missing or fails to load. + */ +export function ContactMap({ lat, lng, zoom, address, mapLink }: Props) { + const hostRef = useRef<HTMLDivElement>(null); + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?.trim() ?? ""; + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (!apiKey || !hostRef.current) return; + let cancelled = false; + + loadGoogleMaps(apiKey) + .then(() => { + if (cancelled || !hostRef.current || !window.google?.maps) { + if (!cancelled) setFailed(true); + return; + } + const map = new window.google.maps.Map(hostRef.current, { + center: { lat, lng }, + zoom, + styles: CLEAN_DARK_STYLES, + disableDefaultUI: true, + zoomControl: true, + mapTypeControl: false, + streetViewControl: false, + fullscreenControl: false, + clickableIcons: false, + }); + new window.google.maps.Marker({ position: { lat, lng }, map }); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + + return () => { + cancelled = true; + }; + }, [apiKey, lat, lng, zoom]); + + if (!apiKey || failed) { + return <OsmFallback lat={lat} lng={lng} address={address} mapLink={mapLink} />; + } + + return ( + <section className="contact-map" aria-label="موقعیت روی نقشه"> + <div ref={hostRef} className="contact-map__frame contact-map__frame--google" role="presentation" /> + <a className="contact-map__open" href={mapLink} target="_blank" rel="noreferrer"> + مشاهده در نقشه + </a> + </section> + ); +} diff --git a/src/components/DetailsFrame.tsx b/src/components/DetailsFrame.tsx new file mode 100644 index 0000000..c686803 --- /dev/null +++ b/src/components/DetailsFrame.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useRef, useState } from "react"; + +import { SectionTitle } from "@/components/SectionTitle"; + +type Props = { + videoSrc?: string; + title?: string; + titleMuted?: string; + body?: string; +}; + +/** + * Container-width split: SectionTitle + copy (left), framed portrait video (right). + */ +export function DetailsFrame({ + videoSrc = "/videos/hom-video.mp4", + title = "دقت در جزئیات،", + titleMuted = "کیفیت در تجربه", + body = "از اتصال متریال تا نورگیر و خط نما، هر جزئیات کوچک بخشی از حس نهایی فضاست. در هوم، جزئیات را نادیده نمی‌گیریم؛ همان‌جایی که طرح ساده، زنده و ماندگار می‌شود.", +}: Props) { + const videoRef = useRef<HTMLVideoElement>(null); + const [playing, setPlaying] = useState(true); + + const togglePlayback = () => { + const video = videoRef.current; + if (!video) return; + if (video.paused) { + void video.play(); + setPlaying(true); + } else { + video.pause(); + setPlaying(false); + } + }; + + return ( + <section className="details-frame" aria-label="جزئیات"> + <div className="container details-frame__grid"> + <div className="details-frame__copy"> + <SectionTitle en="details" title={title} titleMuted={titleMuted} /> + <p className="details-frame__body">{body}</p> + </div> + + <figure className="details-frame__photo"> + <div className="details-frame__frame"> + <video + ref={videoRef} + className="details-frame__video" + src={videoSrc} + autoPlay + muted + loop + playsInline + preload="metadata" + aria-label="نگاهی به جزئیات پروژه‌های استودیو هوم" + onPlay={() => setPlaying(true)} + onPause={() => setPlaying(false)} + /> + <button + type="button" + className={`details-frame__play${playing ? " details-frame__play--playing" : ""}`} + onClick={togglePlayback} + aria-label={playing ? "توقف ویدیو" : "پخش ویدیو"} + > + {playing ? ( + <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> + <rect x="6" y="5" width="4" height="14" rx="0.5" /> + <rect x="14" y="5" width="4" height="14" rx="0.5" /> + </svg> + ) : ( + <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> + <path d="M8 5.5v13l11-6.5L8 5.5Z" /> + </svg> + )} + </button> + </div> + </figure> + </div> + </section> + ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 488489a..0479f77 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { NAV_LINKS, site } from "@/data/site"; +import { NAV_LINKS, services, site } from "@/data/site"; import { stripScripts, toFaDigits, type PhoneLink } from "@/lib/content"; type Props = { @@ -38,12 +38,13 @@ export function Footer({ logo, name, address, phones, emails, instagram, badges </div> <div> - <h3>خدمات</h3> + <h3>تخصص‌ها</h3> <ul> - <li>طراحی معماری</li> - <li>طراحی داخلی</li> - <li>طراحی نما</li> - <li>نظارت و اجرا</li> + {services.map((service) => ( + <li key={service.slug}> + <Link href={`/services/${service.slug}`}>{service.titleFa}</Link> + </li> + ))} </ul> </div> @@ -53,17 +54,21 @@ export function Footer({ logo, name, address, phones, emails, instagram, badges <li>{address}</li> {phones.map((phone) => ( <li key={phone.href}> - <a href={phone.href}>{phone.display}</a> + <a href={phone.href} className="latin" dir="ltr"> + {phone.display} + </a> </li> ))} {emails.map((email) => ( <li key={email}> - <a href={`mailto:${email}`}>{email}</a> + <a href={`mailto:${email}`} className="latin" dir="ltr"> + {email} + </a> </li> ))} <li> <a href={instagram.href} target="_blank" rel="noreferrer"> - اینستاگرام {instagram.handle} + اینستاگرام <span className="latin">{instagram.handle}</span> </a> </li> </ul> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 30d3f66..63fd0c2 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,9 +2,10 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; -import { NAV_LINKS } from "@/data/site"; +import { AccountMenu } from "@/components/AccountMenu"; +import { NAV_LINKS, type NavLink } from "@/data/site"; import type { PhoneLink } from "@/lib/content"; type Props = { @@ -14,18 +15,80 @@ type Props = { instagram: { href: string; handle: string }; }; +function linkActive(pathname: string, href: string) { + if (href === "/") return pathname === "/"; + return pathname === href || pathname.startsWith(`${href}/`); +} + +function NavItem({ link, pathname }: { link: NavLink; pathname: string }) { + const menuId = useId(); + const hasChildren = Boolean(link.children?.length); + const active = linkActive(pathname, link.href); + + if (!hasChildren) { + return ( + <Link href={link.href} aria-current={active ? "page" : undefined}> + {link.label} + </Link> + ); + } + + return ( + <div className="nav__item" data-has-menu=""> + <Link + href={link.href} + className="nav__trigger" + aria-current={active ? "page" : undefined} + aria-haspopup="menu" + aria-controls={menuId} + > + {link.label} + <svg className="nav__caret" viewBox="0 0 12 12" aria-hidden="true"> + <path d="M2 4.5 6 8.5 10 4.5" fill="none" stroke="currentColor" strokeWidth="1.2" /> + </svg> + </Link> + <div id={menuId} className="nav__menu" role="menu"> + {link.children!.map((child) => ( + <Link + key={child.href} + href={child.href} + role="menuitem" + aria-current={linkActive(pathname, child.href) ? "page" : undefined} + > + {child.label} + </Link> + ))} + </div> + </div> + ); +} + export function Header({ logo, name, phone, instagram }: Props) { const pathname = usePathname(); const [open, setOpen] = useState(false); + const [mobileOpen, setMobileOpen] = useState<string | null>(null); useEffect(() => { setOpen(false); + setMobileOpen(null); }, [pathname]); - const isActive = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href)); + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + window.addEventListener("keydown", onKey); + return () => { + document.body.style.overflow = prev; + window.removeEventListener("keydown", onKey); + }; + }, [open]); return ( - <header className="header"> + <header className="header" data-nav-open={open ? "true" : "false"}> <div className="container header__inner"> <Link href="/" className="header__logo" aria-label={name}> <img src={logo} alt={name} width={172} height={66} /> @@ -33,18 +96,17 @@ export function Header({ logo, name, phone, instagram }: Props) { <nav className="nav" aria-label="منوی اصلی"> {NAV_LINKS.map((link) => ( - <Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}> - {link.label} - </Link> + <NavItem key={link.href} link={link} pathname={pathname} /> ))} </nav> <div className="header__tools"> {phone ? ( - <a className="header__phone" href={phone.href}> + <a className="header__phone latin" href={phone.href} dir="ltr"> {phone.display} </a> ) : null} + <AccountMenu /> <button type="button" className="header__burger" @@ -58,17 +120,75 @@ export function Header({ logo, name, phone, instagram }: Props) { </div> </div> - <div id="mobile-nav" className="mobile-nav" data-open={open}> - {NAV_LINKS.map((link) => ( - <Link key={link.href} href={link.href} aria-current={isActive(link.href) ? "page" : undefined}> - {link.label} - </Link> - ))} + <div + id="mobile-nav" + className="mobile-nav" + data-open={open} + hidden={!open} + > + <nav className="mobile-nav__links" aria-label="منوی موبایل"> + {NAV_LINKS.map((link) => { + const active = linkActive(pathname, link.href); + if (!link.children?.length) { + return ( + <Link key={link.href} href={link.href} aria-current={active ? "page" : undefined}> + {link.label} + </Link> + ); + } + + const expanded = mobileOpen === link.href; + return ( + <div key={link.href} className="mobile-nav__group" data-open={expanded}> + <div className="mobile-nav__row"> + <Link href={link.href} aria-current={active ? "page" : undefined}> + {link.label} + </Link> + <button + type="button" + className="mobile-nav__toggle" + aria-expanded={expanded} + aria-label={`${expanded ? "بستن" : "باز کردن"} زیرمنوی ${link.label}`} + onClick={() => setMobileOpen(expanded ? null : link.href)} + > + <svg viewBox="0 0 12 12" aria-hidden="true"> + <path d="M2 4.5 6 8.5 10 4.5" fill="none" stroke="currentColor" strokeWidth="1.2" /> + </svg> + </button> + </div> + {expanded ? ( + <div className="mobile-nav__sub"> + {link.children.map((child) => ( + <Link + key={child.href} + href={child.href} + aria-current={linkActive(pathname, child.href) ? "page" : undefined} + > + {child.label} + </Link> + ))} + </div> + ) : null} + </div> + ); + })} + </nav> + <div className="mobile-nav__meta"> - {phone ? <a href={phone.href}>{phone.display}</a> : null} - <a href={instagram.href} target="_blank" rel="noreferrer"> + {phone ? ( + <a href={phone.href} className="latin" dir="ltr"> + {phone.display} + </a> + ) : null} + <a href={instagram.href} target="_blank" rel="noreferrer" className="latin" dir="ltr"> {instagram.handle} </a> + <Link href="/contact" className="btn mobile-nav__cta" onClick={() => setOpen(false)}> + تماس با ما + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M4 12h16M13 5l7 7-7 7" /> + </svg> + </Link> </div> </div> </header> diff --git a/src/components/HeroSlider.tsx b/src/components/HeroSlider.tsx new file mode 100644 index 0000000..b9dd786 --- /dev/null +++ b/src/components/HeroSlider.tsx @@ -0,0 +1,129 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +import type { StaticImage } from "@/lib/static-images"; + +type Props = { + slides: StaticImage[]; + title: string; + titleLine2: string; + lead: string; + ctaLabel: string; + ctaHref: string; +}; + +const INTERVAL_MS = 6200; + +export function HeroSlider({ slides, title, titleLine2, lead, ctaLabel, ctaHref }: Props) { + const count = slides.length; + const [index, setIndex] = useState(0); + const [paused, setPaused] = useState(false); + /** Bumps on every slide change so CSS enter animations restart. */ + const [enterKey, setEnterKey] = useState(0); + + const go = useCallback( + (next: number) => { + if (!count) return; + const resolved = ((next % count) + count) % count; + if (resolved === index) return; + setIndex(resolved); + setEnterKey((value) => value + 1); + }, + [count, index], + ); + + useEffect(() => { + if (count < 2 || paused) return; + const id = window.setInterval(() => go(index + 1), INTERVAL_MS); + return () => window.clearInterval(id); + }, [count, go, index, paused]); + + if (!count) return null; + + return ( + <section + className="hero-slider" + aria-roledescription="carousel" + aria-label="اسلایدر اصلی" + onMouseEnter={() => setPaused(true)} + onMouseLeave={() => setPaused(false)} + > + <div className="hero-slider__viewport"> + {slides.map((slide, i) => { + const active = i === index; + const frame = ( + <img + key={active ? `in-${enterKey}` : `out-${i}`} + src={slide.url ?? ""} + alt={slide.titleFa || slide.titleEn || `اسلاید ${i + 1}`} + className="hero-slider__image" + loading={i === 0 ? "eager" : "lazy"} + decoding="async" + fetchPriority={i === 0 ? "high" : undefined} + /> + ); + return ( + <div + key={`${slide.url}-${i}`} + className="hero-slider__slide" + data-active={active} + aria-hidden={!active} + > + {slide.linkUrl ? ( + <a + href={slide.linkUrl} + tabIndex={active ? 0 : -1} + target={/^https?:\/\//.test(slide.linkUrl) ? "_blank" : undefined} + rel={/^https?:\/\//.test(slide.linkUrl) ? "noreferrer" : undefined} + > + {frame} + </a> + ) : ( + frame + )} + </div> + ); + })} + + <div className="hero-slider__bar" key={enterKey}> + <div className="hero-slider__panel"> + <h1 className="hero-slider__title hero-slider__seq" style={{ ["--seq" as string]: 1 }}> + {title}{" "} + <em>{titleLine2}</em> + <span className="hero-slider__dot" aria-hidden="true" /> + </h1> + <p className="hero-slider__lead hero-slider__seq" style={{ ["--seq" as string]: 2 }}> + {lead} + </p> + <div className="hero-slider__actions hero-slider__seq" style={{ ["--seq" as string]: 3 }}> + <Link href={ctaHref} className="btn"> + {ctaLabel} + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M4 12h16M13 5l7 7-7 7" /> + </svg> + </Link> + {count > 1 ? ( + <div className="hero-slider__dots" role="tablist" aria-label="انتخاب اسلاید"> + {slides.map((_, i) => ( + <button + key={i} + type="button" + role="tab" + aria-selected={i === index} + aria-label={`اسلاید ${i + 1}`} + className="hero-slider__dot-btn" + data-active={i === index} + onClick={() => go(i)} + /> + ))} + </div> + ) : null} + </div> + </div> + </div> + </div> + </section> + ); +} diff --git a/src/components/InstagramFollow.tsx b/src/components/InstagramFollow.tsx new file mode 100644 index 0000000..42f1bdb --- /dev/null +++ b/src/components/InstagramFollow.tsx @@ -0,0 +1,47 @@ +import { SlotImage } from "@/components/SlotImage"; +import type { StaticImage } from "@/lib/static-images"; + +type Props = { + handle: string; + href: string; + image: StaticImage; + ratio?: string; +}; + +/** + * Instagram follow block — Ayoubi-style framed screenshot + copy. + * Image comes from the `instagram-image` Meshkee slot (or local fallback). + */ +export function InstagramFollow({ handle, href, image, ratio = "9 / 16" }: Props) { + const link = image.linkUrl || href; + const title = image.titleFa?.trim() || "ما را در اینستاگرام دنبال کنید"; + const kicker = image.titleEn?.trim() || "Follow us"; + const lead = + image.subtext?.trim() || + "پروژه‌های تازه، جزئیات اجرا و فضای استودیو را در صفحه اینستاگرام ببینید."; + + return ( + <section className="follow" aria-labelledby="follow-title"> + <div className="container follow__grid"> + <div className="follow__copy"> + <p className="follow__kicker latin" dir="ltr"> + {kicker} + </p> + <h2 id="follow-title">{title}</h2> + <p className="follow__lead">{lead}</p> + <a className="text-link latin" href={link} target="_blank" rel="noreferrer" dir="ltr"> + {handle} + </a> + </div> + <figure className="follow__photo"> + <SlotImage + image={{ ...image, linkUrl: link, url: image.url || "/images/garden-gate.jpg" }} + alt={image.titleFa || image.titleEn || `صفحه اینستاگرام ${handle}`} + ratio={ratio} + className="follow__frame" + /> + </figure> + </div> + </section> + ); +} diff --git a/src/components/MediaGallery.tsx b/src/components/MediaGallery.tsx new file mode 100644 index 0000000..6fb1f99 --- /dev/null +++ b/src/components/MediaGallery.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function MediaGallery({ images, title }: { images: string[]; title: string }) { + const gallery = images.length ? images : ["/images/facade-sunset.jpg"]; + const [active, setActive] = useState(0); + const [zoom, setZoom] = useState<{ x: number; y: number } | null>(null); + const [lightbox, setLightbox] = useState(false); + + const prev = () => setActive((index) => (index - 1 + gallery.length) % gallery.length); + const next = () => setActive((index) => (index + 1) % gallery.length); + + useEffect(() => { + if (!lightbox) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setLightbox(false); + if (event.key === "ArrowLeft") next(); + if (event.key === "ArrowRight") prev(); + }; + document.addEventListener("keydown", onKey); + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = ""; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lightbox, active]); + + return ( + <div className="media-gallery"> + <div + className="media-gallery__stage" + onMouseMove={(event) => { + const rect = event.currentTarget.getBoundingClientRect(); + setZoom({ + x: ((event.clientX - rect.left) / rect.width) * 100, + y: ((event.clientY - rect.top) / rect.height) * 100, + }); + }} + onMouseLeave={() => setZoom(null)} + onClick={() => setLightbox(true)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setLightbox(true); + } + }} + aria-label={`گالری تصاویر ${title}`} + > + <img src={gallery[active]} alt={title} /> + {zoom ? ( + <span + className="media-gallery__zoom" + style={{ + backgroundImage: `url(${gallery[active]})`, + backgroundPosition: `${zoom.x}% ${zoom.y}%`, + }} + aria-hidden + /> + ) : null} + {gallery.length > 1 ? ( + <> + <button + type="button" + className="media-gallery__nav" + aria-label="قبلی" + onClick={(event) => { + event.stopPropagation(); + prev(); + }} + > + › + </button> + <button + type="button" + className="media-gallery__nav media-gallery__nav--next" + aria-label="بعدی" + onClick={(event) => { + event.stopPropagation(); + next(); + }} + > + ‹ + </button> + </> + ) : null} + </div> + + {gallery.length > 1 ? ( + <ul className="media-gallery__thumbs"> + {gallery.map((src, index) => ( + <li key={`${src}-${index}`}> + <button + type="button" + className={index === active ? "is-active" : undefined} + onClick={() => setActive(index)} + aria-label={`تصویر ${index + 1}`} + > + <img src={src} alt="" /> + </button> + </li> + ))} + </ul> + ) : null} + + {lightbox ? ( + <div className="media-lightbox" role="dialog" aria-modal aria-label={`گالری ${title}`}> + <div className="media-lightbox__bar"> + <span className="latin"> + {active + 1} / {gallery.length} + </span> + <button type="button" onClick={() => setLightbox(false)}> + بستن + </button> + </div> + <div className="media-lightbox__body"> + <img src={gallery[active]} alt={title} /> + {gallery.length > 1 ? ( + <> + <button type="button" className="media-lightbox__nav" onClick={prev} aria-label="قبلی"> + › + </button> + <button + type="button" + className="media-lightbox__nav media-lightbox__nav--next" + onClick={next} + aria-label="بعدی" + > + ‹ + </button> + </> + ) : null} + </div> + </div> + ) : null} + </div> + ); +} diff --git a/src/components/OpeningFrame.tsx b/src/components/OpeningFrame.tsx new file mode 100644 index 0000000..c4d9e5e --- /dev/null +++ b/src/components/OpeningFrame.tsx @@ -0,0 +1,164 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +type Feature = { + title: string; + titleEn: string; + text: string; + icon: ReactNode; +}; + +type Props = { + image: string; + imageAlt?: string; + eyebrow?: string; + headline?: string; + headlineMuted?: string; + headlineEn?: string; + body?: string; + features?: Feature[]; + ctaLabel?: string; + ctaHref?: string; +}; + +function IconPlan() { + return ( + <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> + <rect x="6" y="8" width="28" height="24" /> + <path d="M6 16h28M16 8v24M24 16v16" /> + </svg> + ); +} + +function IconInterior() { + return ( + <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> + <path d="M8 30V18l12-8 12 8v12" /> + <path d="M14 30v-8h12v8" /> + <path d="M8 30h24" /> + </svg> + ); +} + +function IconFacade() { + return ( + <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> + <rect x="8" y="6" width="24" height="28" /> + <path d="M8 14h24M8 22h24M8 30h24M16 14v16M24 14v16" /> + </svg> + ); +} + +function IconBuild() { + return ( + <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true"> + <path d="M10 28 20 8l10 20" /> + <path d="M13 22h14" /> + <circle cx="20" cy="30" r="2.2" fill="currentColor" stroke="none" /> + </svg> + ); +} + +const DEFAULT_FEATURES: Feature[] = [ + { + title: "معماری", + titleEn: "Architecture", + text: "از ایده تا نقشه‌های اجرایی، متناسب با زمین و نور.", + icon: <IconPlan />, + }, + { + title: "طراحی داخلی", + titleEn: "Interior", + text: "چیدمان، متریال و نور برای فضای زندگی روزمره.", + icon: <IconInterior />, + }, + { + title: "طراحی نما", + titleEn: "Facade", + text: "ریتم، متریال و نورپردازی در اولین برخورد با بنا.", + icon: <IconFacade />, + }, + { + title: "نظارت و اجرا", + titleEn: "Build", + text: "همراهی تا تحویل، تا طرح همان‌طور ساخته شود.", + icon: <IconBuild />, + }, +]; + +/** + * Sticky scroll frame (zoom-out reveal) — Hom layout with 4 icon features, + * distinct from Mana Collection’s big-type highlight / detail list. + */ +export function OpeningFrame({ + image, + imageAlt = "نمونه پروژه استودیو هوم", + eyebrow = "نگاه ما", + headline = "معماری ساده،", + headlineMuted = "زندگی روشن", + headlineEn = "SIMPLE ARCHITECTURE, BRIGHT LIVING", + body = "از نخستین اسکیس تا تحویل نهایی، تیم طراحی و اجرای هوم در کنار شماست تا طرح روی کاغذ، همان‌طور که تصور شده ساخته شود.", + features = DEFAULT_FEATURES, + ctaLabel = "درباره استودیو", + ctaHref = "/about", +}: Props) { + return ( + <section className="opening-track" aria-label="نگاه استودیو"> + <div className="opening-stage"> + <div className="opening-media"> + <img src={image} alt={imageAlt} loading="eager" decoding="async" /> + </div> + + <div className="opening-vignette" aria-hidden="true" /> + <div className="opening-shade" aria-hidden="true" /> + + <div className="opening-headline"> + <p className="opening-headline__eyebrow">{eyebrow}</p> + <h2 className="opening-headline__title"> + {headline} <em>{headlineMuted}</em> + <span className="opening-headline__mark" aria-hidden="true" /> + </h2> + {headlineEn ? ( + <p className="opening-headline__en latin">{headlineEn}</p> + ) : null} + </div> + + <div className="opening-copy"> + <div className="opening-panel"> + <div className="opening-panel__head"> + <div className="opening-panel__rail" aria-hidden="true"> + <span className="latin">studio</span> + <span className="opening-panel__line" /> + <span className="opening-panel__capsule" /> + <span className="opening-panel__dot" /> + </div> + <p className="opening-panel__lead">{body}</p> + </div> + + <div className="opening-features"> + {features.map((feature) => ( + <article key={feature.titleEn} className="opening-feature"> + <div className="opening-feature__icon">{feature.icon}</div> + <h3>{feature.title}</h3> + <span className="latin">{feature.titleEn}</span> + <p>{feature.text}</p> + </article> + ))} + </div> + + {ctaLabel ? ( + <div className="opening-panel__cta"> + <Link href={ctaHref} className="btn btn--light"> + {ctaLabel} + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M4 12h16M13 5l7 7-7 7" /> + </svg> + </Link> + </div> + ) : null} + </div> + </div> + </div> + </section> + ); +} diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx new file mode 100644 index 0000000..72d6503 --- /dev/null +++ b/src/components/Providers.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { SessionProvider } from "@/lib/session"; + +export function Providers({ + businessId, + children, +}: { + businessId: string; + children: ReactNode; +}) { + return <SessionProvider businessId={businessId}>{children}</SessionProvider>; +} diff --git a/src/components/SectionTitle.tsx b/src/components/SectionTitle.tsx new file mode 100644 index 0000000..3253a99 --- /dev/null +++ b/src/components/SectionTitle.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react"; + +type Props = { + /** English label — logo-style lowercase latin */ + en: string; + /** Primary Persian phrase (solid black) */ + title: string; + /** Secondary Persian phrase (lighter black), slider-style */ + titleMuted?: string; + as?: "h1" | "h2"; + /** Invert colors for dark sections */ + light?: boolean; + /** Optional sibling (lead, link, CTA) */ + aside?: ReactNode; + className?: string; +}; + +/** + * Section heading inspired by the HOM logo + hero slider title: + * latin rail (line + red dot), then FA title split into black / muted parts. + */ +export function SectionTitle({ + en, + title, + titleMuted, + as = "h2", + light = false, + aside, + className, +}: Props) { + const Heading = as; + + return ( + <div className={`section-title${light ? " section-title--light" : ""}${className ? ` ${className}` : ""}`}> + <div className="section-title__main"> + <div className="section-title__rail" aria-hidden="true"> + <span className="section-title__en latin">{en}</span> + <span className="section-title__track"> + <span className="section-title__line" /> + <span className="section-title__capsule" /> + <span className="section-title__line section-title__line--tail" /> + <span className="section-title__dot" /> + </span> + </div> + <Heading className="section-title__fa"> + {title} + {titleMuted ? ( + <> + {" "} + <em>{titleMuted}</em> + </> + ) : null} + </Heading> + </div> + {aside ? <div className="section-title__aside">{aside}</div> : null} + </div> + ); +} diff --git a/src/components/ServiceFrame.tsx b/src/components/ServiceFrame.tsx new file mode 100644 index 0000000..45069e8 --- /dev/null +++ b/src/components/ServiceFrame.tsx @@ -0,0 +1,89 @@ +import Link from "next/link"; + +import type { ServiceStep } from "@/data/site"; + +type Props = { + image: string; + imageAlt?: string; + index: string; + title: string; + titleEn: string; + lead: string; + steps: ServiceStep[]; + ctaLabel?: string; + ctaHref?: string; +}; + +/** + * Sticky scroll frame for service pages — side card + vertical steps. + * Same scroll/zoom mechanic as OpeningFrame, different composition. + */ +export function ServiceFrame({ + image, + imageAlt = "نمونه خدمت", + index, + title, + titleEn, + lead, + steps, + ctaLabel = "درخواست مشاوره", + ctaHref = "/contact", +}: Props) { + return ( + <section className="service-frame-track" aria-label={title}> + <div className="service-frame-stage"> + <div className="service-frame-media"> + <img src={image} alt={imageAlt} loading="eager" decoding="async" /> + </div> + + <div className="service-frame-vignette" aria-hidden="true" /> + <div className="service-frame-shade" aria-hidden="true" /> + + <div className="service-frame-headline"> + <p className="service-frame-headline__index latin" lang="en" dir="ltr"> + {index} + </p> + <h2 className="service-frame-headline__title">{title}</h2> + <p className="service-frame-headline__en latin">{titleEn}</p> + </div> + + <div className="service-frame-copy"> + <aside className="service-frame-card"> + <div className="service-frame-card__rail" aria-hidden="true"> + <span className="latin">process</span> + <span className="service-frame-card__line" /> + <span className="service-frame-card__dot" /> + </div> + + <p className="service-frame-card__lead">{lead}</p> + + <ol className="service-frame-steps"> + {steps.map((step, i) => ( + <li key={step.title} className="service-frame-step"> + <span className="service-frame-step__num latin" lang="en" dir="ltr"> + {String(i + 1).padStart(2, "0")} + </span> + <div> + <h3>{step.title}</h3> + <p>{step.text}</p> + </div> + </li> + ))} + </ol> + + {ctaLabel ? ( + <div className="service-frame-card__cta"> + <Link href={ctaHref} className="btn btn--light"> + {ctaLabel} + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"> + <path d="M4 12h16M13 5l7 7-7 7" /> + </svg> + </Link> + </div> + ) : null} + </aside> + </div> + </div> + </section> + ); +} diff --git a/src/data/site.ts b/src/data/site.ts index 99066d9..1087c1c 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -10,11 +10,8 @@ export type Project = { ratio: "4:5" | "1:1" | "5:4"; }; -export const NAV_LINKS = [ - { href: "/", label: "خانه" }, - { href: "/about", label: "درباره ما" }, - { href: "/contact", label: "تماس با ما" }, -]; +export type NavChild = { href: string; label: string }; +export type NavLink = { href: string; label: string; children?: NavChild[] }; export const site = { nameFa: "استودیو معماری هوم", @@ -25,8 +22,10 @@ export const site = { "استودیو معماری هوم؛ گروه معماری و طراحی داخلی. طراحی نما، معماری مسکونی، طراحی داخلی و اجرای پروژه‌های ساختمانی با نگاهی مینیمال، دقیق و کاربردی.", logo: "/images/logo.png", address: "تهران", + /** Default map pin (Tehran) — replace when a studio address is confirmed */ + map: { lat: 35.7219, lng: 51.3347, zoom: 13 }, email: "info@homgroup.com", - phones: [{ display: "۰۹۱۲ ۰۰۰ ۰۰۰۰", href: "tel:+989120000000" }], + phones: [{ display: "0912 000 0000", href: "tel:+989120000000" }], instagram: { href: "https://instagram.com/hom.archstudio", handle: "@hom.archstudio" }, about: [ "استودیو معماری هوم یک گروه معماری و طراحی داخلی است که با تمرکز بر سادگی، تناسب و کیفیت اجرا، فضاهایی برای زندگی و کار طراحی می‌کند.", @@ -35,63 +34,87 @@ export const site = { ], }; -export const services = [ +export type ServiceStep = { title: string; text: string }; + +export type Service = { + slug: string; + index: string; + titleFa: string; + titleEn: string; + text: string; + lead: string; + image: string; + frameImage: string; + steps: ServiceStep[]; +}; + +export const services: Service[] = [ { + slug: "architecture", index: "01", titleFa: "طراحی معماری", titleEn: "Architecture", text: "طراحی ساختمان‌های مسکونی و تجاری از مرحله ایده تا نقشه‌های اجرایی، با رعایت ضوابط و تناسب با بستر پروژه.", + lead: "از تحلیل زمین و برنامه فضایی تا پلان‌های اجرایی؛ معماری که با نور، سازه و زندگی روزمره هماهنگ است.", + image: "/images/courtyard-brick.jpg", + frameImage: "/images/courtyard-brick-2.jpg", + steps: [ + { title: "شناخت بستر", text: "بررسی زمین، ضوابط، نور و نیاز کارفرما." }, + { title: "ایده و اسکیس", text: "شکل‌گیری حجم و سازمان فضایی اولیه." }, + { title: "توسعه طرح", text: "پلان، نما و مقاطع تا هماهنگی کامل." }, + { title: "نقشه اجرایی", text: "مدارک آماده برای اخذ مجوز و ساخت." }, + ], }, { + slug: "interior-design", index: "02", titleFa: "طراحی داخلی", titleEn: "Interior Design", text: "طراحی فضاهای داخلی، چیدمان، نورپردازی و انتخاب متریال؛ آشپزخانه، نشیمن، اتاق کار و فضاهای تجاری.", + lead: "فضای داخلی باید آرام، کاربردی و دقیق باشد؛ از چیدمان تا متریال و نور، همه در خدمت تجربه زندگی.", + image: "/images/kitchen-cream.jpg", + frameImage: "/images/kitchen-oak.jpg", + steps: [ + { title: "برنامه فضایی", text: "تعیین کارکردها و مسیر حرکت در فضا." }, + { title: "ایده طراحی", text: "مفهوم، پالت متریال و حس کلی فضا." }, + { title: "جزئیات", text: "نور، مبلمان، کابینت و نقاط تمرکز." }, + { title: "اجرای داخلی", text: "نقشه‌ها و همراهی تا تحویل فضا." }, + ], }, { + slug: "facade-design", index: "03", titleFa: "طراحی نما", titleEn: "Facade Design", text: "طراحی و بازطراحی نمای ساختمان با متریال‌های آجر، سنگ، چوب و فلز؛ همراه با مدل‌سازی و رندر واقع‌گرایانه.", + lead: "نما اولین برخورد با بناست؛ ریتم، متریال و نورپردازی را طوری می‌چینیم که هویت پروژه خوانا بماند.", + image: "/images/facade-louver.jpg", + frameImage: "/images/facade-sunset.jpg", + steps: [ + { title: "تحلیل نما", text: "بررسی جهت، همسایگی و ضوابط شهری." }, + { title: "زبان بصری", text: "ریتم، عمق و ترکیب متریال." }, + { title: "مدل و رندر", text: "شبیه‌سازی واقع‌گرایانه روز و شب." }, + { title: "جزئیات اجرایی", text: "دیتیل اتصال و آماده ساخت." }, + ], }, { + slug: "build-supervision", index: "04", titleFa: "نظارت و اجرا", titleEn: "Build & Supervision", text: "مدیریت و نظارت بر اجرای پروژه تا تحویل نهایی، تا آنچه طراحی شده دقیقاً همان‌طور ساخته شود.", + lead: "طرح روی کاغذ کافی نیست؛ با نظارت مستمر، کیفیت اجرا را تا تحویل نهایی حفظ می‌کنیم.", + image: "/images/garden-gate.jpg", + frameImage: "/images/facade-sunset-detail.jpg", + steps: [ + { title: "برنامه اجرا", text: "زمان‌بندی، اولویت‌ها و هماهنگی عوامل." }, + { title: "کنترل کیفیت", text: "بررسی متریال و جزئیات در کارگاه." }, + { title: "هماهنگی تیم‌ها", text: "ارتباط مستمر بین طراحی و اجرا." }, + { title: "تحویل نهایی", text: "بازبینی کامل تا تطبیق با طرح." }, + ], }, ]; -export const processSteps = [ - { - step: "۱", - titleFa: "شناخت", - text: "جلسه آشنایی، بازدید از محل و تعریف دقیق نیازها، بودجه و زمان‌بندی پروژه.", - }, - { - step: "۲", - titleFa: "ایده و طرح اولیه", - text: "مطالعه بستر، ارائه کانسپت و گزینه‌های طراحی به همراه تصاویر سه‌بعدی.", - }, - { - step: "۳", - titleFa: "طراحی تفصیلی", - text: "تهیه نقشه‌های اجرایی، جزئیات، جدول متریال و برآورد هزینه.", - }, - { - step: "۴", - titleFa: "اجرا و تحویل", - text: "هماهنگی پیمانکاران، نظارت مستمر بر کیفیت اجرا و تحویل پروژه.", - }, -]; - -export const stats = [ - { value: "۱۰+", label: "سال تجربه" }, - { value: "۸۰+", label: "پروژه طراحی‌شده" }, - { value: "۳۰+", label: "پروژه اجراشده" }, - { value: "۱۰۰٪", label: "تعهد به جزئیات" }, -]; - export const projects: Project[] = [ { slug: "sunset-brick-villa", @@ -155,14 +178,74 @@ export const projects: Project[] = [ }, ]; +/** Top-level nav; items with `children` render a dropdown. */ +export const NAV_LINKS: NavLink[] = [ + { href: "/", label: "خانه" }, + { + href: "/portfolio", + label: "پروژه", + children: projects.map((project) => ({ + href: `/portfolio/${project.slug}`, + label: project.titleFa, + })), + }, + { + href: "/services", + label: "تخصص‌های ما", + children: services.map((service) => ({ + href: `/services/${service.slug}`, + label: service.titleFa, + })), + }, + { href: "/blog", label: "بلاگ" }, + { href: "/about", label: "درباره ما" }, + { href: "/contact", label: "تماس با ما" }, +]; + +export const processSteps = [ + { + step: 1, + titleFa: "شناخت", + text: "جلسه آشنایی، بازدید از محل و تعریف دقیق نیازها، بودجه و زمان‌بندی پروژه.", + }, + { + step: 2, + titleFa: "ایده و طرح اولیه", + text: "مطالعه بستر، ارائه کانسپت و گزینه‌های طراحی به همراه تصاویر سه‌بعدی.", + }, + { + step: 3, + titleFa: "طراحی تفصیلی", + text: "تهیه نقشه‌های اجرایی، جزئیات، جدول متریال و برآورد هزینه.", + }, + { + step: 4, + titleFa: "اجرا و تحویل", + text: "هماهنگی پیمانکاران، نظارت مستمر بر کیفیت اجرا و تحویل پروژه.", + }, +]; + +export const stats = [ + { value: "10+", label: "سال تجربه" }, + { value: "80+", label: "پروژه طراحی‌شده" }, + { value: "30+", label: "پروژه اجراشده" }, + { value: "100%", label: "تعهد به جزئیات" }, +]; + export const gallery = { - hero: "/images/facade-sunset.jpg", - studio: "/images/garden-gate.jpg", - aboutHero: "/images/courtyard-brick.jpg", - aboutGallery: [ - "/images/kitchen-oak-2.jpg", - "/images/facade-sunset-detail.jpg", - "/images/study-terracotta-2.jpg", + /** Fallback slides for homepage `slider` slot */ + heroSlides: [ + { url: "/images/facade-sunset.jpg", titleFa: "ویلای آجری", titleEn: "Brick Residence" }, + { url: "/images/courtyard-brick.jpg", titleFa: "خانه حیاط‌دار", titleEn: "Courtyard House" }, + { url: "/images/garden-gate.jpg", titleFa: "ورودی سبز", titleEn: "Garden Gate" }, + { url: "/images/facade-louver.jpg", titleFa: "نمای لوور", titleEn: "Louver Facade" }, ], - contact: "/images/kitchen-cream-portrait.jpg", + studio: "/images/garden-gate.jpg", + instagram: "/images/hom-instagram.jpeg", + aboutImages: [ + { url: "/images/courtyard-brick.jpg", titleFa: "خانه حیاط‌دار", titleEn: "Courtyard", subtext: "معماری مسکونی" }, + { url: "/images/kitchen-oak-2.jpg", titleFa: "آشپزخانه بلوط", titleEn: "Oak Kitchen", subtext: "طراحی داخلی" }, + ], + /** Fallback logos for homepage `brands` slot — empty until CMS uploads */ + brands: [] as { url: string; titleFa?: string; titleEn?: string; linkUrl?: string }[], }; diff --git a/src/lib/blog.ts b/src/lib/blog.ts new file mode 100644 index 0000000..9e38066 --- /dev/null +++ b/src/lib/blog.ts @@ -0,0 +1,131 @@ +import { meshkeeGet, meshkeeGetOrNull, tenantPath } from "@/lib/meshkee"; +import { detailPath, slugifyForUrl } from "@/lib/slug"; + +export type BlogPost = { + id: string; + title: string; + slug?: string | null; + abstract?: string | null; + mainTextHtml?: string | null; + titleImageUrl?: string | null; + publishedAt?: string | null; + createdAt?: string | null; + commentCount?: number; + categoryName?: string | null; + categoryNameFa?: string | null; +}; + +export type PagedBlogs = { + items: BlogPost[]; + total: number; + page: number; + pageSize: number; +}; + +const LOCAL_POSTS: BlogPost[] = [ + { + id: "local-architecture-light", + title: "معماری ساده و نور طبیعی", + slug: "architecture-light", + abstract: "نگاهی به نقش نور در فضاهای مسکونی مینیمال.", + titleImageUrl: "/images/courtyard-brick.jpg", + mainTextHtml: + "<p>نور طبیعی یکی از مهم‌ترین ابزارهای طراحی در استودیو هوم است. با کنترل ورود نور و سایه، فضا هم خوانا می‌ماند و هم در طول روز تغییر احساس می‌دهد.</p>", + publishedAt: null, + }, + { + id: "local-material-detail", + title: "جزئیات متریال در طراحی داخلی", + slug: "material-detail", + abstract: "چگونه انتخاب متریال، حس فضا را برای سال‌ها حفظ می‌کند.", + titleImageUrl: "/images/kitchen-oak.jpg", + mainTextHtml: + "<p>در طراحی داخلی، متریال فقط پوشش نیست؛ ریتم و دوام فضا را می‌سازد. چوب، سنگ و فلز وقتی درست کنار هم قرار بگیرند، فضا را آرام و ماندگار می‌کنند.</p>", + publishedAt: null, + }, + { + id: "local-facade-rhythm", + title: "ریتم نما و اولین برخورد با بنا", + slug: "facade-rhythm", + abstract: "طراحی نما به‌عنوان هویت بیرونی پروژه.", + titleImageUrl: "/images/facade-louver.jpg", + mainTextHtml: + "<p>نما اولین جملهٔ معماری است. ریتم بازشوها، عمق و متریال باید با بافت اطراف هماهنگ باشد و در عین حال شخصیت پروژه را نشان دهد.</p>", + publishedAt: null, + }, +]; + +export async function fetchBlogs(params: { page?: number; pageSize?: number } = {}) { + const query = new URLSearchParams(); + query.set("page", String(params.page ?? 1)); + query.set("pageSize", String(params.pageSize ?? 12)); + const data = await meshkeeGet<PagedBlogs>(tenantPath(`/blogs?${query.toString()}`), { + items: [], + total: 0, + page: 1, + pageSize: params.pageSize ?? 12, + }); + return { + ...data, + items: (data.items ?? []).map((item) => ({ ...item, id: String(item.id) })), + }; +} + +export async function fetchBlogBySlug(slug: string) { + const data = await meshkeeGetOrNull<{ blog?: BlogPost }>( + tenantPath(`/blogs/${encodeURIComponent(slug)}`), + ); + return data?.blog ? { ...data.blog, id: String(data.blog.id) } : null; +} + +export async function fetchBlogById(id: string) { + const data = await meshkeeGetOrNull<{ blog?: BlogPost }>( + tenantPath(`/blogs/by-id/${encodeURIComponent(id)}`), + ); + return data?.blog ? { ...data.blog, id: String(data.blog.id) } : null; +} + +export function blogHref(post: Pick<BlogPost, "id" | "slug" | "title">) { + if (post.slug?.trim()) return `/blog/${encodeURIComponent(post.slug.trim())}`; + return detailPath("/blog", post.id, post.title); +} + +export function blogImage(post: BlogPost, fallback = "/images/garden-gate.jpg") { + return post.titleImageUrl?.trim() || fallback; +} + +export function asBlogHtml(input?: string | null): string | undefined { + if (!input?.trim()) return undefined; + if (/<[a-z][\s\S]*>/i.test(input)) return input; + return input + .split(/\r?\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `<p>${line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</p>`) + .join(""); +} + +export function getLocalBlogPosts(): BlogPost[] { + return LOCAL_POSTS; +} + +export function getLocalBlogPost(slugOrId: string) { + const decoded = decodeURIComponent(slugOrId); + return ( + LOCAL_POSTS.find( + (post) => + post.slug === decoded || + post.id === decoded || + slugifyForUrl(post.title) === decoded, + ) ?? null + ); +} + +export function formatBlogDate(value?: string | null) { + if (!value) return ""; + try { + return new Date(value).toLocaleDateString("fa-IR"); + } catch { + return ""; + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 3989368..27f31a6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,4 +1,4 @@ -export const websiteDomain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.com"; +export const websiteDomain = process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "homgroup.ir"; export const meshkeeApiBase = process.env.NEXT_PUBLIC_MESHKEE_API_BASE || "https://api.meshkee.com/api/v1"; @@ -6,3 +6,5 @@ export const meshkeeApiBase = export const siteUrl = `https://${websiteDomain}`; export const customerPortalUrl = `https://customer.${websiteDomain}`; + +export const businessDashboardUrl = `https://business.${websiteDomain}`; diff --git a/src/lib/content.ts b/src/lib/content.ts index 2483f6a..5a434ac 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -28,10 +28,13 @@ export type PhoneLink = { href: string; display: string }; export function resolvePhones(info: BusinessInfo): PhoneLink[] { const fromApi = (info.phoneNumbers ?? []).map(textOf).filter(Boolean); if (!fromApi.length) return [...site.phones]; - return fromApi.map((display) => ({ - display, - href: `tel:${toLatinDigits(display).replace(/[^\d+]/g, "")}`, - })); + return fromApi.map((raw) => { + const display = toLatinDigits(raw); + return { + display, + href: `tel:${display.replace(/[^\d+]/g, "")}`, + }; + }); } export function resolveAddress(info: BusinessInfo) { diff --git a/src/lib/meshkee-server.ts b/src/lib/meshkee-server.ts new file mode 100644 index 0000000..97b9bce --- /dev/null +++ b/src/lib/meshkee-server.ts @@ -0,0 +1,42 @@ +import { meshkeeApiBase } from "@/lib/config"; + +export function getBearerToken(request: Request): string | null { + const auth = request.headers.get("authorization"); + if (auth?.startsWith("Bearer ")) return auth.slice(7); + return null; +} + +export async function meshkeeFetch( + path: string, + init?: RequestInit & { token?: string | null }, +): Promise<{ ok: boolean; status: number; data: unknown }> { + const headers = new Headers(init?.headers); + if (!headers.has("Content-Type") && init?.body) { + headers.set("Content-Type", "application/json"); + } + if (init?.token) { + headers.set("Authorization", `Bearer ${init.token}`); + } + + const res = await fetch(`${meshkeeApiBase}${path}`, { + ...init, + headers, + }); + + let data: unknown = null; + try { + data = await res.json(); + } catch { + data = null; + } + + return { ok: res.ok, status: res.status, data }; +} + +export function jsonResponse(data: unknown, status = 200) { + return Response.json(data, { status }); +} + +export function unauthorizedResponse() { + return jsonResponse({ error: "Unauthorized" }, 401); +} diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index ce2a541..dcbdb56 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -12,10 +12,36 @@ export async function meshkeeGet<T>(path: string, fallback: T, revalidate = 300) } } +export async function meshkeeGetOrNull<T>(path: string, revalidate = 300): Promise<T | null> { + try { + const response = await fetch(`${meshkeeApiBase}${path}`, { next: { revalidate } }); + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + return null; + } +} + export function tenantPath(suffix: string) { return `/tenants/${encodeURIComponent(websiteDomain)}${suffix}`; } +type Tenant = { + id: number | string; + nameFa?: string; + name?: string; + domain?: string; +}; + +export const getTenant = cache(async () => + meshkeeGet<Tenant | null>(`/tenants/${encodeURIComponent(websiteDomain)}`, null), +); + +export const getBusinessId = cache(async () => { + const tenant = await getTenant(); + return tenant?.id != null ? String(tenant.id) : ""; +}); + export type FaviconInfo = { faviconUrl: string | null; logoUrl: string | null; diff --git a/src/lib/portfolio.ts b/src/lib/portfolio.ts new file mode 100644 index 0000000..b6c6e84 --- /dev/null +++ b/src/lib/portfolio.ts @@ -0,0 +1,121 @@ +import { projects } from "@/data/site"; +import { meshkeeGet, meshkeeGetOrNull, tenantPath } from "@/lib/meshkee"; +import { detailPath, slugifyForUrl } from "@/lib/slug"; + +export type PortfolioItem = { + id: string; + title?: string | null; + titleFa?: string | null; + titleEn?: string | null; + slug?: string | null; + abstract?: string | null; + mainTextHtml?: string | null; + titleImageUrl?: string | null; + categoryId?: string | null; + categoryName?: string | null; + projectUrl?: string | null; + gallery?: { url?: string | null }[] | string[] | null; + publishedAt?: string | null; + createdAt?: string | null; +}; + +export type PagedPortfolios = { + items: PortfolioItem[]; + total: number; + page: number; + pageSize: number; +}; + +function normalize(item: PortfolioItem): PortfolioItem { + return { ...item, id: String(item.id) }; +} + +export async function fetchPortfolios(params: { page?: number; pageSize?: number } = {}) { + const query = new URLSearchParams(); + query.set("page", String(params.page ?? 1)); + query.set("pageSize", String(params.pageSize ?? 12)); + const data = await meshkeeGet<PagedPortfolios>(tenantPath(`/portfolios?${query.toString()}`), { + items: [], + total: 0, + page: 1, + pageSize: params.pageSize ?? 12, + }); + return { + ...data, + items: (data.items ?? []).map(normalize), + }; +} + +export async function fetchPortfolioBySlug(slug: string) { + const data = await meshkeeGetOrNull<{ portfolio?: PortfolioItem }>( + tenantPath(`/portfolios/${encodeURIComponent(slug)}`), + ); + return data?.portfolio ? normalize(data.portfolio) : null; +} + +export async function fetchPortfolioById(id: string) { + const data = await meshkeeGetOrNull<{ portfolio?: PortfolioItem }>( + tenantPath(`/portfolios/by-id/${encodeURIComponent(id)}`), + ); + return data?.portfolio ? normalize(data.portfolio) : null; +} + +export function portfolioTitle(item: Pick<PortfolioItem, "titleFa" | "title" | "titleEn">) { + return item.titleFa?.trim() || item.title?.trim() || item.titleEn?.trim() || "نمونه کار"; +} + +export function portfolioHref(item: Pick<PortfolioItem, "id" | "slug" | "titleFa" | "title">) { + if (item.slug?.trim()) return `/portfolio/${encodeURIComponent(item.slug.trim())}`; + return detailPath("/portfolio", item.id, portfolioTitle(item)); +} + +export function portfolioImages(item: PortfolioItem, fallback = "/images/facade-sunset.jpg"): string[] { + const extras = (item.gallery ?? []) + .map((entry) => (typeof entry === "string" ? entry : entry?.url)) + .filter((url): url is string => Boolean(url?.trim())); + const urls = [item.titleImageUrl?.trim(), ...extras].filter((url): url is string => Boolean(url)); + return urls.length ? [...new Set(urls)] : [fallback]; +} + +export function portfolioCover(item: PortfolioItem, fallback = "/images/facade-sunset.jpg") { + return portfolioImages(item, fallback)[0]; +} + +export function asRichHtml(input?: string | null): string | undefined { + if (!input?.trim()) return undefined; + if (/<[a-z][\s\S]*>/i.test(input)) return input; + return input + .split(/\r?\n+/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `<p>${line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</p>`) + .join(""); +} + +export function getLocalPortfolios(): PortfolioItem[] { + return projects.map((project) => ({ + id: `local-${project.slug}`, + slug: project.slug, + title: project.titleFa, + titleFa: project.titleFa, + titleEn: project.titleEn, + abstract: `${project.category} · ${project.year} · ${project.location}`, + categoryName: project.category, + titleImageUrl: project.image, + gallery: [project.image], + mainTextHtml: `<p>${project.titleFa} — ${project.category} در ${project.location}، سال ${project.year}.</p>`, + })); +} + +export function getLocalPortfolio(slugOrId: string) { + const decoded = decodeURIComponent(slugOrId); + return ( + getLocalPortfolios().find( + (item) => item.slug === decoded || item.id === decoded || item.id === `local-${decoded}`, + ) ?? null + ); +} + +export function localPortfolioSlug(title: string) { + return slugifyForUrl(title); +} diff --git a/src/lib/session.tsx b/src/lib/session.tsx new file mode 100644 index 0000000..bc953a3 --- /dev/null +++ b/src/lib/session.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +import { + clearSharedAuthCookies, + hydrateLocalTokensFromSharedCookies, + SHARED_ACCESS_TOKEN_KEY, + SHARED_REFRESH_TOKEN_KEY, + writeSharedAuthCookies, +} from "@/lib/shared-auth-cookie"; + +export type AuthBusinessMembership = { + id: string; + name: string; + slug: string; + isOwner: boolean; + teamRole: string | null; +}; + +export type AuthUser = { + id: string; + firstName?: string; + lastName?: string; + cellNumber?: string; + email?: string; + roles?: string[]; + isSuperAdmin?: boolean; + businesses?: AuthBusinessMembership[]; +}; + +type SessionContextValue = { + user: AuthUser | null; + isAuthenticated: boolean; + ready: boolean; + businessId: string; + isWebsiteAdmin: boolean; + logout: () => void; + getAccessToken: () => string | null; +}; + +function normalizeUser(raw: Partial<AuthUser> & { id?: string | number }): AuthUser | null { + if (raw.id == null) return null; + return { + id: String(raw.id), + firstName: raw.firstName ?? undefined, + lastName: raw.lastName ?? undefined, + cellNumber: raw.cellNumber ?? undefined, + email: raw.email ?? undefined, + roles: Array.isArray(raw.roles) ? raw.roles : undefined, + isSuperAdmin: Boolean(raw.isSuperAdmin), + businesses: Array.isArray(raw.businesses) + ? raw.businesses.map((business) => ({ + id: String(business.id), + name: business.name, + slug: business.slug, + isOwner: Boolean(business.isOwner), + teamRole: business.teamRole ?? null, + })) + : undefined, + }; +} + +export function isWebsiteAdminForBusiness(user: AuthUser | null | undefined, businessId: string): boolean { + if (!user || !businessId) return false; + if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true; + return Boolean( + user.businesses?.some( + (business) => String(business.id) === String(businessId) && (business.isOwner || Boolean(business.teamRole)), + ), + ); +} + +const SessionContext = createContext<SessionContextValue | null>(null); + +function readToken(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeToken(key: string, value: string | null) { + try { + if (value) localStorage.setItem(key, value); + else localStorage.removeItem(key); + } catch { + // ignore + } +} + +export function SessionProvider({ + children, + businessId, +}: { + children: ReactNode; + businessId: string; +}) { + const [user, setUser] = useState<AuthUser | null>(null); + const [ready, setReady] = useState(false); + + const getAccessToken = useCallback(() => { + hydrateLocalTokensFromSharedCookies(SHARED_ACCESS_TOKEN_KEY, SHARED_REFRESH_TOKEN_KEY); + return ( + readToken(SHARED_ACCESS_TOKEN_KEY) || + readToken("accessToken") || + readToken("meshkeeToken") || + readToken("token") + ); + }, []); + + const clearTokens = useCallback(() => { + writeToken(SHARED_ACCESS_TOKEN_KEY, null); + writeToken(SHARED_REFRESH_TOKEN_KEY, null); + writeToken("accessToken", null); + writeToken("meshkeeToken", null); + writeToken("token", null); + clearSharedAuthCookies(); + setUser(null); + }, []); + + useEffect(() => { + const token = getAccessToken(); + if (!token) { + setReady(true); + return; + } + + const refresh = readToken(SHARED_REFRESH_TOKEN_KEY); + writeSharedAuthCookies(token, refresh ?? undefined); + + fetch("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } }) + .then(async (res) => { + if (res.status === 401) { + clearTokens(); + return; + } + if (!res.ok) return; + const data = (await res.json()) as { user?: AuthUser } & AuthUser; + const next = normalizeUser(data.user ?? data); + if (next) setUser(next); + }) + .catch(() => undefined) + .finally(() => setReady(true)); + }, [clearTokens, getAccessToken]); + + const logout = useCallback(() => { + clearTokens(); + }, [clearTokens]); + + const isWebsiteAdmin = useMemo(() => isWebsiteAdminForBusiness(user, businessId), [user, businessId]); + + const value = useMemo( + () => ({ + user, + isAuthenticated: Boolean(user), + ready, + businessId, + isWebsiteAdmin, + logout, + getAccessToken, + }), + [user, ready, businessId, isWebsiteAdmin, logout, getAccessToken], + ); + + return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>; +} + +export function useSession() { + const ctx = useContext(SessionContext); + if (!ctx) throw new Error("useSession must be used within SessionProvider"); + return ctx; +} + +export function userDisplayName(user: AuthUser | null | undefined) { + if (!user) return "کاربر"; + const full = [user.firstName, user.lastName].filter(Boolean).join(" ").trim(); + return full || user.cellNumber || user.email || "کاربر"; +} diff --git a/src/lib/shared-auth-cookie.ts b/src/lib/shared-auth-cookie.ts new file mode 100644 index 0000000..2c3c880 --- /dev/null +++ b/src/lib/shared-auth-cookie.ts @@ -0,0 +1,123 @@ +/** Shared auth cookies so storefront + customer.{domain} share one login. */ + +export const SHARED_ACCESS_TOKEN_KEY = "meshkee_customer_access_token"; +export const SHARED_REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token"; + +const CHUNK_SIZE = 2800; +const MAX_CHUNKS = 24; + +function cookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + const host = window.location.hostname.toLowerCase(); + if (host === "localhost" || host === "127.0.0.1") return undefined; + const parts = host.split(".").filter(Boolean); + if (parts.length < 2) return undefined; + return `.${parts.slice(-2).join(".")}`; +} + +function readRawCookie(name: string): string | null { + if (typeof document === "undefined") return null; + const prefix = `${name}=`; + const match = document.cookie.split("; ").find((row) => row.startsWith(prefix)); + if (!match) return null; + return match.slice(prefix.length); +} + +function writeRawCookie(name: string, value: string | null) { + if (typeof document === "undefined") return; + const domain = cookieDomain(); + if (value == null) { + let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`; + if (domain) cookie += `; domain=${domain}`; + document.cookie = cookie; + return; + } + const maxAge = 60 * 60 * 24 * 30; + let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`; + if (domain) cookie += `; domain=${domain}`; + document.cookie = cookie; +} + +function clearChunkedCookie(name: string) { + writeRawCookie(name, null); + const n = Number(readRawCookie(`${name}_n`) || "0"); + writeRawCookie(`${name}_n`, null); + for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) { + writeRawCookie(`${name}_${i}`, null); + } +} + +function writeCookie(name: string, value: string | null) { + clearChunkedCookie(name); + if (value == null || value === "") return; + + const encoded = encodeURIComponent(value); + if (encoded.length <= 3500) { + writeRawCookie(name, encoded); + return; + } + + const chunks: string[] = []; + for (let i = 0; i < encoded.length; i += CHUNK_SIZE) { + chunks.push(encoded.slice(i, i + CHUNK_SIZE)); + } + writeRawCookie(`${name}_n`, String(chunks.length)); + chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk)); +} + +function readCookie(name: string): string | null { + const n = Number(readRawCookie(`${name}_n`) || "0"); + if (n > 0) { + let encoded = ""; + for (let i = 0; i < n; i++) { + const part = readRawCookie(`${name}_${i}`); + if (part == null) return null; + encoded += part; + } + try { + return decodeURIComponent(encoded); + } catch { + return null; + } + } + + const raw = readRawCookie(name); + if (raw == null) return null; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + +export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) { + writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken); + if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken); +} + +export function clearSharedAuthCookies() { + writeCookie(SHARED_ACCESS_TOKEN_KEY, null); + writeCookie(SHARED_REFRESH_TOKEN_KEY, null); +} + +export function readSharedAccessToken(): string | null { + return readCookie(SHARED_ACCESS_TOKEN_KEY); +} + +export function readSharedRefreshToken(): string | null { + return readCookie(SHARED_REFRESH_TOKEN_KEY); +} + +export function hydrateLocalTokensFromSharedCookies( + accessKey = SHARED_ACCESS_TOKEN_KEY, + refreshKey = SHARED_REFRESH_TOKEN_KEY, +): boolean { + if (typeof window === "undefined") return false; + if (localStorage.getItem(accessKey)) return false; + const access = readSharedAccessToken(); + if (!access) return false; + localStorage.setItem(accessKey, access); + const refresh = readSharedRefreshToken(); + if (refresh) localStorage.setItem(refreshKey, refresh); + return true; +} diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 0000000..321b35d --- /dev/null +++ b/src/lib/slug.ts @@ -0,0 +1,17 @@ +/** Build a URL path segment from a title (Persian / Arabic / Latin). */ +export function slugifyForUrl(value: string, fallback = "item"): string { + const slug = value + .trim() + .normalize("NFC") + .replace(/\s+/g, "-") + .replace(/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g, "") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + return slug || fallback; +} + +export function detailPath(base: string, id: string | number, title: string) { + const slug = slugifyForUrl(title); + return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`; +} diff --git a/src/lib/static-images.ts b/src/lib/static-images.ts index 6421fe9..5e59c3e 100644 --- a/src/lib/static-images.ts +++ b/src/lib/static-images.ts @@ -27,58 +27,67 @@ export type StaticImageSlot = { */ export const STATIC_IMAGE_SLOTS: StaticImageSlot[] = [ { - key: "hero", - label: "تصویر اصلی صفحه اول", - kind: "single", + key: "slider", + label: "اسلایدر تمام‌صفحه صفحه اول (قابل تکرار)", + kind: "list", pageKey: "home", aspectRatio: "16:9", - itemCount: 1, + itemCount: null, recommendedWidth: 1920, }, { - key: "projects", - label: "پروژه‌های منتخب صفحه اول", + key: "four-category", + label: "چهار خدمت / تخصص صفحه اول", kind: "list", pageKey: "home", - aspectRatio: "4:5", + aspectRatio: "3:4", + itemCount: 4, + recommendedWidth: 900, + }, + { + key: "project-images", + label: "پروژه‌های منتخب صفحه اول (میسونری — اندازه آزاد)", + kind: "list", + pageKey: "home", + aspectRatio: "3:4", itemCount: null, recommendedWidth: 1200, }, { - key: "studio-image", - label: "تصویر معرفی استودیو (صفحه اول)", + key: "sticky-frame-bg", + label: "پس‌زمینه فریم استیکی OpeningFrame", kind: "single", pageKey: "home", - aspectRatio: "4:3", - itemCount: 1, - recommendedWidth: 1400, - }, - { - key: "about-hero", - label: "تصویر اصلی درباره ما", - kind: "single", - pageKey: "about", aspectRatio: "16:9", itemCount: 1, recommendedWidth: 1920, }, { - key: "about-gallery", - label: "گالری درباره ما", - kind: "list", - pageKey: "about", - aspectRatio: "1:1", - itemCount: 3, - recommendedWidth: 900, + key: "instagram-row", + label: "فریم صفحه اینستاگرام (اسکرین‌شات موبایل)", + kind: "single", + pageKey: "home", + aspectRatio: "9:16", + itemCount: 1, + recommendedWidth: 720, }, { - key: "contact-image", - label: "تصویر تماس با ما", - kind: "single", - pageKey: "contact", - aspectRatio: "4:5", - itemCount: 1, - recommendedWidth: 1200, + key: "brands", + label: "لوگوی برندها / همکاران (قابل تکرار)", + kind: "list", + pageKey: "home", + aspectRatio: "3:2", + itemCount: null, + recommendedWidth: 480, + }, + { + key: "about-us-image", + label: "تصاویر درباره ما (۲ تصویر)", + kind: "list", + pageKey: "about", + aspectRatio: "16:9", + itemCount: 2, + recommendedWidth: 1600, }, ];