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`.
|
||||
- Language: `en`, direction: `ltr`.
|
||||
- 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`.
|
||||
- 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.
|
||||
- Public routes: `/`, `/about-us`, `/contact-us`, `/portfolios`, `/portfolios/{id}/{slug}`, `/blog`, `/blog/{id}/{slug}`.
|
||||
- Homepage project/news cards still use local placeholders when Meshkee lists are empty.
|
||||
|
||||
## Source clone
|
||||
|
||||
@@ -22,11 +22,29 @@
|
||||
- Canonical docs: `https://api.meshkee.com/docs/website`
|
||||
- API base: `https://api.meshkee.com/api/v1`
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
- `GET /meshkee/sitemap-config.json` is generated by scanning public static `src/app/**/page.*` routes.
|
||||
- Auth, admin, and Meshkee integration routes are excluded.
|
||||
- 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.
|
||||
- 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.
|
||||
- Future portfolio/blog details must use Meshkee canonical `{id}/{slug}` URLs when those routes are added.
|
||||
- Every crawlable page must have unique metadata, exactly one H1, structured headings, and meaningful image alt text.
|
||||
- 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`.
|
||||
- Shared helper: `src/lib/seo.ts` (`pageMetadata`).
|
||||
|
||||
## 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`.
|
||||
- Verify `/meshkee/static-image-slots` returns the slot catalog.
|
||||
- Verify `/meshkee/sitemap-config.json` lists `/`, `/about-us`, and `/contact-us`.
|
||||
- Check homepage, about, and contact on desktop and mobile; hamburger menu, hero slider, and footer Meshkee credit should match the source palette.
|
||||
- Verify `/meshkee/sitemap-config.json` lists `/`, `/about-us`, `/contact-us`, `/portfolios`, `/blog`.
|
||||
- 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 { Reveal } from "@/components/Reveal";
|
||||
import { features } from "@/data/content";
|
||||
import {
|
||||
ABOUT_SLOT_KEYS,
|
||||
@@ -9,27 +9,32 @@ import {
|
||||
slotByKey,
|
||||
slotImages,
|
||||
} from "@/lib/meshkee";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export const metadata = pageMetadata({
|
||||
title: "About Studio",
|
||||
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.",
|
||||
alternates: { canonical: "/about-us" },
|
||||
};
|
||||
"Khiyal Architecture Studio reinterprets Iranian architecture through bold vision, rigorous analysis, and a deep focus on human experience.",
|
||||
path: "/about-us",
|
||||
image: "/images/about/subheader.jpg",
|
||||
imageAlt: "About Khiyal Architecture Studio",
|
||||
});
|
||||
|
||||
const galleryFallback = [
|
||||
"/images/about/1.jpg",
|
||||
"/images/about/2.jpg",
|
||||
"/images/about/3.jpg",
|
||||
{ src: "/images/about/1.jpg", alt: "Khiyal Architecture Studio workspace and materials" },
|
||||
{ src: "/images/about/2.jpg", alt: "Architecture model from Khiyal Studio design process" },
|
||||
{ src: "/images/about/3.jpg", alt: "Completed architecture project by Khiyal Studio" },
|
||||
];
|
||||
|
||||
export default async function AboutUsPage() {
|
||||
const slots = await getStaticImageSlots("about-us");
|
||||
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 photos = gallery.length >= 3 ? gallery.map((image) => image.url) : galleryFallback;
|
||||
const gallery = slotImages(slotByKey(slots, ABOUT_SLOT_KEYS.gallery));
|
||||
const photos = galleryFallback.map((fallback, index) => ({
|
||||
src: gallery[index]?.url || fallback.src,
|
||||
alt: imageAlt(gallery[index], fallback.alt),
|
||||
}));
|
||||
|
||||
return (
|
||||
<main>
|
||||
@@ -37,22 +42,22 @@ export default async function AboutUsPage() {
|
||||
className="page-hero dotted"
|
||||
style={{ backgroundImage: `url(${imageUrl(hero, "/images/about/subheader.jpg")})` }}
|
||||
>
|
||||
<div>
|
||||
<Reveal>
|
||||
<h1>
|
||||
<span style={{ color: "var(--base-color)" }}>about</span> studio
|
||||
</h1>
|
||||
<h2>Let's see who we are</h2>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section className="section dotted">
|
||||
<div className="container-site intro-grid">
|
||||
<div className="about-visual">
|
||||
<img src={photos[0]} alt="Khiyal studio" />
|
||||
<img src={photos[1]} alt="Khiyal architecture" />
|
||||
<img src={photos[2] || photos[0]} alt="Khiyal project" style={{ gridColumn: "1 / -1" }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="container-site about-intro">
|
||||
<Reveal className="about-visual">
|
||||
<img className="about-pic-a" src={photos[0].src} alt={photos[0].alt} />
|
||||
<img className="about-pic-b" src={photos[1].src} alt={photos[1].alt} />
|
||||
<img className="about-pic-c" src={photos[2].src} alt={photos[2].alt} />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<span className="eyebrow">About Architecture</span>
|
||||
<h2 className="heading-white">Working group creating pure ideas</h2>
|
||||
<p>
|
||||
@@ -66,10 +71,10 @@ export default async function AboutUsPage() {
|
||||
<Link href="/#projects" className="link-line">
|
||||
Explore services →
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="container-site" style={{ marginTop: 64 }}>
|
||||
<Reveal className="container-site" style={{ marginTop: 64 }}>
|
||||
<h3 className="heading-white" style={{ color: "var(--base-color)" }}>
|
||||
Areas of Expertise – Khiyal Architecture Studio
|
||||
</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
|
||||
experience of living and to reinterpret the principles of refined Iranian architecture in a contemporary way.
|
||||
</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
|
||||
</h3>
|
||||
<p>
|
||||
@@ -129,9 +136,9 @@ export default async function AboutUsPage() {
|
||||
architecture that offers a different perspective, balances imagination with analysis, and creates a
|
||||
project unlike any other this is Khiyal Architecture Office.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="container-site owner" style={{ marginTop: 56, maxWidth: 980 }}>
|
||||
<Reveal className="container-site owner" style={{ marginTop: 56, maxWidth: 980 }}>
|
||||
<img
|
||||
src={imageUrl(owner, "/images/about/owner.jpg")}
|
||||
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
|
||||
that are enduring, identity-driven, and deeply human
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="container-site feature-grid" style={{ marginTop: 56 }}>
|
||||
<Reveal className="container-site feature-grid" style={{ marginTop: 56 }}>
|
||||
{features.map((item) => (
|
||||
<div key={item.number} className="feature-item">
|
||||
<span>{item.text}</span>
|
||||
<span>{item.number}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
</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 { Reveal } from "@/components/Reveal";
|
||||
import { socialLinks } from "@/data/content";
|
||||
import { CONTACT_SLOT_KEYS, getStaticImageSlots, imageUrl, slotByKey, slotImages } from "@/lib/meshkee";
|
||||
import { pageMetadata } from "@/lib/seo";
|
||||
import { CONTACT } from "@/lib/site";
|
||||
import { SocialIcon } from "@/components/SocialIcon";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
export const metadata = pageMetadata({
|
||||
title: "Contact Us",
|
||||
description:
|
||||
"Let's work together. Contact Khiyal Architecture Studio at 0912 252 50 08 or Mostafa.eng@live.com.",
|
||||
alternates: { canonical: "/contact-us" },
|
||||
};
|
||||
"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.",
|
||||
path: "/contact-us",
|
||||
image: "/images/about/contact-subheader.jpg",
|
||||
imageAlt: "Contact Khiyal Architecture Studio",
|
||||
});
|
||||
|
||||
export default async function ContactUsPage() {
|
||||
const slots = await getStaticImageSlots("contact-us");
|
||||
@@ -22,16 +25,16 @@ export default async function ContactUsPage() {
|
||||
className="page-hero dotted"
|
||||
style={{ backgroundImage: `url(${imageUrl(hero, "/images/about/contact-subheader.jpg")})` }}
|
||||
>
|
||||
<div>
|
||||
<Reveal>
|
||||
<h1>
|
||||
<span style={{ color: "var(--base-color)" }}>contact</span> us
|
||||
</h1>
|
||||
<h2>Let's working together</h2>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section className="section dotted">
|
||||
<div className="container-site contact-top">
|
||||
<Reveal className="container-site contact-top">
|
||||
<div>
|
||||
<span className="eyebrow">Get to know us</span>
|
||||
<h2 className="heading-white" style={{ fontSize: 34, margin: 0 }}>
|
||||
@@ -65,12 +68,12 @@ export default async function ContactUsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section className="section dotted" style={{ paddingTop: 0 }}>
|
||||
<div className="container-site">
|
||||
<div className="form-wrap">
|
||||
<Reveal className="form-wrap">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 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?
|
||||
@@ -78,7 +81,7 @@ export default async function ContactUsPage() {
|
||||
<img src="/images/contact-icon.jpg" alt="" style={{ height: 50, width: "auto" }} />
|
||||
</div>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="watermark" style={{ position: "relative", bottom: -30 }}>
|
||||
architecture
|
||||
|
||||
+473
-27
@@ -237,23 +237,28 @@ textarea {
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
color: #fff;
|
||||
padding: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.hamburger span {
|
||||
.hamburger:hover,
|
||||
.user-menu-icon:hover,
|
||||
.user-menu-trigger:hover {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.hamburger svg,
|
||||
.user-menu-icon svg {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
transition: 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-overlay {
|
||||
@@ -261,12 +266,17 @@ textarea {
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: #191919 url("/images/brand/vertical-line-bg.svg") center top / cover;
|
||||
display: none;
|
||||
overflow: auto;
|
||||
top: -100%;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: top 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-overlay.open {
|
||||
display: block;
|
||||
top: 0;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.menu-close {
|
||||
@@ -281,6 +291,15 @@ textarea {
|
||||
color: #191919;
|
||||
font-size: 22px;
|
||||
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 {
|
||||
@@ -292,6 +311,34 @@ textarea {
|
||||
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 {
|
||||
display: block;
|
||||
font-family: var(--alt-font);
|
||||
@@ -299,8 +346,26 @@ textarea {
|
||||
line-height: 1.05;
|
||||
color: #fff;
|
||||
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.active {
|
||||
color: var(--base-color);
|
||||
@@ -578,18 +643,29 @@ textarea {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
padding: 28px 28px 90px;
|
||||
background: linear-gradient(to top, #101010 18%, transparent 100%);
|
||||
transform: translateY(60px);
|
||||
padding: 30px 28px 32px;
|
||||
background: linear-gradient(to top, #101010 22%, transparent 100%);
|
||||
transform: translateY(calc(100% - 90px));
|
||||
transition: transform 550ms cubic-bezier(0.12, 0, 0.1, 0);
|
||||
}
|
||||
|
||||
.portfolio-card .copy h3 {
|
||||
margin: 0;
|
||||
margin: 0 0 10px;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.portfolio-card .copy p {
|
||||
margin: 0;
|
||||
color: #d4d4d4;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
text-align: justify;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 8;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portfolio-card:hover img {
|
||||
@@ -907,9 +983,36 @@ textarea {
|
||||
}
|
||||
|
||||
@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 {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -922,18 +1025,20 @@ textarea {
|
||||
.news-section {
|
||||
position: relative;
|
||||
padding-top: 0;
|
||||
padding-bottom: 120px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.news-watermark {
|
||||
margin-top: 48px;
|
||||
position: relative;
|
||||
margin: 32px 0 0;
|
||||
text-align: center;
|
||||
font-family: var(--alt-font);
|
||||
font-size: clamp(5.5rem, 14vw, 12.5rem);
|
||||
line-height: 1;
|
||||
font-size: clamp(3rem, 8vw, 7.5rem);
|
||||
line-height: 0.72;
|
||||
font-weight: 600;
|
||||
letter-spacing: -4px;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--nero-grey);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1000,11 +1105,103 @@ textarea {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.about-visual {
|
||||
.about-intro {
|
||||
display: grid;
|
||||
grid-template-columns: 0.7fr 1fr;
|
||||
gap: 18px;
|
||||
grid-template-columns: 7fr 5fr;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.about-visual {
|
||||
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 {
|
||||
@@ -1074,7 +1271,7 @@ textarea {
|
||||
|
||||
.site-footer {
|
||||
background: var(--nero-grey);
|
||||
padding: 96px 0 0;
|
||||
padding: 72px 0 0;
|
||||
}
|
||||
|
||||
.footer-cols {
|
||||
@@ -1284,6 +1481,7 @@ textarea {
|
||||
@media (max-width: 991px) {
|
||||
.menu-grid,
|
||||
.intro-grid,
|
||||
.about-intro,
|
||||
.years-wrap,
|
||||
.video-split,
|
||||
.feature-grid,
|
||||
@@ -1296,6 +1494,10 @@ textarea {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.about-intro {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.years-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
@@ -1350,6 +1552,7 @@ textarea {
|
||||
|
||||
.menu-grid,
|
||||
.intro-grid,
|
||||
.about-intro,
|
||||
.years-wrap,
|
||||
.video-split,
|
||||
.feature-grid,
|
||||
@@ -1432,6 +1635,29 @@ textarea {
|
||||
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 {
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
@@ -1455,3 +1681,223 @@ textarea {
|
||||
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 { Footer } from "@/components/Footer";
|
||||
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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: SITE_TITLE,
|
||||
template: "%s | Khiyal Architecture Studio",
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description: SITE_DESCRIPTION,
|
||||
icons: { icon: "/favicon.png" },
|
||||
robots: { index: true, follow: true },
|
||||
openGraph: {
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
url: "/",
|
||||
siteName: "Khiyal Architecture Studio",
|
||||
siteName: SITE_NAME,
|
||||
locale: "en_US",
|
||||
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 {
|
||||
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 === "/contact-us") return { path, changefreq: "monthly", priority: 0.5 };
|
||||
return { path, changefreq: "weekly", priority: 0.5 };
|
||||
@@ -82,6 +84,7 @@ export function GET() {
|
||||
{
|
||||
headers: {
|
||||
"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 { Counters } from "@/components/Counters";
|
||||
import { HeroSlider } from "@/components/HeroSlider";
|
||||
import { PortfolioCarousel } from "@/components/PortfolioCarousel";
|
||||
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 {
|
||||
features,
|
||||
@@ -22,12 +23,16 @@ import {
|
||||
slotImages,
|
||||
} from "@/lib/meshkee";
|
||||
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,
|
||||
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() {
|
||||
const slots = await getStaticImageSlots("home");
|
||||
@@ -72,7 +77,7 @@ export default async function HomePage() {
|
||||
|
||||
<section className="section dotted">
|
||||
<div className="container-site intro-grid">
|
||||
<div>
|
||||
<Reveal>
|
||||
<img
|
||||
src={imageUrl(signature, "/images/brand/signature.png")}
|
||||
alt={imageAlt(signature, "Khiyal signature")}
|
||||
@@ -81,8 +86,8 @@ export default async function HomePage() {
|
||||
<h2 className="heading-white" style={{ fontSize: "28px", marginTop: 20 }}>
|
||||
Khiyal | Between Thought and Construction Architecture, experienced before it is built
|
||||
</h2>
|
||||
</div>
|
||||
<div className="years-wrap">
|
||||
</Reveal>
|
||||
<Reveal className="years-wrap">
|
||||
<div className="years-badge">
|
||||
<div className="circle" />
|
||||
<div className="num">15</div>
|
||||
@@ -96,21 +101,21 @@ export default async function HomePage() {
|
||||
fulfill functional needs while creating profound and enduring human experiences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="container-site feature-grid">
|
||||
<Reveal className="container-site feature-grid">
|
||||
{features.map((item) => (
|
||||
<div key={item.number} className="feature-item">
|
||||
<span>{item.text}</span>
|
||||
<span>{item.number}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section id="services" className="section dotted">
|
||||
<div className="container-site">
|
||||
<div className="intro-grid" style={{ marginBottom: 48 }}>
|
||||
<Reveal className="intro-grid" style={{ marginBottom: 48 }}>
|
||||
<div>
|
||||
<span className="eyebrow">Architecture services</span>
|
||||
<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
|
||||
spaces that are distinctive, memorable, and beyond the ordinary
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
<PortfolioCarousel />
|
||||
</div>
|
||||
<div className="container-site">
|
||||
<Reveal className="container-site">
|
||||
<div className="partners">
|
||||
{(partnerImages.length
|
||||
? partnerImages.map((image, index) => ({
|
||||
@@ -144,7 +149,7 @@ export default async function HomePage() {
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section className="video-split">
|
||||
@@ -154,7 +159,7 @@ export default async function HomePage() {
|
||||
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>
|
||||
@@ -162,11 +167,11 @@ export default async function HomePage() {
|
||||
click for <span>watch video</span>
|
||||
</h2>
|
||||
<div className="watermark text-outline">architecture</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section id="projects" className="section dotted" style={{ background: "var(--nero-grey)" }}>
|
||||
<div className="container-site intro-grid">
|
||||
<Reveal className="container-site intro-grid">
|
||||
<div>
|
||||
<span className="eyebrow">Best Projects</span>
|
||||
<h2 className="heading-white" style={{ fontSize: 30 }}>
|
||||
@@ -188,15 +193,15 @@ export default async function HomePage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section className="section dotted" style={{ paddingTop: 0 }}>
|
||||
<div className="container-site">
|
||||
<Reveal className="container-site">
|
||||
<RecentProjects items={recent} />
|
||||
<Counters />
|
||||
<Testimonials />
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<section className="section dotted marquee-section">
|
||||
@@ -205,11 +210,11 @@ export default async function HomePage() {
|
||||
|
||||
<section id="news" className="section dotted news-section">
|
||||
<div className="container-site">
|
||||
<div style={{ textAlign: "center", marginBottom: 36 }}>
|
||||
<Reveal style={{ textAlign: "center", marginBottom: 36 }}>
|
||||
<span className="eyebrow">Architecture news</span>
|
||||
<h2 className="heading-white">Projects Under Construction Current Developments</h2>
|
||||
</div>
|
||||
<div className="news-grid">
|
||||
</Reveal>
|
||||
<Reveal className="news-grid">
|
||||
{news.map((item) => (
|
||||
<figure key={item.title} className="news-card">
|
||||
<img src={item.src} alt={item.title} />
|
||||
@@ -219,10 +224,10 @@ export default async function HomePage() {
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="news-watermark" aria-hidden>
|
||||
architecture
|
||||
steel dance studio
|
||||
</div>
|
||||
</section>
|
||||
</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>;
|
||||
}
|
||||
+26
-17
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { navItems, socialLinks } from "@/data/content";
|
||||
import { CONTACT } from "@/lib/site";
|
||||
import { UserMenu } from "./UserMenu";
|
||||
import { SocialIcon } from "./SocialIcon";
|
||||
|
||||
export function Header() {
|
||||
@@ -24,18 +25,22 @@ export function Header() {
|
||||
<Link href="/" className="logo" aria-label="Khiyal Architecture Studio">
|
||||
<img src="/images/brand/logo-white.png" alt="Khiyal Architecture Studio" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="hamburger"
|
||||
aria-label="Open menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
<div className="header-actions">
|
||||
<UserMenu />
|
||||
<button
|
||||
type="button"
|
||||
className="hamburger"
|
||||
aria-label="Open menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<svg width="22" height="14" viewBox="0 0 22 14" aria-hidden>
|
||||
<rect width="22" height="2" fill="currentColor" />
|
||||
<rect y="6" width="22" height="2" fill="currentColor" />
|
||||
<rect y="12" width="14" height="2" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={`menu-overlay ${open ? "open" : ""}`} role="dialog" aria-modal="true">
|
||||
@@ -44,21 +49,21 @@ export function Header() {
|
||||
</button>
|
||||
<div className="menu-grid">
|
||||
<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" }} />
|
||||
</Link>
|
||||
<div className="menu-meta">
|
||||
<div>
|
||||
<div className="menu-address">
|
||||
<span className="eyebrow">Appointments</span>
|
||||
<p>For better access to the office, please visit the contact page.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="menu-address">
|
||||
<span className="eyebrow">Let's talk</span>
|
||||
<a href={`mailto:${CONTACT.email}`} className="heading-white">
|
||||
{CONTACT.email}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div className="menu-social">
|
||||
<span className="eyebrow">Connect with us</span>
|
||||
<div className="social-row">
|
||||
{socialLinks.map((item) => (
|
||||
@@ -75,7 +80,11 @@ export function Header() {
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={pathname === item.href ? "active" : undefined}
|
||||
className={
|
||||
pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))
|
||||
? "active"
|
||||
: undefined
|
||||
}
|
||||
onClick={close}
|
||||
>
|
||||
{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} />
|
||||
<div className="copy">
|
||||
<h3>{item.titleEn}</h3>
|
||||
<p>{item.text}</p>
|
||||
</div>
|
||||
</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: "/about-us", label: "About" },
|
||||
{ href: "/#services", label: "Services" },
|
||||
{ href: "/#projects", label: "Projects" },
|
||||
{ href: "/#news", label: "Product design" },
|
||||
{ href: "/portfolios", label: "Projects" },
|
||||
{ href: "/blog", label: "Blog" },
|
||||
{ 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 { 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 = {
|
||||
id: string;
|
||||
@@ -183,3 +186,122 @@ export function aspectRatioStyle(ratio?: string | null): CSSProperties | undefin
|
||||
if (!width || !height) return undefined;
|
||||
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 =
|
||||
process.env.NEXT_PUBLIC_MESHKEE_API_URL?.replace(/\/$/, "") ??
|
||||
"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_TITLE = "Khiyal | Between Thought and Construction";
|
||||
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 = {
|
||||
email: "Mostafa.eng@live.com",
|
||||
|
||||
Reference in New Issue
Block a user