Files
aragrp/components/projects-17.tsx
T
ar.badieiandCursor c2376b3b9e
Build & Push Docker Image / build (push) Has been cancelled
feat: redesign landing page with brand system and section blocks
Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 17:00:55 +03:30

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>
);
}