Add services page and make the portfolio carousel draggable

Port the /services page from the legacy site: hero, "Field of activity"
with the five expertise areas, the video feature image that straddles the
section boundary, the services slider, the stats row, and the capability
lists with the closing "architecture" watermark. Images come from the
legacy theme; they are also wired to Meshkee static-image slots
(services-hero/feature/cards/gallery) so they can be swapped from the panel.

The navbar Services link now points at /services instead of the homepage
anchor, and the sitemap picks the route up automatically.

PortfolioCarousel takes an optional `items` prop so the new page can reuse
it, and now supports pointer dragging. Two fixes were needed for that:

- Easing is toggled on the track imperatively with a forced style flush
  instead of via a class. Chrome decides whether to animate from the
  before-change style, so a class-based `transition: none` also suppressed
  the transition on the commit that removed it — the drag snap jumped, and
  the loop reset (which was driven by onTransitionEnd) never ran at all,
  letting the track drift off the end of the list. The loop now re-anchors
  on a timer and the reset hops instead of gliding.
- The gesture is tracked on the window rather than through pointer capture,
  so releasing outside the carousel or losing focus mid-drag still settles
  it instead of leaving the track parked at its dragged offset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
amirhosein.ashourloo
2026-09-14 12:33:19 +03:30
co-authored by Claude Opus 5
parent a8454f37f0
commit 6bcbe73d81
15 changed files with 757 additions and 35 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

+310 -3
View File
@@ -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) {
@@ -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 };
+188
View File
@@ -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 (
<main>
<section
className="page-hero dotted"
style={{ backgroundImage: `url(${imageUrl(hero, "/images/services/subheader.jpg")})` }}
>
<Reveal>
<h1>
<span style={{ color: "var(--base-color)" }}>our</span> services
</h1>
<h2>Great architecture services</h2>
</Reveal>
</section>
<section className="section dotted service-lead">
<div className="container-site service-intro">
<Reveal>
<span className="eyebrow">Architecture process</span>
<h2 className="heading-white" style={{ fontSize: 30, marginTop: 0 }}>
Field of activity
</h2>
<p>Areas of Expertise – Khiyal Architecture Studio</p>
<p>
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.
</p>
</Reveal>
<Reveal className="service-areas">
{serviceAreas.map((area) => (
<article key={area.number} className="service-area">
<span className="service-area-number text-outline" aria-hidden>
{area.number}
</span>
<h3>{area.title}</h3>
<p>{area.text}</p>
</article>
))}
<div className="service-common">
<span className="heading-white" style={{ fontWeight: 600 }}>
Common Features Across All Our Work:
</span>
<p>{serviceCommonFeatures.join(" · ")}</p>
</div>
</Reveal>
</div>
</section>
<section className="section dotted service-feature-section" style={{ background: "var(--nero-grey)" }}>
<div className="container-site">
<Reveal className="service-feature" self>
<div
className="service-feature-image"
style={{ backgroundImage: `url(${imageUrl(feature, "/images/services/feature.jpg")})` }}
role="img"
aria-label={imageAlt(feature, "Minimal architecture project at dusk")}
/>
<a
className="service-play"
href={CONTACT.servicesVideo}
target="_blank"
rel="noreferrer"
aria-label="Play studio video"
>
Play
</a>
</Reveal>
<Reveal className="intro-grid" style={{ marginTop: 72, marginBottom: 48 }}>
<div>
<span className="eyebrow">Architecture services</span>
<h2 className="heading-white" style={{ fontSize: 30, margin: 0 }}>
Modern buildings create modern and up-to-date functionality for you
</h2>
</div>
<p>
Our buildings combine minimalism and elegance of lines and shapes. We want them to be an integral part of
the surrounding landscape.
</p>
</Reveal>
<PortfolioCarousel items={cards} />
</div>
</section>
<section className="section dotted" style={{ background: "var(--nero-grey)" }}>
<Reveal className="container-site service-stats">
{serviceStats.map((stat) => (
<div key={stat.label} className="service-stat">
<strong>
{stat.value}
{stat.suffix ? <sup>{stat.suffix}</sup> : null}
</strong>
<div>
{stat.stars ? (
<span className="service-stars" aria-label={`${stat.stars} out of 5`}>
{"★".repeat(stat.stars)}
</span>
) : null}
<span>{stat.label}</span>
</div>
</div>
))}
</Reveal>
</section>
<section className="section dotted service-outro">
<div className="container-site service-outro-grid">
<Reveal>
<h3 className="heading-white" style={{ fontSize: 22, marginTop: 0 }}>
Our small size enables us to service our clients closely and gives us the flexibility to adapt to changing
scenarios in the design world.
</h3>
<div className="service-capabilities">
{serviceCapabilities.map((column) => (
<ul key={column[0]}>
{column.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
))}
</div>
</Reveal>
<Reveal className="service-visual">
<img className="service-pic-a" src={photos[0].src} alt={photos[0].alt} />
<img className="service-pic-b" src={photos[1].src} alt={photos[1].alt} />
</Reveal>
</div>
<div className="watermark" aria-hidden>
architecture
</div>
</section>
</main>
);
}
+143 -31
View File
@@ -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<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<div
className={`portfolio-bleed${instant ? " is-instant" : ""}`}
ref={bleedRef}
className={`portfolio-bleed${dragging ? " is-dragging" : ""}`}
style={{ ["--slide" as string]: index }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onPointerDown={onPointerDown}
>
<div
className="portfolio-track"
onTransitionEnd={() => {
if (index < count) return;
setInstant(true);
setIndex(0);
}}
>
{[...portfolioItems, ...portfolioItems].map((item, i) => (
<article key={`${item.titleEn}-${i}`} className="portfolio-card">
<img src={item.image} alt={item.titleEn} />
<div className="copy">
<h3>{item.titleEn}</h3>
<p>{item.text}</p>
</div>
</article>
))}
<div ref={trackRef} className="portfolio-track">
{Array.from({ length: reps }, () => slides)
.flat()
.map((item, i) => (
<article key={`${item.title}-${i}`} className="portfolio-card">
<img src={item.image} alt={item.title} draggable={false} />
<div className="copy">
<h3>{item.title}</h3>
<p>{item.text}</p>
</div>
</article>
))}
</div>
</div>
);
+71 -1
View File
@@ -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: "نوجان",
+43
View File
@@ -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",
+1
View File
@@ -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",
};