Build & Push Docker Image / build (push) Has been cancelled
Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets. Co-authored-by: Cursor <cursoragent@cursor.com>
452 lines
16 KiB
TypeScript
452 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { ArrowLeft, ArrowRight, ArrowUpLeft } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
import { Container } from "@/components/container";
|
|
import { MotionReveal } from "@/components/motion-reveal";
|
|
import { Button } from "@/components/ui/button";
|
|
import type { CarouselApi } from "@/components/ui/carousel";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
} from "@/components/ui/carousel";
|
|
import { layout } from "@/lib/layout";
|
|
import { type, typeMeasure, typeSpacing } from "@/lib/typography";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const AUTOPLAY_MS = 4000;
|
|
const TWEEN_FACTOR_BASE = 0.84;
|
|
const SCALE_MIN = 0.8;
|
|
const SCALE_MAX = 1.05;
|
|
const OPACITY_MIN = 0.45;
|
|
const OPACITY_MAX = 1;
|
|
|
|
const projects17Prop = [
|
|
{
|
|
id: 1,
|
|
title: "طراحی سایت سنگبری",
|
|
profession: "سنگبری",
|
|
image: "/assets/main-pic/100.jpg",
|
|
},
|
|
{
|
|
id: 2,
|
|
title: "طراحی سایت فروشگاهی کفش",
|
|
profession: "فروشگاه کفش",
|
|
image: "/assets/main-pic/101.jpg",
|
|
},
|
|
{
|
|
id: 3,
|
|
title: "طراحی سایت لوازم یدکی خودرو",
|
|
profession: "لوازم یدکی خودرو",
|
|
image: "/assets/main-pic/102.jpg",
|
|
},
|
|
{
|
|
id: 4,
|
|
title: "طراحی سایت لوازم یدکی",
|
|
profession: "لوازم یدکی",
|
|
image: "/assets/main-pic/103.jpg",
|
|
},
|
|
{
|
|
id: 5,
|
|
title: "طراحی سایت تجهیزات ساختمانی",
|
|
profession: "تجهیزات ساختمانی",
|
|
image: "/assets/main-pic/104.jpg",
|
|
},
|
|
];
|
|
|
|
function numberWithinRange(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
interface Projects17Props {
|
|
className?: string;
|
|
}
|
|
|
|
export function Projects17({ className }: Projects17Props) {
|
|
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
|
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
|
const [canScrollNext, setCanScrollNext] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
|
|
const tweenNodesRef = useRef<(HTMLElement | null)[]>([]);
|
|
const tweenFactorRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (!carouselApi) return;
|
|
|
|
const updateSelection = () => {
|
|
setCanScrollPrev(carouselApi.canScrollPrev());
|
|
setCanScrollNext(carouselApi.canScrollNext());
|
|
setSelectedIndex(carouselApi.selectedScrollSnap());
|
|
setScrollSnaps(carouselApi.scrollSnapList());
|
|
};
|
|
|
|
updateSelection();
|
|
carouselApi.on("select", updateSelection);
|
|
carouselApi.on("reInit", updateSelection);
|
|
|
|
return () => {
|
|
carouselApi.off("select", updateSelection);
|
|
carouselApi.off("reInit", updateSelection);
|
|
};
|
|
}, [carouselApi]);
|
|
|
|
// Continuous scale/opacity while scrolling (desktop only)
|
|
useEffect(() => {
|
|
if (!carouselApi) return;
|
|
|
|
const desktop = window.matchMedia("(min-width: 768px)");
|
|
|
|
const setTweenFactor = () => {
|
|
tweenFactorRef.current =
|
|
TWEEN_FACTOR_BASE * carouselApi.scrollSnapList().length;
|
|
};
|
|
|
|
const resetTween = () => {
|
|
tweenNodesRef.current.forEach((node) => {
|
|
if (!node) return;
|
|
node.style.transform = "";
|
|
node.style.opacity = "";
|
|
node.style.zIndex = "";
|
|
});
|
|
};
|
|
|
|
const tweenScale = () => {
|
|
if (!desktop.matches) {
|
|
resetTween();
|
|
return;
|
|
}
|
|
|
|
const engine = carouselApi.internalEngine();
|
|
const scrollProgress = carouselApi.scrollProgress();
|
|
const slidesInView = carouselApi.slidesInView();
|
|
|
|
carouselApi.scrollSnapList().forEach((scrollSnap, snapIndex) => {
|
|
let diffToTarget = scrollSnap - scrollProgress;
|
|
const slidesInSnap = engine.slideRegistry[snapIndex];
|
|
|
|
slidesInSnap.forEach((slideIndex) => {
|
|
const tweenNode = tweenNodesRef.current[slideIndex];
|
|
if (!tweenNode) return;
|
|
|
|
if (engine.options.loop) {
|
|
engine.slideLooper.loopPoints.forEach((loopItem) => {
|
|
const target = loopItem.target();
|
|
if (slideIndex === loopItem.index && target !== 0) {
|
|
const sign = Math.sign(target);
|
|
if (sign === -1) {
|
|
diffToTarget = scrollSnap - (1 + scrollProgress);
|
|
}
|
|
if (sign === 1) {
|
|
diffToTarget = scrollSnap + (1 - scrollProgress);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const tweenValue = 1 - Math.abs(diffToTarget * tweenFactorRef.current);
|
|
const progress = numberWithinRange(tweenValue, 0, 1);
|
|
const scale = SCALE_MIN + progress * (SCALE_MAX - SCALE_MIN);
|
|
const opacity = OPACITY_MIN + progress * (OPACITY_MAX - OPACITY_MIN);
|
|
|
|
if (slidesInView.includes(slideIndex) || progress > 0.01) {
|
|
tweenNode.style.transform = `scale(${scale})`;
|
|
tweenNode.style.opacity = String(opacity);
|
|
tweenNode.style.zIndex = progress > 0.85 ? "1" : "0";
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const onReInit = () => {
|
|
setTweenFactor();
|
|
tweenScale();
|
|
};
|
|
|
|
const onDesktopChange = () => {
|
|
setTweenFactor();
|
|
tweenScale();
|
|
};
|
|
|
|
setTweenFactor();
|
|
tweenScale();
|
|
carouselApi.on("reInit", onReInit);
|
|
carouselApi.on("scroll", tweenScale);
|
|
carouselApi.on("slideFocus", tweenScale);
|
|
desktop.addEventListener("change", onDesktopChange);
|
|
|
|
return () => {
|
|
carouselApi.off("reInit", onReInit);
|
|
carouselApi.off("scroll", tweenScale);
|
|
carouselApi.off("slideFocus", tweenScale);
|
|
desktop.removeEventListener("change", onDesktopChange);
|
|
resetTween();
|
|
};
|
|
}, [carouselApi]);
|
|
|
|
// Desktop autoplay — paused on hover/focus; skipped for reduced motion & mobile
|
|
useEffect(() => {
|
|
if (!carouselApi) return;
|
|
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
const desktop = window.matchMedia("(min-width: 768px)");
|
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
let paused = false;
|
|
|
|
const clear = () => {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = undefined;
|
|
}
|
|
};
|
|
|
|
const tick = () => {
|
|
clear();
|
|
if (paused || reducedMotion.matches || !desktop.matches) return;
|
|
timer = setInterval(() => {
|
|
carouselApi.scrollNext();
|
|
}, AUTOPLAY_MS);
|
|
};
|
|
|
|
const pause = () => {
|
|
paused = true;
|
|
clear();
|
|
};
|
|
|
|
const resume = () => {
|
|
paused = false;
|
|
tick();
|
|
};
|
|
|
|
const onPointerDown = () => clear();
|
|
const onSettle = () => {
|
|
if (!paused) tick();
|
|
};
|
|
const onChange = () => tick();
|
|
|
|
const root = carouselApi.rootNode();
|
|
root.addEventListener("mouseenter", pause);
|
|
root.addEventListener("mouseleave", resume);
|
|
root.addEventListener("focusin", pause);
|
|
root.addEventListener("focusout", resume);
|
|
reducedMotion.addEventListener("change", onChange);
|
|
desktop.addEventListener("change", onChange);
|
|
carouselApi.on("pointerDown", onPointerDown);
|
|
carouselApi.on("settle", onSettle);
|
|
tick();
|
|
|
|
return () => {
|
|
clear();
|
|
root.removeEventListener("mouseenter", pause);
|
|
root.removeEventListener("mouseleave", resume);
|
|
root.removeEventListener("focusin", pause);
|
|
root.removeEventListener("focusout", resume);
|
|
reducedMotion.removeEventListener("change", onChange);
|
|
desktop.removeEventListener("change", onChange);
|
|
carouselApi.off("pointerDown", onPointerDown);
|
|
carouselApi.off("settle", onSettle);
|
|
};
|
|
}, [carouselApi]);
|
|
|
|
return (
|
|
<section
|
|
id="projects"
|
|
className={cn(
|
|
"section-shell bg-section-muted",
|
|
layout.scrollMargin,
|
|
layout.sectionY,
|
|
className
|
|
)}
|
|
>
|
|
<Container className="section-content">
|
|
<MotionReveal>
|
|
<div className="mb-10 flex flex-col gap-6 md:mb-12 md:flex-row md:items-end md:justify-between md:gap-8">
|
|
<div className={cn(typeSpacing.sectionStack, "min-w-0 max-w-2xl")}>
|
|
<p className={cn(type.eyebrow, "text-primary")}>پورتفولیو</p>
|
|
<h2 className={type.h2}>نمونه کارها</h2>
|
|
<p className={cn(type["section-intro"], typeMeasure.prose)}>
|
|
نگاهی به وبسایتهایی که برای کسبوکارهای مختلف طراحی و پیادهسازی
|
|
کردهایم.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
|
<Button
|
|
size="icon"
|
|
variant="secondary"
|
|
onClick={() => carouselApi?.scrollPrev()}
|
|
disabled={!canScrollPrev}
|
|
aria-label="پروژه قبلی"
|
|
className="size-10 border-surface bg-background/90 shadow-surface-sm backdrop-blur-sm disabled:opacity-40"
|
|
>
|
|
<ArrowRight />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="secondary"
|
|
onClick={() => carouselApi?.scrollNext()}
|
|
disabled={!canScrollNext}
|
|
aria-label="پروژه بعدی"
|
|
className="size-10 border-surface bg-background/90 shadow-surface-sm backdrop-blur-sm disabled:opacity-40"
|
|
>
|
|
<ArrowLeft />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative overflow-hidden">
|
|
<div className="local-highlight local-highlight--soft" aria-hidden />
|
|
|
|
<div className="relative z-[1]">
|
|
<Carousel
|
|
setApi={setCarouselApi}
|
|
opts={{
|
|
align: "start",
|
|
loop: true,
|
|
direction: "rtl",
|
|
breakpoints: {
|
|
"(min-width: 768px)": {
|
|
align: "center",
|
|
},
|
|
},
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<CarouselContent
|
|
className="-mr-3 ml-0 sm:-mr-4 md:-mr-5"
|
|
viewportClassName="overflow-hidden py-1 sm:py-2 md:py-6"
|
|
>
|
|
{projects17Prop.map((project, index) => {
|
|
const isActive = index === selectedIndex;
|
|
|
|
return (
|
|
<CarouselItem
|
|
key={project.id}
|
|
className="basis-[85%] pr-3 pl-0 sm:basis-[70%] sm:pr-4 md:basis-[52%] md:pr-5 lg:basis-[40%]"
|
|
>
|
|
<a
|
|
ref={(node) => {
|
|
tweenNodesRef.current[index] = node;
|
|
}}
|
|
href="#"
|
|
aria-current={isActive ? "true" : undefined}
|
|
className={cn(
|
|
"media-frame group relative block aspect-[4/3] overflow-hidden rounded-xl",
|
|
"md:origin-center md:will-change-transform",
|
|
isActive && "md:shadow-glow-media"
|
|
)}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={project.image}
|
|
alt={project.title}
|
|
className={cn(
|
|
"absolute inset-0 size-full object-cover transition-transform duration-500 ease-out",
|
|
isActive && "md:group-hover:scale-105"
|
|
)}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"absolute inset-0 bg-linear-to-t from-brand-neutral/90 via-brand-neutral/35 to-transparent transition-opacity duration-300",
|
|
isActive && "md:group-hover:opacity-95"
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
<div className="absolute inset-0 flex flex-col justify-end gap-1.5 p-4 sm:gap-2 sm:p-5">
|
|
<p className={cn(type.caption, "text-white/75")}>
|
|
{project.profession}
|
|
</p>
|
|
<h3
|
|
className={cn(
|
|
type.h3,
|
|
"text-base text-white transition-[font-size] duration-500 ease-out sm:text-lg",
|
|
isActive && "md:text-xl"
|
|
)}
|
|
>
|
|
{project.title}
|
|
</h3>
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"absolute start-3 top-3 flex size-8 items-center justify-center rounded-full border border-white/20 bg-white/10 text-white backdrop-blur-sm transition-all duration-200 sm:start-4 sm:top-4 sm:size-9",
|
|
isActive
|
|
? "opacity-100 md:opacity-0 md:group-hover:opacity-100"
|
|
: "opacity-0"
|
|
)}
|
|
aria-hidden
|
|
>
|
|
<ArrowUpLeft className="size-4 transition-transform duration-200 group-hover:scale-110 sm:size-5" />
|
|
</span>
|
|
</a>
|
|
</CarouselItem>
|
|
);
|
|
})}
|
|
</CarouselContent>
|
|
</Carousel>
|
|
</div>
|
|
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex aspect-[4/3] items-center justify-between md:hidden">
|
|
<Button
|
|
size="icon"
|
|
variant="secondary"
|
|
onClick={() => carouselApi?.scrollPrev()}
|
|
disabled={!canScrollPrev}
|
|
aria-label="پروژه قبلی"
|
|
className="pointer-events-auto size-10 border-surface bg-background/90 shadow-surface-sm backdrop-blur-sm disabled:opacity-40"
|
|
>
|
|
<ArrowRight />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="secondary"
|
|
onClick={() => carouselApi?.scrollNext()}
|
|
disabled={!canScrollNext}
|
|
aria-label="پروژه بعدی"
|
|
className="pointer-events-auto size-10 border-surface bg-background/90 shadow-surface-sm backdrop-blur-sm disabled:opacity-40"
|
|
>
|
|
<ArrowLeft />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{scrollSnaps.length > 1 ? (
|
|
<div
|
|
className="mt-6 flex items-center justify-center gap-2 md:mt-8"
|
|
role="tablist"
|
|
aria-label="اسلایدهای نمونه کار"
|
|
>
|
|
{scrollSnaps.map((_, index) => (
|
|
<button
|
|
key={index}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={index === selectedIndex}
|
|
aria-label={`اسلاید ${index + 1}`}
|
|
onClick={() => carouselApi?.scrollTo(index)}
|
|
className={cn(
|
|
"h-1.5 rounded-full transition-all duration-200",
|
|
index === selectedIndex
|
|
? "w-6 bg-primary"
|
|
: "w-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/50"
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-10 flex justify-center md:mt-12">
|
|
<Button asChild size="lg">
|
|
<a href="#">
|
|
مشاهده نمونه کارها
|
|
<ArrowLeft />
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</MotionReveal>
|
|
</Container>
|
|
</section>
|
|
);
|
|
}
|