diff --git a/public/images/services/card-architecture.jpg b/public/images/services/card-architecture.jpg new file mode 100644 index 0000000..afe1f29 Binary files /dev/null and b/public/images/services/card-architecture.jpg differ diff --git a/public/images/services/card-exterior.jpg b/public/images/services/card-exterior.jpg new file mode 100644 index 0000000..8203daa Binary files /dev/null and b/public/images/services/card-exterior.jpg differ diff --git a/public/images/services/card-interior.jpg b/public/images/services/card-interior.jpg new file mode 100644 index 0000000..f453946 Binary files /dev/null and b/public/images/services/card-interior.jpg differ diff --git a/public/images/services/card-residential.jpg b/public/images/services/card-residential.jpg new file mode 100644 index 0000000..465e498 Binary files /dev/null and b/public/images/services/card-residential.jpg differ diff --git a/public/images/services/feature.jpg b/public/images/services/feature.jpg new file mode 100644 index 0000000..f1cbe41 Binary files /dev/null and b/public/images/services/feature.jpg differ diff --git a/public/images/services/gallery-1.jpg b/public/images/services/gallery-1.jpg new file mode 100644 index 0000000..b8b9c11 Binary files /dev/null and b/public/images/services/gallery-1.jpg differ diff --git a/public/images/services/gallery-2.jpg b/public/images/services/gallery-2.jpg new file mode 100644 index 0000000..17bd37e Binary files /dev/null and b/public/images/services/gallery-2.jpg differ diff --git a/public/images/services/subheader.jpg b/public/images/services/subheader.jpg new file mode 100644 index 0000000..7e012d7 Binary files /dev/null and b/public/images/services/subheader.jpg differ diff --git a/src/app/globals.css b/src/app/globals.css index 295b144..8a8c720 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -605,20 +605,30 @@ textarea { --gap: 35px; --shown: 4; --slide: 0; + /* live pointer-drag offset, written straight to the node while dragging */ + --drag: 0px; container-type: inline-size; width: calc(100% + 30vw); overflow: hidden; + cursor: grab; + touch-action: pan-y; + -webkit-user-select: none; + user-select: none; } -.portfolio-bleed.is-instant .portfolio-track { - transition: none; +.portfolio-bleed.is-dragging { + cursor: grabbing; } +/* the transition below is switched off imperatively while the track follows the cursor and + while the loop re-anchors — see freeze()/thaw() in PortfolioCarousel */ .portfolio-track { display: flex; gap: var(--gap); transition: transform 0.7s cubic-bezier(0.12, 0, 0.1, 0); - transform: translateX(calc(-1 * var(--slide) * (100cqi + var(--gap)) / var(--shown))); + transform: translateX( + calc(-1 * var(--slide) * (100cqi + var(--gap)) / var(--shown) + var(--drag)) + ); } .portfolio-card { @@ -634,6 +644,7 @@ textarea { height: 430px; object-fit: cover; display: block; + -webkit-user-drag: none; transition: transform 1s cubic-bezier(0.12, 0, 0.39, 0); } @@ -1148,6 +1159,217 @@ textarea { box-shadow: 0 25px 70px rgba(0, 0, 0, 0.45); } +/* services page */ + +.service-intro { + display: grid; + grid-template-columns: 4fr 8fr; + gap: 30px; + align-items: start; +} + +/* mirrors the blank line the legacy page had between the lead and the body copy */ +.service-intro p + p { + margin-top: 1.875rem; +} + +.service-areas { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; +} + +.service-area { + position: relative; + padding-top: 56px; +} + +.service-area-number { + position: absolute; + top: -14px; + left: -4px; + font-family: var(--alt-font); + font-size: clamp(66px, 6vw, 96px); + font-weight: 800; + line-height: 1; + -webkit-text-stroke-color: rgba(255, 255, 255, 0.22); + pointer-events: none; +} + +.service-area h3 { + position: relative; + margin: 0 0 6px; + color: var(--white); + font-size: 19px; + font-weight: 500; + line-height: 1.5; +} + +.service-area p { + margin: 0; +} + +.service-common { + grid-column: 1 / -1; + margin-top: 20px; +} + +.service-common p { + margin: 6px 0 0; +} + +/* the feature image straddles the boundary between the two sections */ +.service-lead { + padding-bottom: 415px; +} + +.service-feature-section { + padding-top: 0; + padding-bottom: 0; +} + +.service-feature { + position: relative; + margin-top: -325px; +} + +.service-feature-image { + height: 650px; + border-radius: 6px; + background-size: cover; + background-position: center; + box-shadow: inset 0 0 0 100vmax rgba(25, 25, 25, 0.2); +} + +/* centred with margin auto rather than a transform, which `.reveal-self > *` resets */ +.service-play { + position: absolute; + inset: 0; + margin: auto; + width: 170px; + height: 170px; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--base-color); + color: var(--dark-gray); + font-size: 16px; + font-weight: 800; + letter-spacing: 1px; + text-transform: uppercase; +} + +.service-play:hover { + background: var(--white); +} + +.service-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); +} + +.service-stat { + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + padding: 0 20px; + border-right: 1px solid rgba(255, 255, 255, 0.1); +} + +.service-stat:last-child { + border-right: 0; +} + +.service-stat strong { + flex-shrink: 0; + font-family: var(--alt-font); + font-size: clamp(40px, 5vw, 58px); + font-weight: 600; + line-height: 1.1; + color: var(--white); +} + +.service-stat strong sup { + font-size: 0.52em; + top: -0.5em; +} + +.service-stat span { + display: block; + font-size: 18px; + line-height: 26px; + font-weight: 500; +} + +.service-stars { + color: var(--base-color); + letter-spacing: 2px; + line-height: 1.4; +} + +.service-outro-grid { + display: grid; + grid-template-columns: 8fr 4fr; + gap: 30px; + align-items: start; + padding-bottom: 90px; +} + +.service-capabilities { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-top: 40px; +} + +.service-capabilities ul { + margin: 0; + padding: 0; + list-style: none; +} + +.service-capabilities li { + padding-bottom: 5px; +} + +.service-capabilities li::before { + content: "+"; + margin-right: 10px; + font-size: 12px; + color: var(--base-color); +} + +/* nothing clips this one, so keep it clear of the footer */ +.service-outro .watermark { + bottom: 0; +} + +.service-visual { + position: relative; + padding-bottom: 60px; +} + +.service-pic-a { + display: block; + width: 80%; + margin-left: auto; + margin-top: -20px; + box-shadow: 0 25px 70px rgba(0, 0, 0, 0.45); +} + +.service-pic-b { + position: absolute; + left: 15px; + bottom: 10px; + width: 50%; + z-index: 2; + box-shadow: 0 25px 70px rgba(0, 0, 0, 0.45); +} + +.service-visual.is-in .service-pic-a { transition-delay: 0.2s; } +.service-visual.is-in .service-pic-b { transition-delay: 0.45s; } + .reveal > * { opacity: 0; transform: translateY(30px); @@ -1536,6 +1758,28 @@ textarea { --shown: 3; } + .service-intro, + .service-outro-grid { + grid-template-columns: 1fr; + } + + .service-feature-image { + height: 450px; + } + + .service-feature { + margin-top: -225px; + } + + .service-lead { + padding-bottom: 315px; + } + + .service-play { + width: 130px; + height: 130px; + } + .marquee { letter-spacing: -5px; } @@ -1672,6 +1916,69 @@ textarea { min-height: 320px; padding-top: 130px; } + + .service-areas, + .service-stats, + .service-capabilities { + grid-template-columns: 1fr; + } + + .service-areas { + gap: 24px; + } + + .service-stat { + border-right: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + flex-direction: column; + gap: 4px; + text-align: center; + padding: 0 0 28px; + margin-bottom: 28px; + } + + .service-stat:last-child { + border-bottom: 0; + margin-bottom: 0; + padding-bottom: 0; + } + + .service-feature-image { + height: 350px; + } + + .service-feature { + margin-top: -175px; + } + + .service-lead { + padding-bottom: 265px; + } + + .service-play { + width: 110px; + height: 110px; + font-size: 14px; + } + + .service-capabilities { + gap: 0; + } + + .service-visual { + display: grid; + gap: 16px; + padding-bottom: 0; + } + + .service-pic-a, + .service-pic-b { + position: relative; + width: 100%; + left: auto; + bottom: auto; + margin: 0; + } } @media only screen and (max-width: 575px) { diff --git a/src/app/meshkee/sitemap-config.json/route.ts b/src/app/meshkee/sitemap-config.json/route.ts index fa67961..a2fa20d 100644 --- a/src/app/meshkee/sitemap-config.json/route.ts +++ b/src/app/meshkee/sitemap-config.json/route.ts @@ -58,6 +58,7 @@ 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 === "/services") return { path, changefreq: "monthly", 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 }; diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx new file mode 100644 index 0000000..d27a9a9 --- /dev/null +++ b/src/app/services/page.tsx @@ -0,0 +1,188 @@ +import { PortfolioCarousel } from "@/components/PortfolioCarousel"; +import { Reveal } from "@/components/Reveal"; +import { + serviceAreas, + serviceCapabilities, + serviceCards, + serviceCommonFeatures, + serviceStats, +} from "@/data/content"; +import { + SERVICES_SLOT_KEYS, + getStaticImageSlots, + imageAlt, + imageUrl, + slotByKey, + slotImages, +} from "@/lib/meshkee"; +import { pageMetadata } from "@/lib/seo"; +import { CONTACT } from "@/lib/site"; + +export const metadata = pageMetadata({ + title: "Our Services", + description: + "Khiyal Architecture Studio offers a wide spectrum of architectural and design services — luxury villas, minimal interiors, creative workspaces, cultural projects, and conceptual design.", + path: "/services", + image: "/images/services/subheader.jpg", + imageAlt: "Architecture services by Khiyal Studio", +}); + +const galleryFallback = [ + { src: "/images/services/gallery-1.jpg", alt: "Interior designed by Khiyal Architecture Studio" }, + { src: "/images/services/gallery-2.jpg", alt: "Stone and timber facade by Khiyal Architecture Studio" }, +]; + +export default async function ServicesPage() { + const slots = await getStaticImageSlots("services"); + const hero = slotImages(slotByKey(slots, SERVICES_SLOT_KEYS.hero))[0]; + const feature = slotImages(slotByKey(slots, SERVICES_SLOT_KEYS.feature))[0]; + const cardImages = slotImages(slotByKey(slots, SERVICES_SLOT_KEYS.cards)); + const gallery = slotImages(slotByKey(slots, SERVICES_SLOT_KEYS.gallery)); + + const cards = serviceCards.map((card, index) => ({ + image: cardImages[index]?.url || card.image, + title: cardImages[index]?.titleEn || cardImages[index]?.title || card.title, + text: cardImages[index]?.subtext || card.text, + })); + + const photos = galleryFallback.map((fallback, index) => ({ + src: gallery[index]?.url || fallback.src, + alt: imageAlt(gallery[index], fallback.alt), + })); + + return ( +
+
+ +

+ our services +

+

Great architecture services

+
+
+ +
+
+ + Architecture process +

+ Field of activity +

+

Areas of Expertise – Khiyal Architecture Studio

+

+ Khiyal Architecture Studio offers a wide spectrum of architectural and design services, distinguished by + 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. +

+
+ + {serviceAreas.map((area) => ( +
+ + {area.number} + +

{area.title}

+

{area.text}

+
+ ))} +
+ + Common Features Across All Our Work: + +

{serviceCommonFeatures.join(" · ")}

+
+
+
+
+ +
+
+ +
+ + Play + + + + +
+ Architecture services +

+ Modern buildings create modern and up-to-date functionality for you +

+
+

+ Our buildings combine minimalism and elegance of lines and shapes. We want them to be an integral part of + the surrounding landscape. +

+
+ + +
+
+ +
+ + {serviceStats.map((stat) => ( +
+ + {stat.value} + {stat.suffix ? {stat.suffix} : null} + +
+ {stat.stars ? ( + + {"★".repeat(stat.stars)} + + ) : null} + {stat.label} +
+
+ ))} +
+
+ +
+
+ +

+ Our small size enables us to service our clients closely and gives us the flexibility to adapt to changing + scenarios in the design world. +

+
+ {serviceCapabilities.map((column) => ( +
    + {column.map((item) => ( +
  • {item}
  • + ))} +
+ ))} +
+
+ + {photos[0].alt} + {photos[1].alt} + +
+
+ architecture +
+
+
+ ); +} diff --git a/src/components/PortfolioCarousel.tsx b/src/components/PortfolioCarousel.tsx index 1ec8456..a7a7ef3 100644 --- a/src/components/PortfolioCarousel.tsx +++ b/src/components/PortfolioCarousel.tsx @@ -1,54 +1,166 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { flushSync } from "react-dom"; import { portfolioItems } from "@/data/content"; -export function PortfolioCarousel() { - const [index, setIndex] = useState(0); +export type CarouselItem = { + image: string; + title: string; + text: string; +}; + +const defaultItems: CarouselItem[] = portfolioItems.map((item) => ({ + image: item.image, + title: item.titleEn, + text: item.text, +})); + +/** widest `--shown` the stylesheet uses — we render enough copies to cover it on both sides */ +const MAX_SHOWN = 4; +/** how many slides one drag gesture may travel */ +const MAX_DRAG_SLIDES = 2; +/** must stay just above the `.portfolio-track` transform transition */ +const SLIDE_MS = 750; + +export function PortfolioCarousel({ items }: { items?: CarouselItem[] } = {}) { + const slides = items && items.length > 0 ? items : defaultItems; + const count = slides.length; + const reps = Math.max(3, Math.ceil((MAX_SHOWN + 1) / count) + 2); + const maxShift = Math.min(MAX_DRAG_SLIDES, count); + + // the visible window sits in the second copy so it can travel a full gesture either way + // before being re-anchored back to a visually identical position in the middle + const [index, setIndex] = useState(count); const [paused, setPaused] = useState(false); - const [instant, setInstant] = useState(false); - const count = portfolioItems.length; + const [dragging, setDragging] = useState(false); + + const bleedRef = useRef(null); + const trackRef = useRef(null); + const originX = useRef(0); + const offset = useRef(0); useEffect(() => { - if (paused) return; + if (paused || dragging) return; const timer = window.setInterval(() => { setIndex((current) => current + 1); }, 4000); return () => window.clearInterval(timer); - }, [paused]); + }, [paused, dragging]); + // Chrome decides whether to animate from the *before-change* style, so toggling easing off + // and changing the transform in one commit still animates. Reading a layout property in + // between forces the browser to record the intermediate state, which is what actually + // suppresses (freeze) or arms (thaw) the transition. + const freeze = useCallback(() => { + const track = trackRef.current; + if (!track) return; + track.style.transition = "none"; + void track.offsetWidth; + }, []); + + const thaw = useCallback(() => { + const track = trackRef.current; + if (!track) return; + void track.offsetWidth; + track.style.transition = ""; + }, []); + + // Once a slide has settled, hop back into the middle copy. The rendered position is + // identical, so with easing suppressed the hop is invisible — it just keeps the window + // away from the ends of the list. useEffect(() => { - if (!instant) return; - const frame = requestAnimationFrame(() => { - requestAnimationFrame(() => setInstant(false)); - }); - return () => cancelAnimationFrame(frame); - }, [instant]); + if (dragging) return; + if (index >= count && index < count * 2) return; + const timer = window.setTimeout(() => { + freeze(); + flushSync(() => setIndex(count + ((((index - count) % count) + count) % count))); + thaw(); + }, SLIDE_MS); + return () => window.clearTimeout(timer); + }, [index, count, dragging, freeze, thaw]); + + /** width of one slide plus the gap, i.e. how far the track moves per step */ + const slideStep = useCallback(() => { + const track = trackRef.current; + const first = track?.firstElementChild as HTMLElement | null; + if (!track || !first) return 0; + const gap = Number.parseFloat(getComputedStyle(track).columnGap) || 0; + return first.getBoundingClientRect().width + gap; + }, []); + + const setDragOffset = useCallback((value: number) => { + offset.current = value; + bleedRef.current?.style.setProperty("--drag", `${value}px`); + }, []); + + const onPointerDown = (event: ReactPointerEvent) => { + if (event.pointerType === "mouse" && event.button !== 0) return; + originX.current = event.clientX; + setDragOffset(0); + setDragging(true); + // follow the cursor exactly rather than easing towards it + freeze(); + }; + + // Tracked on the window rather than the element: the gesture then still finishes when the + // pointer is released outside the carousel, or when the tab loses focus mid-drag, instead + // of leaving the track parked at its dragged offset. + useEffect(() => { + if (!dragging) return; + + const onMove = (event: PointerEvent) => { + const limit = slideStep() * MAX_DRAG_SLIDES; + const travelled = event.clientX - originX.current; + setDragOffset(limit > 0 ? Math.max(-limit, Math.min(limit, travelled)) : 0); + }; + + const onEnd = () => { + const step = slideStep(); + const shift = + step > 0 ? Math.max(-maxShift, Math.min(maxShift, Math.round(-offset.current / step))) : 0; + + // arm the transition at the dragged position, so clearing the offset animates the + // remaining distance to the snapped slide instead of jumping to it + thaw(); + setDragOffset(0); + setDragging(false); + if (shift !== 0) setIndex((current) => current + shift); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onEnd); + window.addEventListener("pointercancel", onEnd); + window.addEventListener("blur", onEnd); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onEnd); + window.removeEventListener("pointercancel", onEnd); + window.removeEventListener("blur", onEnd); + }; + }, [dragging, maxShift, setDragOffset, slideStep, thaw]); return (
setPaused(true)} onMouseLeave={() => setPaused(false)} + onPointerDown={onPointerDown} > -
{ - if (index < count) return; - setInstant(true); - setIndex(0); - }} - > - {[...portfolioItems, ...portfolioItems].map((item, i) => ( -
- {item.titleEn} -
-

{item.titleEn}

-

{item.text}

-
-
- ))} +
+ {Array.from({ length: reps }, () => slides) + .flat() + .map((item, i) => ( +
+ {item.title} +
+

{item.title}

+

{item.text}

+
+
+ ))}
); diff --git a/src/data/content.ts b/src/data/content.ts index 174aa37..0772f12 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -3,7 +3,7 @@ import { CONTACT } from "@/lib/site"; export const navItems = [ { href: "/", label: "Home" }, { href: "/about-us", label: "About" }, - { href: "/#services", label: "Services" }, + { href: "/services", label: "Services" }, { href: "/portfolios", label: "Projects" }, { href: "/blog", label: "Blog" }, { href: "/contact-us", label: "Contact" }, @@ -57,6 +57,76 @@ export const features = [ }, ]; +export const serviceAreas = [ + { + number: "01", + title: "Luxury Villas & Residential Projects", + text: "Designing homes that are not just places to live but authentic, serene, and personal experiences. Form, light, and material are carefully curated to make each residence a unique world.", + }, + { + number: "02", + title: "Minimal & Architectural Interior Design", + text: "Crafting interiors that tell a story through form, material, and light, enhancing the quality of life. Every detail is thoughtfully designed with regard to function, emotion, and the client’s identity.", + }, + { + number: "03", + title: "Office & Creative Workspaces", + text: "Spaces that are inspiring, professional, and creative, focusing on human interaction and productivity. Each office or workspace reflects the brand story and organizational identity.", + }, + { + number: "04", + title: "Recreational & Cultural Projects", + text: "Including experience-driven spaces such as galleries, cafés, auditoriums, and public areas that deliver outcomes beyond expectations through design and spatial essence.", + }, + { + number: "05", + title: "Conceptual Projects & Product Design", + text: "Generating bold, innovative ideas at every scale—from custom handles and furniture to spatial elements that strengthen the identity and feeling of the project.", + }, +]; + +export const serviceCommonFeatures = [ + "Human-centered design", + "Bold use of form and materials", + "Reinterpreting Iranian heritage in contemporary architecture", + "Quality, detail, and design beyond expectation", +]; + +export const serviceCards = [ + { + image: "/images/services/card-architecture.jpg", + title: "Architecture", + text: "Volumes shaped by site, light, and climate, resolved down to the last detail.", + }, + { + image: "/images/services/card-residential.jpg", + title: "Residential space", + text: "Homes designed around the way you actually live, calm, private, and enduring.", + }, + { + image: "/images/services/card-interior.jpg", + title: "Interior design", + text: "Minimal interiors where material, texture, and light carry the whole story.", + }, + { + image: "/images/services/card-exterior.jpg", + title: "Exterior planning", + text: "Facades and landscapes that tie the building back into its surroundings.", + }, +]; + +export const serviceStats = [ + { value: "99", suffix: "%", label: "Track and analyze business reports." }, + { value: "4.98", stars: 5, label: "Best rated agency" }, + { value: "98", suffix: "%", label: "Genuine repeated happy customers." }, +]; + +export const serviceCapabilities = [ + ["Structural design", "Landscape design", "Architectural", "Space planning"], + ["Building design", "Facility optimization", "Innovative design", "Residential"], + ["Interior design", "Space utilization", "Contractor bidding", "Technological design"], +]; + export const portfolioItems = [ { title: "نوجان", diff --git a/src/lib/meshkee.ts b/src/lib/meshkee.ts index 186321a..f4d869e 100644 --- a/src/lib/meshkee.ts +++ b/src/lib/meshkee.ts @@ -44,6 +44,13 @@ export const ABOUT_SLOT_KEYS = { hero: "about-hero", } as const; +export const SERVICES_SLOT_KEYS = { + hero: "services-hero", + feature: "services-feature", + cards: "services-cards", + gallery: "services-gallery", +} as const; + export const CONTACT_SLOT_KEYS = { hero: "contact-hero", } as const; @@ -130,6 +137,42 @@ export const STATIC_IMAGE_SLOT_CATALOG = [ itemCount: 1, recommendedWidth: 1400, }, + { + key: SERVICES_SLOT_KEYS.hero, + label: "Services page header", + kind: "single" as const, + pageKey: "services", + aspectRatio: "16:6", + itemCount: 1, + recommendedWidth: 1920, + }, + { + key: SERVICES_SLOT_KEYS.feature, + label: "Services video cover", + kind: "single" as const, + pageKey: "services", + aspectRatio: "16:9", + itemCount: 1, + recommendedWidth: 1400, + }, + { + key: SERVICES_SLOT_KEYS.cards, + label: "Services slider", + kind: "list" as const, + pageKey: "services", + aspectRatio: "4:5", + itemCount: 4, + recommendedWidth: 800, + }, + { + key: SERVICES_SLOT_KEYS.gallery, + label: "Services page gallery", + kind: "list" as const, + pageKey: "services", + aspectRatio: "4:5", + itemCount: 2, + recommendedWidth: 720, + }, { key: CONTACT_SLOT_KEYS.hero, label: "Contact page header", diff --git a/src/lib/site.ts b/src/lib/site.ts index f8477f9..e275a41 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -30,4 +30,5 @@ export const CONTACT = { instagram: "https://www.instagram.com/mostafa_heydar_hoseini?igsh=MXh5MzRtM3g4bHoycQ==", video: "https://www.aparat.com/v/oiz515o", + servicesVideo: "https://www.aparat.com/v/uxi63k7", };