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:
co-authored by
Cursor
parent
d2a82849ef
commit
6cd85e7e59
+31
-9
@@ -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
@@ -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's see who we are</h2>
|
<h2>Let'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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's working together</h2>
|
<h2>Let'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's talk.</span> Have a project in mind?
|
<span style={{ color: "var(--base-color)" }}>Let'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
@@ -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
@@ -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": "*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Reveal className="container-site feature-grid">
|
||||||
<div 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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,6 +25,8 @@ 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>
|
||||||
|
<div className="header-actions">
|
||||||
|
<UserMenu />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hamburger"
|
className="hamburger"
|
||||||
@@ -31,11 +34,13 @@ export function Header() {
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<span />
|
<svg width="22" height="14" viewBox="0 0 22 14" aria-hidden>
|
||||||
<span />
|
<rect width="22" height="2" fill="currentColor" />
|
||||||
<span />
|
<rect y="6" width="22" height="2" fill="currentColor" />
|
||||||
<span />
|
<rect y="12" width="14" height="2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
</button>
|
</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's talk</span>
|
<span className="eyebrow">Let'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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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(/ /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)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user