diff --git a/CONTEXT.md b/CONTEXT.md index ea89c8e..0d6d85c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/src/app/about-us/page.tsx b/src/app/about-us/page.tsx index 632796f..b5ea87d 100644 --- a/src/app/about-us/page.tsx +++ b/src/app/about-us/page.tsx @@ -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 (
@@ -37,22 +42,22 @@ export default async function AboutUsPage() { className="page-hero dotted" style={{ backgroundImage: `url(${imageUrl(hero, "/images/about/subheader.jpg")})` }} > -
+

about studio

Let's see who we are

-
+
-
-
- Khiyal studio - Khiyal architecture - Khiyal project -
-
+
+ + {photos[0].alt} + {photos[1].alt} + {photos[2].alt} + + About Architecture

Working group creating pure ideas

@@ -66,10 +71,10 @@ export default async function AboutUsPage() { Explore services → -

+
-
+

Areas of Expertise – Khiyal Architecture Studio

@@ -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.

+
-

+ +

About Us

@@ -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.

-

+ -
+ {imageAlt(owner, -
+ -
+ {features.map((item) => (
{item.text} {item.number}
))} -
+
); diff --git a/src/app/blog/[id]/[slug]/page.tsx b/src/app/blog/[id]/[slug]/page.tsx new file mode 100644 index 0000000..73a886c --- /dev/null +++ b/src/app/blog/[id]/[slug]/page.tsx @@ -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 { + 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 ( +
+ +
+ + {item.titleImageUrl ? ( +
+ {title} +
+ ) : null} + {html ?
:

{excerptOf(item)}

} + + Back to blog → + + +
+
+ ); +} diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx new file mode 100644 index 0000000..40190c0 --- /dev/null +++ b/src/app/blog/page.tsx @@ -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 ( +
+ +
+ + {items.length === 0 ? ( + No articles published yet. + ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/contact-us/page.tsx b/src/app/contact-us/page.tsx index b054095..5fcfa1d 100644 --- a/src/app/contact-us/page.tsx +++ b/src/app/contact-us/page.tsx @@ -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")})` }} > -
+

contact us

Let's working together

-
+
-
+
Get to know us

@@ -65,12 +68,12 @@ export default async function ContactUsPage() { ))}

- +
-
+

Let's talk. Have a project in mind? @@ -78,7 +81,7 @@ export default async function ContactUsPage() {

-
+
architecture diff --git a/src/app/globals.css b/src/app/globals.css index 2e049a7..6c639db 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9e4685d..9112b72 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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" }], }, }; diff --git a/src/app/meshkee/sitemap-config.json/route.ts b/src/app/meshkee/sitemap-config.json/route.ts index f8143a9..fa67961 100644 --- a/src/app/meshkee/sitemap-config.json/route.ts +++ b/src/app/meshkee/sitemap-config.json/route.ts @@ -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": "*", }, }, ); diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..f71275b --- /dev/null +++ b/src/app/not-found.tsx @@ -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 ( +
+ +
+
+

This page is unavailable.

+ + Back to home → + +
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 84152d5..5730b80 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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() {
-
+ {imageAlt(signature, Khiyal | Between Thought and Construction Architecture, experienced before it is built -
-
+ +
15
@@ -96,21 +101,21 @@ export default async function HomePage() { fulfill functional needs while creating profound and enduring human experiences

-
+
-
+ {features.map((item) => (
{item.text} {item.number}
))} -
+
-
+
Architecture services

@@ -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

-

+
-
+
{(partnerImages.length ? partnerImages.map((image, index) => ({ @@ -144,7 +149,7 @@ export default async function HomePage() { ))}
-
+
@@ -154,7 +159,7 @@ export default async function HomePage() { backgroundImage: `url(${imageUrl(videoCover, "/images/video-cover.jpg")})`, }} /> -
+ ▶ @@ -162,11 +167,11 @@ export default async function HomePage() { click for watch video
architecture
-
+
-
+
Best Projects

@@ -188,15 +193,15 @@ export default async function HomePage() { ))}

-
+
-
+ -
+
@@ -205,11 +210,11 @@ export default async function HomePage() {
-
+ Architecture news

Projects Under Construction Current Developments

-
-
+ + {news.map((item) => (
{item.title} @@ -219,10 +224,10 @@ export default async function HomePage() {
))} -
+
- architecture + steel dance studio
diff --git a/src/app/portfolios/[id]/[slug]/page.tsx b/src/app/portfolios/[id]/[slug]/page.tsx new file mode 100644 index 0000000..18ef4de --- /dev/null +++ b/src/app/portfolios/[id]/[slug]/page.tsx @@ -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 { + 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 ( +
+ +
+
+ + + + + {item.mainTextHtml ? ( +
+ ) : ( +

{excerptOf(item)}

+ )} + {item.projectUrl ? ( +

+ + Project website → + +

+ ) : null} + + Back to projects → + + +
+
+ {similar.length > 0 ? ( +
+ + Similar projects +

Related work

+
+ {similar.map((entry) => ( + + ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/src/app/portfolios/page.tsx b/src/app/portfolios/page.tsx new file mode 100644 index 0000000..705623c --- /dev/null +++ b/src/app/portfolios/page.tsx @@ -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 ( +
+ +
+ + {items.length === 0 ? ( + No projects published yet. + ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/CatalogCard.tsx b/src/components/CatalogCard.tsx new file mode 100644 index 0000000..4b3e7de --- /dev/null +++ b/src/components/CatalogCard.tsx @@ -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 ( + + + {image ? {title} : } + + + {meta ? {meta} : null} + {title} + {excerpt ? {excerpt} : null} + + + ); +} + +export function EmptyState({ children }: { children: string }) { + return

{children}

; +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 55e9d3c..f26999b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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() { Khiyal Architecture Studio - +
+ + +
@@ -44,21 +49,21 @@ export function Header() {
- + Khiyal
-
+
Appointments

For better access to the office, please visit the contact page.

-
+ -
+
Connect with us
{socialLinks.map((item) => ( @@ -75,7 +80,11 @@ export function Header() { {item.label} diff --git a/src/components/LightboxGallery.tsx b/src/components/LightboxGallery.tsx new file mode 100644 index 0000000..6db975f --- /dev/null +++ b/src/components/LightboxGallery.tsx @@ -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 ( + <> +
+ {images.map((image, imageIndex) => ( + + ))} +
+ {open ? ( +
setOpen(false)}> + + {images.length > 1 ? ( + <> + + + + ) : null} + {title} event.stopPropagation()} /> +
+ ) : null} + + ); +} diff --git a/src/components/PageHero.tsx b/src/components/PageHero.tsx new file mode 100644 index 0000000..2c55ce5 --- /dev/null +++ b/src/components/PageHero.tsx @@ -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 ( +
+ +

+ {accent} {title} +

+

{subtitle}

+
+
+ ); +} diff --git a/src/components/PortfolioCarousel.tsx b/src/components/PortfolioCarousel.tsx index 228bd84..1ec8456 100644 --- a/src/components/PortfolioCarousel.tsx +++ b/src/components/PortfolioCarousel.tsx @@ -45,6 +45,7 @@ export function PortfolioCarousel() { {item.titleEn}

{item.titleEn}

+

{item.text}

))} diff --git a/src/components/Reveal.tsx b/src/components/Reveal.tsx new file mode 100644 index 0000000..624102f --- /dev/null +++ b/src/components/Reveal.tsx @@ -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(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 ( +
+ {children} +
+ ); +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..3a10bde --- /dev/null +++ b/src/components/UserMenu.tsx @@ -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
; + } + + if (!isLoggedIn) { + return ( + + + + ); + } + + const name = [user?.firstName, user?.lastName].filter(Boolean).join(" ") || "کاربر"; + + return ( +
+ + {open ? ( +
+ پروفایل من + {isAdminUser(user) ? مدیریت وبسایت : null} + +
+ ) : null} +
+ ); +} + +function UserGlyph() { + return ( + + + + + ); +} diff --git a/src/data/content.ts b/src/data/content.ts index 31b498c..174aa37 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -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" }, ]; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..ad5bc0e --- /dev/null +++ b/src/lib/auth.ts @@ -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(null); + const [token, setToken] = useState(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 }; +} diff --git a/src/lib/meshkee-types.ts b/src/lib/meshkee-types.ts new file mode 100644 index 0000000..b66ac24 --- /dev/null +++ b/src/lib/meshkee-types.ts @@ -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; +}; diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 46931e7..186321a 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -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 = { + 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; + push((record.url || record.imageUrl || record.src) as string | undefined); + } + } + } + return urls; +} + +function queryString(params: Record) { + 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(data: Paged | null, pageSize: number): Paged { + 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> { + return meshkeeFetch>(`/portfolios${queryString(params)}`).then((data) => + paged(data, params.pageSize ?? 12), + ); +} + +export async function getPortfolioById(id: string): Promise { + 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> { + return meshkeeFetch>(`/blogs${queryString(params)}`).then((data) => paged(data, params.pageSize ?? 12)); +} + +export async function getBlogById(id: string): Promise { + 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)}`; +} diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..8cd9e97 --- /dev/null +++ b/src/lib/seo.ts @@ -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, + }, + }; +} diff --git a/src/lib/site.ts b/src/lib/site.ts index e76e05c..f8477f9 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -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",