Add portfolio, blog, customer login, and Meshkee SEO.

Wire CMS routes and account UI, match the original header icons, and publish sitemap-config with unique page metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-31 14:03:46 +03:30
co-authored by Cursor
parent d2a82849ef
commit 6cd85e7e59
25 changed files with 1485 additions and 125 deletions
+31 -9
View File
@@ -7,8 +7,8 @@
- Apex domain: `khiyalstudio.com`. - Apex domain: `khiyalstudio.com`.
- Language: `en`, direction: `ltr`. - Language: `en`, direction: `ltr`.
- Local images live in `public/images`; local fonts live in `public/fonts` (Antonio + Plus Jakarta Sans, matching the source site). - Local images live in `public/images`; local fonts live in `public/fonts` (Antonio + Plus Jakarta Sans, matching the source site).
- Public routes in this clone: `/`, `/about-us`, `/contact-us`. - Public routes: `/`, `/about-us`, `/contact-us`, `/portfolios`, `/portfolios/{id}/{slug}`, `/blog`, `/blog/{id}/{slug}`.
- Portfolio and blog listing/detail pages are **not** implemented yet; homepage cards use current site data as local placeholders until Meshkee portfolio/blog endpoints are wired. - Homepage project/news cards still use local placeholders when Meshkee lists are empty.
## Source clone ## Source clone
@@ -22,11 +22,29 @@
- Canonical docs: `https://api.meshkee.com/docs/website` - Canonical docs: `https://api.meshkee.com/docs/website`
- API base: `https://api.meshkee.com/api/v1` - API base: `https://api.meshkee.com/api/v1`
- Tenant domain: `khiyalstudio.com` (`SITE_DOMAIN` in `src/lib/site.ts`) - Tenant domain: `khiyalstudio.com` (`SITE_DOMAIN` in `src/lib/site.ts`)
- All public fetches go through `src/lib/meshkee.ts`. Do not call the API from components. - Enabled modules currently include `portfolio` and `blog`. This site does **not** use workshops.
- All public GET fetches go through `src/lib/meshkee.ts`.
- Empty or failed static-image slots keep local placeholders. - Empty or failed static-image slots keep local placeholders.
- Do not invent dashboard/CMS/upload endpoints. - Do not invent dashboard/CMS/upload endpoints.
- Do not add `public/sitemap.xml`, `public/robots.txt`, `app/sitemap.ts`, or `app/robots.ts`; nginx proxies these URLs to Meshkee. - Do not add `public/sitemap.xml`, `public/robots.txt`, `app/sitemap.ts`, or `app/robots.ts`; nginx proxies these URLs to Meshkee.
## Customer account
- Login / user icon goes to `https://customer.khiyalstudio.com`.
- Logged-in header shows `{firstName} عزیز` with dropdown: `پروفایل من`, `خروج`, and `مدیریت وبسایت` when the user is an admin (`https://business.khiyalstudio.com`).
- Auth check: `GET /auth/me` with localStorage token (`accessToken` / `meshkee_access_token`) or credentials cookie.
## Content routes
| Kind | List | Detail | API |
|---|---|---|---|
| Portfolio | `/portfolios` | `/portfolios/{id}/{slug}` | `GET /tenants/{domain}/portfolios`, `.../portfolios/by-id/{id}` |
| Blog | `/blog` | `/blog/{id}/{slug}` | `GET /tenants/{domain}/blogs`, `.../blogs/by-id/{id}` |
- Portfolio/blog details use Meshkee canonical `{id}/{slug}` URLs and redirect if the slug is stale.
- Portfolio details: lightbox gallery, hover zoom, similar-projects row.
- Detail galleries prefer `titleImageUrl` plus `gallery` URLs.
## Homepage static-image slots ## Homepage static-image slots
Dashboard import catalog: `GET /meshkee/static-image-slots`. Dashboard import catalog: `GET /meshkee/static-image-slots`.
@@ -48,11 +66,15 @@ If a rendered image size or crop ratio changes, update the catalog in `src/app/m
## Sitemap and SEO ## Sitemap and SEO
- `GET /meshkee/sitemap-config.json` is generated by scanning public static `src/app/**/page.*` routes. - Do **not** add `public/sitemap.xml`, `public/robots.txt`, `app/sitemap.ts`, or `app/robots.ts`. Nginx proxies `/sitemap.xml` and `/robots.txt` to Meshkee.
- Auth, admin, and Meshkee integration routes are excluded. - Live sitemap index: `/sitemap.xml` → `/sitemap-main.xml` plus module sitemaps (`/sitemap-portfolios.xml`, `/sitemap-blogs.xml`).
- `GET /meshkee/sitemap-config.json` is generated at build time by scanning public static `src/app/**/page.*` routes (`/`, `/about-us`, `/contact-us`, `/portfolios`, `/blog`). CORS `*` is set so the business dashboard can sync it (Website → Settings → Sync sitemap config).
- Dynamic `{id}/{slug}` pages are omitted from that static list; Meshkee fills them from CMS. URL templates are omitted because this site uses Meshkee defaults (`/blog/{id}/{titleSlug}`, `/portfolios/{id}/{titleFaSlug}`).
- Public slugs are built with `slugify()` (letters/numbers kept, spaces → `-`, punctuation stripped). Do **not** use the legacy DB `slug` field in public URLs. Portfolio slugs prefer `titleFa`; blog slugs prefer `title`.
- Auth, admin, checkout, cart, and Meshkee integration routes are excluded.
- Sitemap base URL uses `NEXT_PUBLIC_SITE_URL`, then `SITE_URL`, with `https://khiyalstudio.com` as the fallback. - Sitemap base URL uses `NEXT_PUBLIC_SITE_URL`, then `SITE_URL`, with `https://khiyalstudio.com` as the fallback.
- Future portfolio/blog details must use Meshkee canonical `{id}/{slug}` URLs when those routes are added. - Every crawlable page has unique title + meta description (CMS fields on details), exactly one H1, H2/H3 for real sections, Open Graph, and meaningful image alt text (CMS `titleEn` / `title` / `titleFa` when available). 404 is `noindex`.
- Every crawlable page must have unique metadata, exactly one H1, structured headings, and meaningful image alt text. - Shared helper: `src/lib/seo.ts` (`pageMetadata`).
## Contact ## Contact
@@ -66,5 +88,5 @@ If a rendered image size or crop ratio changes, update the catalog in `src/app/m
- Run `npm run lint` and `npm run build`. - Run `npm run lint` and `npm run build`.
- Verify `/meshkee/static-image-slots` returns the slot catalog. - Verify `/meshkee/static-image-slots` returns the slot catalog.
- Verify `/meshkee/sitemap-config.json` lists `/`, `/about-us`, and `/contact-us`. - Verify `/meshkee/sitemap-config.json` lists `/`, `/about-us`, `/contact-us`, `/portfolios`, `/blog`.
- Check homepage, about, and contact on desktop and mobile; hamburger menu, hero slider, and footer Meshkee credit should match the source palette. - Check homepage, about, contact, and empty states for portfolios/blog. Header user icon should open the customer portal when logged out.
+35 -28
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Reveal } from "@/components/Reveal";
import { features } from "@/data/content"; import { features } from "@/data/content";
import { import {
ABOUT_SLOT_KEYS, ABOUT_SLOT_KEYS,
@@ -9,27 +9,32 @@ import {
slotByKey, slotByKey,
slotImages, slotImages,
} from "@/lib/meshkee"; } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = { export const metadata = pageMetadata({
title: "About Studio", title: "About Studio",
description: description:
"Khiyal Architecture Studio is a contemporary reinterpretation of Iranian architecture — a place where every project is shaped through bold vision, rigorous analysis, and a deep focus on human experience.", "Khiyal Architecture Studio reinterprets Iranian architecture through bold vision, rigorous analysis, and a deep focus on human experience.",
alternates: { canonical: "/about-us" }, path: "/about-us",
}; image: "/images/about/subheader.jpg",
imageAlt: "About Khiyal Architecture Studio",
});
const galleryFallback = [ const galleryFallback = [
"/images/about/1.jpg", { src: "/images/about/1.jpg", alt: "Khiyal Architecture Studio workspace and materials" },
"/images/about/2.jpg", { src: "/images/about/2.jpg", alt: "Architecture model from Khiyal Studio design process" },
"/images/about/3.jpg", { src: "/images/about/3.jpg", alt: "Completed architecture project by Khiyal Studio" },
]; ];
export default async function AboutUsPage() { export default async function AboutUsPage() {
const slots = await getStaticImageSlots("about-us"); const slots = await getStaticImageSlots("about-us");
const hero = slotImages(slotByKey(slots, ABOUT_SLOT_KEYS.hero))[0]; const hero = slotImages(slotByKey(slots, ABOUT_SLOT_KEYS.hero))[0];
const gallery = slotImages(slotByKey(slots, ABOUT_SLOT_KEYS.gallery));
const owner = slotImages(slotByKey(slots, ABOUT_SLOT_KEYS.owner))[0]; const owner = slotImages(slotByKey(slots, ABOUT_SLOT_KEYS.owner))[0];
const gallery = slotImages(slotByKey(slots, ABOUT_SLOT_KEYS.gallery));
const photos = gallery.length >= 3 ? gallery.map((image) => image.url) : galleryFallback; const photos = galleryFallback.map((fallback, index) => ({
src: gallery[index]?.url || fallback.src,
alt: imageAlt(gallery[index], fallback.alt),
}));
return ( return (
<main> <main>
@@ -37,22 +42,22 @@ export default async function AboutUsPage() {
className="page-hero dotted" className="page-hero dotted"
style={{ backgroundImage: `url(${imageUrl(hero, "/images/about/subheader.jpg")})` }} style={{ backgroundImage: `url(${imageUrl(hero, "/images/about/subheader.jpg")})` }}
> >
<div> <Reveal>
<h1> <h1>
<span style={{ color: "var(--base-color)" }}>about</span> studio <span style={{ color: "var(--base-color)" }}>about</span> studio
</h1> </h1>
<h2>Let&apos;s see who we are</h2> <h2>Let&apos;s see who we are</h2>
</div> </Reveal>
</section> </section>
<section className="section dotted"> <section className="section dotted">
<div className="container-site intro-grid"> <div className="container-site about-intro">
<div className="about-visual"> <Reveal className="about-visual">
<img src={photos[0]} alt="Khiyal studio" /> <img className="about-pic-a" src={photos[0].src} alt={photos[0].alt} />
<img src={photos[1]} alt="Khiyal architecture" /> <img className="about-pic-b" src={photos[1].src} alt={photos[1].alt} />
<img src={photos[2] || photos[0]} alt="Khiyal project" style={{ gridColumn: "1 / -1" }} /> <img className="about-pic-c" src={photos[2].src} alt={photos[2].alt} />
</div> </Reveal>
<div> <Reveal>
<span className="eyebrow">About Architecture</span> <span className="eyebrow">About Architecture</span>
<h2 className="heading-white">Working group creating pure ideas</h2> <h2 className="heading-white">Working group creating pure ideas</h2>
<p> <p>
@@ -66,10 +71,10 @@ export default async function AboutUsPage() {
<Link href="/#projects" className="link-line"> <Link href="/#projects" className="link-line">
Explore services → Explore services →
</Link> </Link>
</div> </Reveal>
</div> </div>
<div className="container-site" style={{ marginTop: 64 }}> <Reveal className="container-site" style={{ marginTop: 64 }}>
<h3 className="heading-white" style={{ color: "var(--base-color)" }}> <h3 className="heading-white" style={{ color: "var(--base-color)" }}>
Areas of Expertise – Khiyal Architecture Studio Areas of Expertise – Khiyal Architecture Studio
</h3> </h3>
@@ -78,8 +83,10 @@ export default async function AboutUsPage() {
creating unique, meaningful, and beyond-conventional spaces. Each project is an opportunity to redefine the creating unique, meaningful, and beyond-conventional spaces. Each project is an opportunity to redefine the
experience of living and to reinterpret the principles of refined Iranian architecture in a contemporary way. experience of living and to reinterpret the principles of refined Iranian architecture in a contemporary way.
</p> </p>
</Reveal>
<h3 className="heading-white" style={{ color: "var(--base-color)", marginTop: 40 }}> <Reveal className="container-site" style={{ marginTop: 40 }} self>
<h3 className="heading-white" style={{ color: "var(--base-color)" }}>
About Us About Us
</h3> </h3>
<p> <p>
@@ -129,9 +136,9 @@ export default async function AboutUsPage() {
architecture that offers a different perspective, balances imagination with analysis, and creates a architecture that offers a different perspective, balances imagination with analysis, and creates a
project unlike any other this is Khiyal Architecture Office. project unlike any other this is Khiyal Architecture Office.
</p> </p>
</div> </Reveal>
<div className="container-site owner" style={{ marginTop: 56, maxWidth: 980 }}> <Reveal className="container-site owner" style={{ marginTop: 56, maxWidth: 980 }}>
<img <img
src={imageUrl(owner, "/images/about/owner.jpg")} src={imageUrl(owner, "/images/about/owner.jpg")}
alt={imageAlt(owner, "Mostafa Heydarhoseini")} alt={imageAlt(owner, "Mostafa Heydarhoseini")}
@@ -144,16 +151,16 @@ export default async function AboutUsPage() {
experience of living, and his design defined by a modern, minimal, and bold language seek to create spaces experience of living, and his design defined by a modern, minimal, and bold language seek to create spaces
that are enduring, identity-driven, and deeply human that are enduring, identity-driven, and deeply human
</p> </p>
</div> </Reveal>
<div className="container-site feature-grid" style={{ marginTop: 56 }}> <Reveal className="container-site feature-grid" style={{ marginTop: 56 }}>
{features.map((item) => ( {features.map((item) => (
<div key={item.number} className="feature-item"> <div key={item.number} className="feature-item">
<span>{item.text}</span> <span>{item.text}</span>
<span>{item.number}</span> <span>{item.number}</span>
</div> </div>
))} ))}
</div> </Reveal>
</section> </section>
</main> </main>
); );
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { PageHero } from "@/components/PageHero";
import { Reveal } from "@/components/Reveal";
import { blogPath, displayTitle, excerptOf, getBlogById, getBlogs } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
type Props = { params: Promise<{ id: string; slug: string }> };
export async function generateStaticParams() {
const { items } = await getBlogs({ pageSize: 100 });
return items.map((item) => {
const segments = blogPath(item).split("/");
return { id: item.id, slug: segments[segments.length - 1] || "item" };
});
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const item = await getBlogById(id);
if (!item) notFound();
const title = displayTitle(item);
const description =
excerptOf(item) ||
`${title} — architecture writing from Khiyal Architecture Studio on design, space, and construction.`;
return pageMetadata({
title,
description,
path: blogPath(item),
image: item.titleImageUrl,
imageAlt: title,
type: "article",
});
}
export default async function BlogDetailPage({ params }: Props) {
const { id, slug } = await params;
const item = await getBlogById(id);
if (!item) notFound();
const canonical = blogPath(item);
if (decodeURIComponent(slug) !== decodeURIComponent(canonical.split("/").pop() || "")) {
redirect(canonical);
}
const title = displayTitle(item);
const html = item.mainTextHtml || item.contentHtml || "";
return (
<main>
<PageHero
accent="journal"
title={title}
subtitle={item.categoryName || item.categoryNameFa || "Architecture"}
image={item.titleImageUrl || undefined}
/>
<section className="section dotted">
<Reveal className="container-site article-wrap">
{item.titleImageUrl ? (
<div className="zoom-frame article-cover">
<img src={item.titleImageUrl} alt={title} />
</div>
) : null}
{html ? <div className="rich-content" dangerouslySetInnerHTML={{ __html: html }} /> : <p>{excerptOf(item)}</p>}
<Link href="/blog" className="link-line">
Back to blog →
</Link>
</Reveal>
</section>
</main>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { CatalogCard, EmptyState } from "@/components/CatalogCard";
import { PageHero } from "@/components/PageHero";
import { Reveal } from "@/components/Reveal";
import { blogPath, displayTitle, excerptOf, getBlogs } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata({
title: "Blog",
description:
"Architecture notes, project stories, and studio news from Khiyal Architecture Studio — writing on design, construction, and human-centered space.",
path: "/blog",
image: "/images/slider/hayat.jpg",
imageAlt: "Khiyal Architecture Studio journal",
});
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page } = await searchParams;
const { items } = await getBlogs({ page: Math.max(1, Number(page) || 1), pageSize: 12 });
return (
<main>
<PageHero accent="studio" title="blog" subtitle="Architecture notes and news" />
<section className="section dotted">
<Reveal className="container-site">
{items.length === 0 ? (
<EmptyState>No articles published yet.</EmptyState>
) : (
<div className="catalog-grid">
{items.map((item) => (
<CatalogCard
key={item.id}
href={blogPath(item)}
image={item.titleImageUrl}
title={displayTitle(item)}
excerpt={excerptOf(item)}
meta={item.categoryName || item.categoryNameFa || "Journal"}
/>
))}
</div>
)}
</Reveal>
</section>
</main>
);
}
+14 -11
View File
@@ -1,16 +1,19 @@
import type { Metadata } from "next";
import { ContactForm } from "@/components/ContactForm"; import { ContactForm } from "@/components/ContactForm";
import { Reveal } from "@/components/Reveal";
import { socialLinks } from "@/data/content"; import { socialLinks } from "@/data/content";
import { CONTACT_SLOT_KEYS, getStaticImageSlots, imageUrl, slotByKey, slotImages } from "@/lib/meshkee"; import { CONTACT_SLOT_KEYS, getStaticImageSlots, imageUrl, slotByKey, slotImages } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo";
import { CONTACT } from "@/lib/site"; import { CONTACT } from "@/lib/site";
import { SocialIcon } from "@/components/SocialIcon"; import { SocialIcon } from "@/components/SocialIcon";
export const metadata: Metadata = { export const metadata = pageMetadata({
title: "Contact Us", title: "Contact Us",
description: description:
"Let's work together. Contact Khiyal Architecture Studio at 0912 252 50 08 or Mostafa.eng@live.com.", "Let’s work together. Contact Khiyal Architecture Studio at 0912 252 50 08 or Mostafa.eng@live.com to discuss villas, residential, and commercial architecture.",
alternates: { canonical: "/contact-us" }, path: "/contact-us",
}; image: "/images/about/contact-subheader.jpg",
imageAlt: "Contact Khiyal Architecture Studio",
});
export default async function ContactUsPage() { export default async function ContactUsPage() {
const slots = await getStaticImageSlots("contact-us"); const slots = await getStaticImageSlots("contact-us");
@@ -22,16 +25,16 @@ export default async function ContactUsPage() {
className="page-hero dotted" className="page-hero dotted"
style={{ backgroundImage: `url(${imageUrl(hero, "/images/about/contact-subheader.jpg")})` }} style={{ backgroundImage: `url(${imageUrl(hero, "/images/about/contact-subheader.jpg")})` }}
> >
<div> <Reveal>
<h1> <h1>
<span style={{ color: "var(--base-color)" }}>contact</span> us <span style={{ color: "var(--base-color)" }}>contact</span> us
</h1> </h1>
<h2>Let&apos;s working together</h2> <h2>Let&apos;s working together</h2>
</div> </Reveal>
</section> </section>
<section className="section dotted"> <section className="section dotted">
<div className="container-site contact-top"> <Reveal className="container-site contact-top">
<div> <div>
<span className="eyebrow">Get to know us</span> <span className="eyebrow">Get to know us</span>
<h2 className="heading-white" style={{ fontSize: 34, margin: 0 }}> <h2 className="heading-white" style={{ fontSize: 34, margin: 0 }}>
@@ -65,12 +68,12 @@ export default async function ContactUsPage() {
))} ))}
</div> </div>
</div> </div>
</div> </Reveal>
</section> </section>
<section className="section dotted" style={{ paddingTop: 0 }}> <section className="section dotted" style={{ paddingTop: 0 }}>
<div className="container-site"> <div className="container-site">
<div className="form-wrap"> <Reveal className="form-wrap">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
<h2 className="heading-white" style={{ margin: 0, fontSize: 32 }}> <h2 className="heading-white" style={{ margin: 0, fontSize: 32 }}>
<span style={{ color: "var(--base-color)" }}>Let&apos;s talk.</span> Have a project in mind? <span style={{ color: "var(--base-color)" }}>Let&apos;s talk.</span> Have a project in mind?
@@ -78,7 +81,7 @@ export default async function ContactUsPage() {
<img src="/images/contact-icon.jpg" alt="" style={{ height: 50, width: "auto" }} /> <img src="/images/contact-icon.jpg" alt="" style={{ height: 50, width: "auto" }} />
</div> </div>
<ContactForm /> <ContactForm />
</div> </Reveal>
</div> </div>
<div className="watermark" style={{ position: "relative", bottom: -30 }}> <div className="watermark" style={{ position: "relative", bottom: -30 }}>
architecture architecture
+473 -27
View File
@@ -237,23 +237,28 @@ textarea {
} }
.hamburger { .hamburger {
width: 48px; display: inline-flex;
height: 48px; align-items: center;
justify-content: center;
width: 42px;
height: 42px;
background: transparent; background: transparent;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
display: grid; color: #fff;
gap: 6px; padding: 0;
align-content: center; transition: opacity 0.3s;
justify-items: center;
} }
.hamburger span { .hamburger:hover,
.user-menu-icon:hover,
.user-menu-trigger:hover {
opacity: 0.65;
}
.hamburger svg,
.user-menu-icon svg {
display: block; display: block;
width: 22px;
height: 2px;
background: #fff;
transition: 0.3s ease;
} }
.menu-overlay { .menu-overlay {
@@ -261,12 +266,17 @@ textarea {
inset: 0; inset: 0;
z-index: 50; z-index: 50;
background: #191919 url("/images/brand/vertical-line-bg.svg") center top / cover; background: #191919 url("/images/brand/vertical-line-bg.svg") center top / cover;
display: none;
overflow: auto; overflow: auto;
top: -100%;
visibility: hidden;
pointer-events: none;
transition: top 0.3s ease, visibility 0.3s ease;
} }
.menu-overlay.open { .menu-overlay.open {
display: block; top: 0;
visibility: visible;
pointer-events: auto;
} }
.menu-close { .menu-close {
@@ -281,6 +291,15 @@ textarea {
color: #191919; color: #191919;
font-size: 22px; font-size: 22px;
cursor: pointer; cursor: pointer;
opacity: 0;
transform: scale(0.6);
transition: transform 0.4s cubic-bezier(0.37, 0, 0.63, 1), opacity 0.4s cubic-bezier(0.37, 0, 0.63, 1);
}
.menu-overlay.open .menu-close {
opacity: 1;
transform: scale(1);
transition-delay: 0.5s;
} }
.menu-grid { .menu-grid {
@@ -292,6 +311,34 @@ textarea {
align-items: center; align-items: center;
} }
.menu-logo,
.menu-address,
.menu-social {
opacity: 0;
transform: translateY(-50px);
transition:
transform 0.5s cubic-bezier(0.4, 0.01, 0.165, 0.99),
opacity 0.6s cubic-bezier(0.4, 0.01, 0.165, 0.99);
}
.menu-overlay.open .menu-logo {
opacity: 1;
transform: translateY(0);
transition-delay: 0.2s;
}
.menu-overlay.open .menu-address {
opacity: 1;
transform: translateY(0);
transition-delay: 0.4s;
}
.menu-overlay.open .menu-social {
opacity: 1;
transform: translateY(0);
transition-delay: 0.8s;
}
.menu-nav a { .menu-nav a {
display: block; display: block;
font-family: var(--alt-font); font-family: var(--alt-font);
@@ -299,8 +346,26 @@ textarea {
line-height: 1.05; line-height: 1.05;
color: #fff; color: #fff;
letter-spacing: -0.03em; letter-spacing: -0.03em;
opacity: 0;
transform: scale(1.15) translateY(-30px);
transition:
transform 0.5s cubic-bezier(0.4, 0.01, 0.165, 0.99),
opacity 0.6s cubic-bezier(0.4, 0.01, 0.165, 0.99),
color 0.2s ease;
} }
.menu-overlay.open .menu-nav a {
opacity: 1;
transform: scale(1) translateY(0);
}
.menu-overlay.open .menu-nav a:nth-child(1) { transition-delay: 0.27s; }
.menu-overlay.open .menu-nav a:nth-child(2) { transition-delay: 0.34s; }
.menu-overlay.open .menu-nav a:nth-child(3) { transition-delay: 0.41s; }
.menu-overlay.open .menu-nav a:nth-child(4) { transition-delay: 0.48s; }
.menu-overlay.open .menu-nav a:nth-child(5) { transition-delay: 0.55s; }
.menu-overlay.open .menu-nav a:nth-child(6) { transition-delay: 0.62s; }
.menu-nav a:hover, .menu-nav a:hover,
.menu-nav a.active { .menu-nav a.active {
color: var(--base-color); color: var(--base-color);
@@ -578,18 +643,29 @@ textarea {
left: 0; left: 0;
width: 100%; width: 100%;
z-index: 1; z-index: 1;
padding: 28px 28px 90px; padding: 30px 28px 32px;
background: linear-gradient(to top, #101010 18%, transparent 100%); background: linear-gradient(to top, #101010 22%, transparent 100%);
transform: translateY(60px); transform: translateY(calc(100% - 90px));
transition: transform 550ms cubic-bezier(0.12, 0, 0.1, 0); transition: transform 550ms cubic-bezier(0.12, 0, 0.1, 0);
} }
.portfolio-card .copy h3 { .portfolio-card .copy h3 {
margin: 0; margin: 0 0 10px;
color: #fff; color: #fff;
font-size: 22px; font-size: 22px;
font-weight: 500; font-weight: 500;
}
.portfolio-card .copy p {
margin: 0;
color: #d4d4d4;
font-size: 14px;
line-height: 1.7;
text-align: justify; text-align: justify;
display: -webkit-box;
-webkit-line-clamp: 8;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.portfolio-card:hover img { .portfolio-card:hover img {
@@ -907,9 +983,36 @@ textarea {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.menu-overlay,
.menu-close,
.menu-logo,
.menu-address,
.menu-social,
.menu-nav a,
.marquee {
transition: none;
}
.menu-overlay {
top: 0;
visibility: hidden;
}
.menu-overlay.open {
visibility: visible;
}
.menu-logo,
.menu-address,
.menu-social,
.menu-nav a,
.menu-close {
opacity: 1;
transform: none;
}
.marquee { .marquee {
transform: none; transform: none;
transition: none;
} }
} }
@@ -922,18 +1025,20 @@ textarea {
.news-section { .news-section {
position: relative; position: relative;
padding-top: 0; padding-top: 0;
padding-bottom: 120px; padding-bottom: 0;
} }
.news-watermark { .news-watermark {
margin-top: 48px; position: relative;
margin: 32px 0 0;
text-align: center; text-align: center;
font-family: var(--alt-font); font-family: var(--alt-font);
font-size: clamp(5.5rem, 14vw, 12.5rem); font-size: clamp(3rem, 8vw, 7.5rem);
line-height: 1; line-height: 0.72;
font-weight: 600; font-weight: 600;
letter-spacing: -4px; letter-spacing: -0.04em;
color: var(--nero-grey); color: var(--nero-grey);
white-space: nowrap;
pointer-events: none; pointer-events: none;
} }
@@ -1000,11 +1105,103 @@ textarea {
font-weight: 600; font-weight: 600;
} }
.about-visual { .about-intro {
display: grid; display: grid;
grid-template-columns: 0.7fr 1fr; grid-template-columns: 7fr 5fr;
gap: 18px; gap: 30px;
align-items: center;
}
.about-visual {
position: relative; position: relative;
display: flex;
align-items: center;
padding-bottom: 50px;
}
.about-pic-a,
.about-pic-b,
.about-pic-c {
display: block;
height: auto;
}
.about-pic-a {
width: 41.666%;
padding: 0 15px;
box-sizing: border-box;
}
.about-pic-b {
width: 50%;
padding: 0 15px;
box-sizing: border-box;
box-shadow: 0 25px 70px rgba(0, 0, 0, 0.45);
}
.about-pic-c {
position: absolute;
left: 150px;
bottom: -50px;
width: 40%;
z-index: 2;
box-shadow: 0 25px 70px rgba(0, 0, 0, 0.45);
}
.reveal > * {
opacity: 0;
transform: translateY(30px);
transition:
transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.reveal > *:nth-child(1) { transition-delay: 0s; }
.reveal > *:nth-child(2) { transition-delay: 0.3s; }
.reveal > *:nth-child(3) { transition-delay: 0.6s; }
.reveal > *:nth-child(4) { transition-delay: 0.9s; }
.reveal > *:nth-child(5) { transition-delay: 1.2s; }
.reveal > *:nth-child(6) { transition-delay: 1.5s; }
.reveal > *:nth-child(7) { transition-delay: 1.8s; }
.reveal > *:nth-child(8) { transition-delay: 2.1s; }
.reveal.is-in > * {
opacity: 1;
transform: none;
}
.about-visual.is-in .about-pic-c { transition-delay: 0.1s; }
.about-visual.is-in .about-pic-b { transition-delay: 0.2s; }
.about-visual.is-in .about-pic-a { transition-delay: 0.5s; }
.reveal-self {
opacity: 0;
transform: translateY(30px);
transition:
transform 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.reveal-self.is-in {
opacity: 1;
transform: none;
}
.reveal-self > *,
.reveal-self.is-in > * {
opacity: 1;
transform: none;
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.reveal > *,
.reveal-self,
.reveal-self.is-in > * {
opacity: 1;
transform: none;
transition: none;
}
} }
.about-copy p { .about-copy p {
@@ -1074,7 +1271,7 @@ textarea {
.site-footer { .site-footer {
background: var(--nero-grey); background: var(--nero-grey);
padding: 96px 0 0; padding: 72px 0 0;
} }
.footer-cols { .footer-cols {
@@ -1284,6 +1481,7 @@ textarea {
@media (max-width: 991px) { @media (max-width: 991px) {
.menu-grid, .menu-grid,
.intro-grid, .intro-grid,
.about-intro,
.years-wrap, .years-wrap,
.video-split, .video-split,
.feature-grid, .feature-grid,
@@ -1296,6 +1494,10 @@ textarea {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.about-intro {
grid-template-columns: 1fr;
}
.years-wrap { .years-wrap {
grid-template-columns: 1fr; grid-template-columns: 1fr;
justify-items: center; justify-items: center;
@@ -1350,6 +1552,7 @@ textarea {
.menu-grid, .menu-grid,
.intro-grid, .intro-grid,
.about-intro,
.years-wrap, .years-wrap,
.video-split, .video-split,
.feature-grid, .feature-grid,
@@ -1432,6 +1635,29 @@ textarea {
display: none; display: none;
} }
.about-intro {
grid-template-columns: 1fr;
}
.about-visual {
display: grid;
gap: 16px;
padding-bottom: 0;
}
.about-pic-a,
.about-pic-b,
.about-pic-c {
position: relative;
width: 100%;
left: auto;
right: auto;
top: auto;
bottom: auto;
padding: 0;
margin: 0;
}
.marquee { .marquee {
letter-spacing: -2px; letter-spacing: -2px;
} }
@@ -1455,3 +1681,223 @@ textarea {
text-align: center; text-align: center;
} }
} }
.header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.user-menu {
position: relative;
min-width: 42px;
min-height: 42px;
}
.user-menu-icon,
.user-menu-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #fff;
background: transparent;
border: 0;
cursor: pointer;
padding: 0;
font-size: 13px;
min-width: 42px;
height: 42px;
transition: opacity 0.3s;
}
.user-menu-icon {
width: 42px;
}
.user-menu-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
min-width: 180px;
background: #111;
border: 1px solid var(--charcoal-grey);
z-index: 60;
}
.user-menu-dropdown a,
.user-menu-dropdown button {
display: block;
width: 100%;
text-align: right;
padding: 12px 16px;
color: #fff;
background: none;
border: 0;
cursor: pointer;
font-size: 14px;
}
.user-menu-dropdown a:hover,
.user-menu-dropdown button:hover {
background: var(--nero-grey);
color: var(--base-color);
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 28px;
}
.catalog-card {
display: grid;
color: #fff;
}
.catalog-card .zoom-frame {
min-height: 280px;
}
.catalog-copy {
display: grid;
gap: 8px;
padding-top: 18px;
}
.catalog-copy strong {
font-size: 22px;
font-weight: 500;
}
.catalog-copy span:last-child {
color: var(--medium-gray);
}
.catalog-fallback {
display: block;
min-height: 280px;
background: var(--nero-grey);
}
.empty-state {
text-align: center;
padding: 48px 0;
}
.zoom-frame {
display: block;
overflow: hidden;
width: 100%;
background: var(--nero-grey);
}
.zoom-frame img {
width: 100%;
height: 100%;
object-fit: cover;
min-height: 280px;
transition: transform 0.7s ease;
}
.zoom-frame:hover img {
transform: scale(1.12);
}
.gallery-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.gallery-grid .zoom-frame:first-child {
grid-column: 1 / -1;
}
.lightbox {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(0, 0, 0, 0.92);
display: grid;
place-items: center;
}
.lightbox img {
max-width: min(92vw, 1200px);
max-height: 88vh;
object-fit: contain;
}
.lightbox-close,
.lightbox-nav {
position: absolute;
color: #fff;
background: transparent;
border: 0;
font-size: 32px;
cursor: pointer;
}
.lightbox-close {
top: 20px;
right: 28px;
}
.lightbox-nav.prev {
left: 24px;
}
.lightbox-nav.next {
right: 24px;
}
.detail-layout {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 48px;
align-items: start;
}
.article-wrap {
max-width: 860px;
}
.article-cover {
margin-bottom: 32px;
}
.rich-content {
color: var(--medium-gray);
line-height: 1.9;
}
.rich-content h2,
.rich-content h3,
.rich-content strong {
color: #fff;
}
.rich-content p {
margin: 0 0 16px;
}
@media (max-width: 991px) {
.catalog-grid,
.detail-layout,
.gallery-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 767px) {
.catalog-grid,
.detail-layout,
.gallery-grid {
grid-template-columns: 1fr;
}
.user-menu-trigger span {
display: none;
}
}
+5 -4
View File
@@ -1,25 +1,26 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Footer } from "@/components/Footer"; import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { SITE_DESCRIPTION, SITE_TITLE, SITE_URL } from "@/lib/site"; import { SITE_DESCRIPTION, SITE_NAME, SITE_TITLE, SITE_URL } from "@/lib/site";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(SITE_URL), metadataBase: new URL(SITE_URL),
title: { title: {
default: SITE_TITLE, default: SITE_TITLE,
template: "%s | Khiyal Architecture Studio", template: `%s | ${SITE_NAME}`,
}, },
description: SITE_DESCRIPTION, description: SITE_DESCRIPTION,
icons: { icon: "/favicon.png" }, icons: { icon: "/favicon.png" },
robots: { index: true, follow: true },
openGraph: { openGraph: {
title: SITE_TITLE, title: SITE_TITLE,
description: SITE_DESCRIPTION, description: SITE_DESCRIPTION,
url: "/", url: "/",
siteName: "Khiyal Architecture Studio", siteName: SITE_NAME,
locale: "en_US", locale: "en_US",
type: "website", type: "website",
images: [{ url: "/images/slider/hayat.jpg", alt: "Khiyal Architecture Studio" }], images: [{ url: "/images/slider/hayat.jpg", alt: "Hayat architecture project by Khiyal Studio" }],
}, },
}; };
@@ -56,6 +56,8 @@ function collectStaticPagePaths(directory: string, routeSegments: string[] = [])
function toStaticPage(path: string): StaticPage { function toStaticPage(path: string): StaticPage {
if (path === "/") return { path, changefreq: "daily", priority: 1 }; if (path === "/") return { path, changefreq: "daily", priority: 1 };
if (path === "/portfolios") return { path, changefreq: "weekly", priority: 0.8 };
if (path === "/blog") return { path, changefreq: "weekly", priority: 0.7 };
if (path === "/about-us") return { path, changefreq: "monthly", priority: 0.6 }; if (path === "/about-us") return { path, changefreq: "monthly", priority: 0.6 };
if (path === "/contact-us") return { path, changefreq: "monthly", priority: 0.5 }; if (path === "/contact-us") return { path, changefreq: "monthly", priority: 0.5 };
return { path, changefreq: "weekly", priority: 0.5 }; return { path, changefreq: "weekly", priority: 0.5 };
@@ -82,6 +84,7 @@ export function GET() {
{ {
headers: { headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600", "Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
"Access-Control-Allow-Origin": "*",
}, },
}, },
); );
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHero } from "@/components/PageHero";
export const metadata: Metadata = {
title: "Page not found",
description: "The requested page does not exist. Return to Khiyal Architecture Studio to continue exploring projects and journal.",
robots: { index: false, follow: true },
};
export default function NotFound() {
return (
<main>
<PageHero accent="page" title="not found" subtitle="The requested page does not exist" />
<section className="section dotted">
<div className="container-site">
<p>This page is unavailable.</p>
<Link href="/" className="link-line">
Back to home →
</Link>
</div>
</section>
</main>
);
}
+31 -26
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { Counters } from "@/components/Counters"; import { Counters } from "@/components/Counters";
import { HeroSlider } from "@/components/HeroSlider"; import { HeroSlider } from "@/components/HeroSlider";
import { PortfolioCarousel } from "@/components/PortfolioCarousel";
import { MarqueeZoom } from "@/components/MarqueeZoom"; import { MarqueeZoom } from "@/components/MarqueeZoom";
import { PortfolioCarousel } from "@/components/PortfolioCarousel";
import { RecentProjects } from "@/components/RecentProjects";
import { Reveal } from "@/components/Reveal";
import { Testimonials } from "@/components/Testimonials"; import { Testimonials } from "@/components/Testimonials";
import { import {
features, features,
@@ -22,12 +23,16 @@ import {
slotImages, slotImages,
} from "@/lib/meshkee"; } from "@/lib/meshkee";
import { CONTACT, SITE_DESCRIPTION, SITE_TITLE } from "@/lib/site"; import { CONTACT, SITE_DESCRIPTION, SITE_TITLE } from "@/lib/site";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = { export const metadata = pageMetadata({
title: SITE_TITLE, title: SITE_TITLE,
description: SITE_DESCRIPTION, description: SITE_DESCRIPTION,
alternates: { canonical: "/" }, path: "/",
}; image: "/images/slider/hayat.jpg",
imageAlt: "Hayat architecture project by Khiyal Studio",
absoluteTitle: true,
});
export default async function HomePage() { export default async function HomePage() {
const slots = await getStaticImageSlots("home"); const slots = await getStaticImageSlots("home");
@@ -72,7 +77,7 @@ export default async function HomePage() {
<section className="section dotted"> <section className="section dotted">
<div className="container-site intro-grid"> <div className="container-site intro-grid">
<div> <Reveal>
<img <img
src={imageUrl(signature, "/images/brand/signature.png")} src={imageUrl(signature, "/images/brand/signature.png")}
alt={imageAlt(signature, "Khiyal signature")} alt={imageAlt(signature, "Khiyal signature")}
@@ -81,8 +86,8 @@ export default async function HomePage() {
<h2 className="heading-white" style={{ fontSize: "28px", marginTop: 20 }}> <h2 className="heading-white" style={{ fontSize: "28px", marginTop: 20 }}>
Khiyal | Between Thought and Construction Architecture, experienced before it is built Khiyal | Between Thought and Construction Architecture, experienced before it is built
</h2> </h2>
</div> </Reveal>
<div className="years-wrap"> <Reveal className="years-wrap">
<div className="years-badge"> <div className="years-badge">
<div className="circle" /> <div className="circle" />
<div className="num">15</div> <div className="num">15</div>
@@ -96,21 +101,21 @@ export default async function HomePage() {
fulfill functional needs while creating profound and enduring human experiences fulfill functional needs while creating profound and enduring human experiences
</p> </p>
</div> </div>
</div> </Reveal>
</div> </div>
<div className="container-site feature-grid"> <Reveal className="container-site feature-grid">
{features.map((item) => ( {features.map((item) => (
<div key={item.number} className="feature-item"> <div key={item.number} className="feature-item">
<span>{item.text}</span> <span>{item.text}</span>
<span>{item.number}</span> <span>{item.number}</span>
</div> </div>
))} ))}
</div> </Reveal>
</section> </section>
<section id="services" className="section dotted"> <section id="services" className="section dotted">
<div className="container-site"> <div className="container-site">
<div className="intro-grid" style={{ marginBottom: 48 }}> <Reveal className="intro-grid" style={{ marginBottom: 48 }}>
<div> <div>
<span className="eyebrow">Architecture services</span> <span className="eyebrow">Architecture services</span>
<h2 className="heading-white" style={{ fontSize: "30px" }}> <h2 className="heading-white" style={{ fontSize: "30px" }}>
@@ -121,10 +126,10 @@ export default async function HomePage() {
At Khiyal Studio, design is not merely built—it is brought to life through boldness and innovation; we craft At Khiyal Studio, design is not merely built—it is brought to life through boldness and innovation; we craft
spaces that are distinctive, memorable, and beyond the ordinary spaces that are distinctive, memorable, and beyond the ordinary
</p> </p>
</div> </Reveal>
<PortfolioCarousel /> <PortfolioCarousel />
</div> </div>
<div className="container-site"> <Reveal className="container-site">
<div className="partners"> <div className="partners">
{(partnerImages.length {(partnerImages.length
? partnerImages.map((image, index) => ({ ? partnerImages.map((image, index) => ({
@@ -144,7 +149,7 @@ export default async function HomePage() {
</a> </a>
))} ))}
</div> </div>
</div> </Reveal>
</section> </section>
<section className="video-split"> <section className="video-split">
@@ -154,7 +159,7 @@ export default async function HomePage() {
backgroundImage: `url(${imageUrl(videoCover, "/images/video-cover.jpg")})`, backgroundImage: `url(${imageUrl(videoCover, "/images/video-cover.jpg")})`,
}} }}
/> />
<div className="video-cta"> <Reveal className="video-cta">
<a href={CONTACT.video} className="play-btn" target="_blank" rel="noreferrer" aria-label="Watch video"> <a href={CONTACT.video} className="play-btn" target="_blank" rel="noreferrer" aria-label="Watch video">
▶ ▶
</a> </a>
@@ -162,11 +167,11 @@ export default async function HomePage() {
click for <span>watch video</span> click for <span>watch video</span>
</h2> </h2>
<div className="watermark text-outline">architecture</div> <div className="watermark text-outline">architecture</div>
</div> </Reveal>
</section> </section>
<section id="projects" className="section dotted" style={{ background: "var(--nero-grey)" }}> <section id="projects" className="section dotted" style={{ background: "var(--nero-grey)" }}>
<div className="container-site intro-grid"> <Reveal className="container-site intro-grid">
<div> <div>
<span className="eyebrow">Best Projects</span> <span className="eyebrow">Best Projects</span>
<h2 className="heading-white" style={{ fontSize: 30 }}> <h2 className="heading-white" style={{ fontSize: 30 }}>
@@ -188,15 +193,15 @@ export default async function HomePage() {
</Link> </Link>
))} ))}
</div> </div>
</div> </Reveal>
</section> </section>
<section className="section dotted" style={{ paddingTop: 0 }}> <section className="section dotted" style={{ paddingTop: 0 }}>
<div className="container-site"> <Reveal className="container-site">
<RecentProjects items={recent} /> <RecentProjects items={recent} />
<Counters /> <Counters />
<Testimonials /> <Testimonials />
</div> </Reveal>
</section> </section>
<section className="section dotted marquee-section"> <section className="section dotted marquee-section">
@@ -205,11 +210,11 @@ export default async function HomePage() {
<section id="news" className="section dotted news-section"> <section id="news" className="section dotted news-section">
<div className="container-site"> <div className="container-site">
<div style={{ textAlign: "center", marginBottom: 36 }}> <Reveal style={{ textAlign: "center", marginBottom: 36 }}>
<span className="eyebrow">Architecture news</span> <span className="eyebrow">Architecture news</span>
<h2 className="heading-white">Projects Under Construction Current Developments</h2> <h2 className="heading-white">Projects Under Construction Current Developments</h2>
</div> </Reveal>
<div className="news-grid"> <Reveal className="news-grid">
{news.map((item) => ( {news.map((item) => (
<figure key={item.title} className="news-card"> <figure key={item.title} className="news-card">
<img src={item.src} alt={item.title} /> <img src={item.src} alt={item.title} />
@@ -219,10 +224,10 @@ export default async function HomePage() {
</figcaption> </figcaption>
</figure> </figure>
))} ))}
</div> </Reveal>
</div> </div>
<div className="news-watermark" aria-hidden> <div className="news-watermark" aria-hidden>
architecture steel dance studio
</div> </div>
</section> </section>
</main> </main>
+112
View File
@@ -0,0 +1,112 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { CatalogCard } from "@/components/CatalogCard";
import { LightboxGallery } from "@/components/LightboxGallery";
import { PageHero } from "@/components/PageHero";
import { Reveal } from "@/components/Reveal";
import {
displayTitle,
excerptOf,
galleryUrls,
getPortfolioById,
getPortfolios,
portfolioPath,
} from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo";
export const revalidate = 60;
type Props = { params: Promise<{ id: string; slug: string }> };
export async function generateStaticParams() {
const { items } = await getPortfolios({ pageSize: 100 });
return items.map((item) => {
const segments = portfolioPath(item).split("/");
return { id: item.id, slug: segments[segments.length - 1] || "item" };
});
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const item = await getPortfolioById(id);
if (!item) notFound();
const title = displayTitle(item);
const description =
excerptOf(item) ||
`${title} is an architecture project by Khiyal Architecture Studio, designed around human experience, material, and site.`;
return pageMetadata({
title,
description,
path: portfolioPath(item),
image: item.titleImageUrl,
imageAlt: title,
type: "article",
});
}
export default async function PortfolioDetailPage({ params }: Props) {
const { id, slug } = await params;
const item = await getPortfolioById(id);
if (!item) notFound();
const canonical = portfolioPath(item);
if (decodeURIComponent(slug) !== decodeURIComponent(canonical.split("/").pop() || "")) {
redirect(canonical);
}
const title = displayTitle(item);
const images = galleryUrls(item);
const similar = (await getPortfolios({ categoryId: item.categoryId || undefined, pageSize: 4 })).items
.filter((entry) => entry.id !== item.id)
.slice(0, 3);
return (
<main>
<PageHero accent="project" title={title} subtitle={item.categoryName || "Architecture"} image={images[0]} />
<section className="section dotted">
<div className="container-site detail-layout">
<Reveal>
<LightboxGallery images={images} title={title} />
</Reveal>
<Reveal>
{item.mainTextHtml ? (
<div className="rich-content" dangerouslySetInnerHTML={{ __html: item.mainTextHtml }} />
) : (
<p>{excerptOf(item)}</p>
)}
{item.projectUrl ? (
<p>
<a href={item.projectUrl} target="_blank" rel="noreferrer" className="link-line">
Project website →
</a>
</p>
) : null}
<Link href="/portfolios" className="link-line">
Back to projects →
</Link>
</Reveal>
</div>
</section>
{similar.length > 0 ? (
<section className="section dotted">
<Reveal className="container-site">
<span className="eyebrow">Similar projects</span>
<h2 className="heading-white">Related work</h2>
<div className="catalog-grid">
{similar.map((entry) => (
<CatalogCard
key={entry.id}
href={portfolioPath(entry)}
image={entry.titleImageUrl}
title={displayTitle(entry)}
excerpt={excerptOf(entry)}
/>
))}
</div>
</Reveal>
</section>
) : null}
</main>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { CatalogCard, EmptyState } from "@/components/CatalogCard";
import { PageHero } from "@/components/PageHero";
import { Reveal } from "@/components/Reveal";
import { displayTitle, excerptOf, getPortfolios, portfolioPath } from "@/lib/meshkee";
import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata({
title: "Projects",
description:
"Selected architecture projects by Khiyal Architecture Studio, from villas and residential buildings to offices and commercial spaces shaped with clients.",
path: "/portfolios",
image: "/images/slider/hayat.jpg",
imageAlt: "Architecture projects by Khiyal Studio",
});
export default async function PortfoliosPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page } = await searchParams;
const { items } = await getPortfolios({ page: Math.max(1, Number(page) || 1), pageSize: 12 });
return (
<main>
<PageHero accent="our" title="projects" subtitle="Selected architecture works" />
<section className="section dotted">
<Reveal className="container-site">
{items.length === 0 ? (
<EmptyState>No projects published yet.</EmptyState>
) : (
<div className="catalog-grid">
{items.map((item) => (
<CatalogCard
key={item.id}
href={portfolioPath(item)}
image={item.titleImageUrl}
title={displayTitle(item)}
excerpt={excerptOf(item)}
meta={item.categoryName || "Architecture"}
/>
))}
</div>
)}
</Reveal>
</section>
</main>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Link from "next/link";
export function CatalogCard({
href,
image,
title,
excerpt,
meta,
}: {
href: string;
image?: string | null;
title: string;
excerpt?: string;
meta?: string;
}) {
return (
<Link href={href} className="catalog-card">
<span className="zoom-frame">
{image ? <img src={image} alt={title} /> : <span className="catalog-fallback" />}
</span>
<span className="catalog-copy">
{meta ? <span className="eyebrow">{meta}</span> : null}
<strong>{title}</strong>
{excerpt ? <span>{excerpt}</span> : null}
</span>
</Link>
);
}
export function EmptyState({ children }: { children: string }) {
return <p className="empty-state">{children}</p>;
}
+26 -17
View File
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { navItems, socialLinks } from "@/data/content"; import { navItems, socialLinks } from "@/data/content";
import { CONTACT } from "@/lib/site"; import { CONTACT } from "@/lib/site";
import { UserMenu } from "./UserMenu";
import { SocialIcon } from "./SocialIcon"; import { SocialIcon } from "./SocialIcon";
export function Header() { export function Header() {
@@ -24,18 +25,22 @@ export function Header() {
<Link href="/" className="logo" aria-label="Khiyal Architecture Studio"> <Link href="/" className="logo" aria-label="Khiyal Architecture Studio">
<img src="/images/brand/logo-white.png" alt="Khiyal Architecture Studio" /> <img src="/images/brand/logo-white.png" alt="Khiyal Architecture Studio" />
</Link> </Link>
<button <div className="header-actions">
type="button" <UserMenu />
className="hamburger" <button
aria-label="Open menu" type="button"
aria-expanded={open} className="hamburger"
onClick={() => setOpen(true)} aria-label="Open menu"
> aria-expanded={open}
<span /> onClick={() => setOpen(true)}
<span /> >
<span /> <svg width="22" height="14" viewBox="0 0 22 14" aria-hidden>
<span /> <rect width="22" height="2" fill="currentColor" />
</button> <rect y="6" width="22" height="2" fill="currentColor" />
<rect y="12" width="14" height="2" fill="currentColor" />
</svg>
</button>
</div>
</header> </header>
<div className={`menu-overlay ${open ? "open" : ""}`} role="dialog" aria-modal="true"> <div className={`menu-overlay ${open ? "open" : ""}`} role="dialog" aria-modal="true">
@@ -44,21 +49,21 @@ export function Header() {
</button> </button>
<div className="menu-grid"> <div className="menu-grid">
<div> <div>
<Link href="/" onClick={close}> <Link href="/" className="menu-logo" onClick={close}>
<img src="/images/brand/logo-footer.png" alt="Khiyal" style={{ height: 72, width: "auto" }} /> <img src="/images/brand/logo-footer.png" alt="Khiyal" style={{ height: 72, width: "auto" }} />
</Link> </Link>
<div className="menu-meta"> <div className="menu-meta">
<div> <div className="menu-address">
<span className="eyebrow">Appointments</span> <span className="eyebrow">Appointments</span>
<p>For better access to the office, please visit the contact page.</p> <p>For better access to the office, please visit the contact page.</p>
</div> </div>
<div> <div className="menu-address">
<span className="eyebrow">Let&apos;s talk</span> <span className="eyebrow">Let&apos;s talk</span>
<a href={`mailto:${CONTACT.email}`} className="heading-white"> <a href={`mailto:${CONTACT.email}`} className="heading-white">
{CONTACT.email} {CONTACT.email}
</a> </a>
</div> </div>
<div> <div className="menu-social">
<span className="eyebrow">Connect with us</span> <span className="eyebrow">Connect with us</span>
<div className="social-row"> <div className="social-row">
{socialLinks.map((item) => ( {socialLinks.map((item) => (
@@ -75,7 +80,11 @@ export function Header() {
<Link <Link
key={item.label} key={item.label}
href={item.href} href={item.href}
className={pathname === item.href ? "active" : undefined} className={
pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
? "active"
: undefined
}
onClick={close} onClick={close}
> >
{item.label} {item.label}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
export function LightboxGallery({ images, title }: { images: string[]; title: string }) {
const [open, setOpen] = useState(false);
const [index, setOpenIndex] = useState(0);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
if (event.key === "ArrowRight") setOpenIndex((current) => (current + 1) % images.length);
if (event.key === "ArrowLeft") setOpenIndex((current) => (current - 1 + images.length) % images.length);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [images.length, open]);
if (images.length === 0) return null;
const current = images[index] || images[0];
return (
<>
<div className="gallery-grid">
{images.map((image, imageIndex) => (
<button
key={`${image}-${imageIndex}`}
type="button"
className="zoom-frame"
onClick={() => {
setOpenIndex(imageIndex);
setOpen(true);
}}
>
<img src={image} alt={`${title} ${imageIndex + 1}`} />
</button>
))}
</div>
{open ? (
<div className="lightbox" role="dialog" aria-modal="true" aria-label={title} onClick={() => setOpen(false)}>
<button type="button" className="lightbox-close" aria-label="Close">
×
</button>
{images.length > 1 ? (
<>
<button
type="button"
className="lightbox-nav prev"
aria-label="Previous image"
onClick={(event) => {
event.stopPropagation();
setOpenIndex((current) => (current - 1 + images.length) % images.length);
}}
>
←
</button>
<button
type="button"
className="lightbox-nav next"
aria-label="Next image"
onClick={(event) => {
event.stopPropagation();
setOpenIndex((current) => (current + 1) % images.length);
}}
>
→
</button>
</>
) : null}
<img src={current} alt={title} onClick={(event) => event.stopPropagation()} />
</div>
) : null}
</>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { Reveal } from "@/components/Reveal";
export function PageHero({
accent,
title,
subtitle,
image,
}: {
accent: string;
title: string;
subtitle: string;
image?: string;
}) {
return (
<section className="page-hero dotted" style={image ? { backgroundImage: `url(${image})` } : undefined}>
<Reveal>
<h1>
<span style={{ color: "var(--base-color)" }}>{accent}</span> {title}
</h1>
<h2>{subtitle}</h2>
</Reveal>
</section>
);
}
+1
View File
@@ -45,6 +45,7 @@ export function PortfolioCarousel() {
<img src={item.image} alt={item.titleEn} /> <img src={item.image} alt={item.titleEn} />
<div className="copy"> <div className="copy">
<h3>{item.titleEn}</h3> <h3>{item.titleEn}</h3>
<p>{item.text}</p>
</div> </div>
</article> </article>
))} ))}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
export function Reveal({
children,
className = "",
style,
self = false,
}: {
children: ReactNode;
className?: string;
style?: CSSProperties;
self?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
const show = () => setVisible(true);
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
show();
observer.disconnect();
},
{ threshold: 0.05, rootMargin: "0px 0px -8% 0px" },
);
observer.observe(node);
const rect = node.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.9 && rect.bottom > 80) {
show();
observer.disconnect();
}
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={`reveal${visible ? " is-in" : ""}${self ? " reveal-self" : ""} ${className}`.trim()}
style={style}
>
{children}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { isAdminUser, useAuthUser } from "@/lib/auth";
import { BUSINESS_URL, CUSTOMER_URL, customerLoginUrl } from "@/lib/site";
export function UserMenu() {
const { user, ready, isLoggedIn, logout } = useAuthUser();
const [open, setOpen] = useState(false);
if (!ready) {
return <div className="user-menu" aria-hidden />;
}
if (!isLoggedIn) {
return (
<a className="user-menu-icon" href={customerLoginUrl()} aria-label="Login">
<UserGlyph />
</a>
);
}
const name = [user?.firstName, user?.lastName].filter(Boolean).join(" ") || "کاربر";
return (
<div className="user-menu">
<button
type="button"
className="user-menu-trigger"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<UserGlyph />
<span>
{name} عزیز
</span>
</button>
{open ? (
<div className="user-menu-dropdown" dir="rtl">
<a href={`${CUSTOMER_URL}/profile`}>پروفایل من</a>
{isAdminUser(user) ? <a href={BUSINESS_URL}>مدیریت وبسایت</a> : null}
<button type="button" onClick={logout}>
خروج
</button>
</div>
) : null}
</div>
);
}
function UserGlyph() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="8" r="3.25" stroke="currentColor" strokeWidth="2" />
<path
d="M5.2 19.2c.85-3.15 3.4-5 6.8-5s5.95 1.85 6.8 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
+2 -2
View File
@@ -4,8 +4,8 @@ export const navItems = [
{ href: "/", label: "Home" }, { href: "/", label: "Home" },
{ href: "/about-us", label: "About" }, { href: "/about-us", label: "About" },
{ href: "/#services", label: "Services" }, { href: "/#services", label: "Services" },
{ href: "/#projects", label: "Projects" }, { href: "/portfolios", label: "Projects" },
{ href: "/#news", label: "Product design" }, { href: "/blog", label: "Blog" },
{ href: "/contact-us", label: "Contact" }, { href: "/contact-us", label: "Contact" },
]; ];
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { API_BASE } from "@/lib/site";
const TOKEN_KEYS = ["accessToken", "meshkee_access_token", "meshkee.accessToken"];
export type AuthUser = {
firstName?: string;
lastName?: string;
email?: string;
role?: string;
roles?: string[];
staffRole?: string;
isAdmin?: boolean;
isWebsiteAdmin?: boolean;
isOwner?: boolean;
};
function readToken() {
if (typeof window === "undefined") return null;
for (const key of TOKEN_KEYS) {
const value = window.localStorage.getItem(key);
if (value) return value;
}
return null;
}
export function clearAuthTokens() {
if (typeof window === "undefined") return;
for (const key of TOKEN_KEYS) window.localStorage.removeItem(key);
}
export function isAdminUser(user: AuthUser | null) {
if (!user) return false;
if (user.isAdmin || user.isWebsiteAdmin || user.isOwner) return true;
const role = String(user.role || user.staffRole || "").toLowerCase();
if (["admin", "owner", "manager"].includes(role)) return true;
return Array.isArray(user.roles) && user.roles.some((roleName) => /admin|owner/i.test(String(roleName)));
}
export function useAuthUser() {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const nextToken = readToken();
setToken(nextToken);
fetch(`${API_BASE}/auth/me`, {
headers: nextToken ? { Authorization: `Bearer ${nextToken}` } : {},
credentials: "include",
})
.then(async (response) => {
if (!response.ok) return null;
const data = (await response.json()) as { user?: AuthUser } & AuthUser;
return data.user ?? data;
})
.then((next) => {
if (!cancelled) setUser(next && (next.firstName || next.lastName || next.email) ? next : null);
})
.catch(() => {
if (!cancelled) setUser(null);
})
.finally(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, []);
const logout = () => {
clearAuthTokens();
setUser(null);
setToken(null);
};
return { user, token, ready, isLoggedIn: Boolean(user), logout };
}
+49
View File
@@ -0,0 +1,49 @@
export type MeshkeeComment = {
id: string;
authorName?: string;
text?: string;
body?: string;
createdAt?: string;
};
export type Portfolio = {
id: string;
slug?: string;
title?: string;
titleFa?: string;
titleEn?: string;
abstract?: string;
summary?: string;
mainTextHtml?: string;
projectUrl?: string | null;
categoryId?: string | null;
categoryName?: string | null;
tags?: string[];
titleImageUrl?: string | null;
gallery?: unknown;
comments?: MeshkeeComment[];
commentCount?: number;
publishedAt?: string | null;
createdAt?: string;
};
export type Blog = {
id: string;
slug?: string;
title?: string;
titleFa?: string;
titleEn?: string;
abstract?: string;
summary?: string;
mainTextHtml?: string;
contentHtml?: string;
categoryId?: string | null;
categoryName?: string | null;
categoryNameFa?: string | null;
tags?: string[];
titleImageUrl?: string | null;
comments?: MeshkeeComment[];
commentCount?: number;
publishedAt?: string | null;
createdAt?: string;
};
+122
View File
@@ -1,5 +1,8 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { API_BASE, SITE_DOMAIN } from "@/lib/site"; import { API_BASE, SITE_DOMAIN } from "@/lib/site";
import type { Blog, Portfolio } from "@/lib/meshkee-types";
export type { Blog, MeshkeeComment, Portfolio } from "@/lib/meshkee-types";
export type StaticImage = { export type StaticImage = {
id: string; id: string;
@@ -183,3 +186,122 @@ export function aspectRatioStyle(ratio?: string | null): CSSProperties | undefin
if (!width || !height) return undefined; if (!width || !height) return undefined;
return { aspectRatio: `${width} / ${height}` }; return { aspectRatio: `${width} / ${height}` };
} }
export type Paged<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
export function slugify(value: string) {
const slug = value
.trim()
.replace(/[^\p{L}\p{N}\s-]+/gu, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
return slug || "item";
}
function publicSlug(item: { title?: string; titleEn?: string; titleFa?: string }, preferFa = false) {
const source = preferFa
? item.titleFa || item.title || item.titleEn || ""
: item.title || item.titleEn || item.titleFa || "";
return slugify(source);
}
export function displayTitle(item: { title?: string; titleEn?: string; titleFa?: string }) {
return item.titleEn || item.title || item.titleFa || "Khiyal";
}
export function stripHtml(html: string, maxLength = 180) {
const text = html
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength).trim()}…`;
}
export function excerptOf(item: { abstract?: string; summary?: string; mainTextHtml?: string; contentHtml?: string }) {
return item.abstract || item.summary || stripHtml(item.mainTextHtml || item.contentHtml || "");
}
export function galleryUrls(item: { titleImageUrl?: string | null; gallery?: unknown; images?: unknown }): string[] {
const urls: string[] = [];
const push = (value?: string | null) => {
if (value && !urls.includes(value)) urls.push(value);
};
push(item.titleImageUrl || null);
const raw = item.gallery ?? item.images;
if (Array.isArray(raw)) {
for (const entry of raw) {
if (typeof entry === "string") push(entry);
else if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
push((record.url || record.imageUrl || record.src) as string | undefined);
}
}
}
return urls;
}
function queryString(params: Record<string, string | number | undefined>) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === "") continue;
search.set(key, String(value));
}
const encoded = search.toString();
return encoded ? `?${encoded}` : "";
}
function paged<T>(data: Paged<T> | null, pageSize: number): Paged<T> {
return {
items: Array.isArray(data?.items) ? data.items : [],
total: data?.total ?? 0,
page: data?.page ?? 1,
pageSize: data?.pageSize ?? pageSize,
};
}
export function getPortfolios(params: {
page?: number;
pageSize?: number;
categoryId?: string;
title?: string;
} = {}): Promise<Paged<Portfolio>> {
return meshkeeFetch<Paged<Portfolio>>(`/portfolios${queryString(params)}`).then((data) =>
paged(data, params.pageSize ?? 12),
);
}
export async function getPortfolioById(id: string): Promise<Portfolio | null> {
const data = await meshkeeFetch<{ portfolio?: Portfolio }>(`/portfolios/by-id/${encodeURIComponent(id)}`);
return data?.portfolio ?? null;
}
export function portfolioPath(item: Portfolio) {
return `/portfolios/${item.id}/${publicSlug(item, true)}`;
}
export function getBlogs(params: {
page?: number;
pageSize?: number;
categoryId?: string;
title?: string;
} = {}): Promise<Paged<Blog>> {
return meshkeeFetch<Paged<Blog>>(`/blogs${queryString(params)}`).then((data) => paged(data, params.pageSize ?? 12));
}
export async function getBlogById(id: string): Promise<Blog | null> {
const data = await meshkeeFetch<{ blog?: Blog }>(`/blogs/by-id/${encodeURIComponent(id)}`);
return data?.blog ?? null;
}
export function blogPath(item: Blog) {
return `/blog/${item.id}/${publicSlug(item)}`;
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { SITE_NAME } from "@/lib/site";
type PageSeo = {
title: string;
description: string;
path: string;
image?: string | null;
imageAlt?: string;
absoluteTitle?: boolean;
type?: "website" | "article";
index?: boolean;
};
export function pageMetadata({
title,
description,
path,
image,
imageAlt,
absoluteTitle = false,
type = "website",
index = true,
}: PageSeo): Metadata {
return {
title: absoluteTitle ? { absolute: title } : title,
description,
alternates: { canonical: path },
robots: index ? { index: true, follow: true } : { index: false, follow: true },
openGraph: {
title,
description,
url: path,
siteName: SITE_NAME,
locale: "en_US",
type,
images: image ? [{ url: image, alt: imageAlt || title }] : undefined,
},
};
}
+12 -1
View File
@@ -3,11 +3,22 @@ export const SITE_URL = "https://khiyalstudio.com";
export const API_BASE = export const API_BASE =
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ?? process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ??
"https://api.meshkee.com/api/v1"; "https://api.meshkee.com/api/v1";
export const CUSTOMER_URL = `https://customer.${SITE_DOMAIN}`;
export const BUSINESS_URL = `https://business.${SITE_DOMAIN}`;
export function customerLoginUrl(returnTo?: string) {
const url = new URL("/login", CUSTOMER_URL);
if (returnTo) {
url.searchParams.set("redirect", returnTo);
url.searchParams.set("returnUrl", returnTo);
}
return url.toString();
}
export const SITE_NAME = "Khiyal Architecture Studio"; export const SITE_NAME = "Khiyal Architecture Studio";
export const SITE_TITLE = "Khiyal | Between Thought and Construction"; export const SITE_TITLE = "Khiyal | Between Thought and Construction";
export const SITE_DESCRIPTION = export const SITE_DESCRIPTION =
"At Khiyal Architecture Studio, design transcends meaning and becomes space. We craft environments that fulfill functional needs while creating profound and enduring human experiences."; "At Khiyal Architecture Studio, design transcends meaning and becomes space — environments that fulfill function while creating enduring human experiences.";
export const CONTACT = { export const CONTACT = {
email: "Mostafa.eng@live.com", email: "Mostafa.eng@live.com",