feat: landing & contact
This commit is contained in:
22
src/components/landing/ActionLink.astro
Normal file
22
src/components/landing/ActionLink.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
variant?: "default" | "outline" | "secondary" | "ghost";
|
||||
size?: "default" | "lg" | "sm";
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
href,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
class: className,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<a href={href} class={cn(buttonVariants({ variant, size }), className)}>
|
||||
<slot />
|
||||
</a>
|
||||
69
src/components/landing/CtaSection.astro
Normal file
69
src/components/landing/CtaSection.astro
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
import ActionLink from "@/components/landing/ActionLink.astro";
|
||||
import { LucideIcon } from "@/components/ui/lucide-icon";
|
||||
import ctaBg from "@/assets/cta-bg.jpg";
|
||||
---
|
||||
|
||||
<section class="bg-background px-4 py-10 md:px-6 md:py-12 lg:px-8">
|
||||
<div
|
||||
class="relative mx-auto max-w-7xl overflow-hidden rounded-3xl border border-border"
|
||||
>
|
||||
<img
|
||||
src={ctaBg.src}
|
||||
alt=""
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-black/90"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col items-center gap-6 p-6 text-center md:flex-row md:items-center md:justify-between md:gap-8 md:p-10 md:text-start lg:p-12"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 md:flex-row md:items-center">
|
||||
<div
|
||||
class="flex size-14 shrink-0 items-center justify-center rounded-xl bg-background md:size-16"
|
||||
>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
class="size-8 md:size-9"
|
||||
width="36"
|
||||
height="36"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-foreground md:text-xl">
|
||||
از امروز باشگاه خود را حرفهایتر مدیریت کنید
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
به ۵۴ باشگاه دیگر در نکستا ملحق شوید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-3 md:w-auto md:flex-row md:flex-wrap md:items-center">
|
||||
<ActionLink
|
||||
href="/plans"
|
||||
size="lg"
|
||||
class="w-full justify-center rounded-full px-6 md:w-auto [&_svg]:ms-1"
|
||||
>
|
||||
شروع کنید
|
||||
<LucideIcon name="arrowLeft" className="size-4" />
|
||||
</ActionLink>
|
||||
<ActionLink
|
||||
href="/contact"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full justify-center rounded-full border-white/40 bg-transparent px-6 text-foreground hover:border-white/60 hover:bg-white/10 hover:text-foreground md:w-auto"
|
||||
>
|
||||
تماس با ما
|
||||
</ActionLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
34
src/components/landing/FeaturesSection.astro
Normal file
34
src/components/landing/FeaturesSection.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import SectionShell from "@/components/landing/SectionShell.astro";
|
||||
import SectionHeading from "@/components/landing/SectionHeading.astro";
|
||||
import { LucideIcon } from "@/components/ui/lucide-icon";
|
||||
import { features } from "@/lib/landing-data";
|
||||
---
|
||||
|
||||
<SectionShell id="features" variant="default">
|
||||
<SectionHeading
|
||||
title="ویژگیها"
|
||||
subtitle="هر آنچه برای مدیریت حرفهای باشگاه خود نیاز دارید"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
{
|
||||
features.map((feature) => (
|
||||
<article class="flex items-start gap-4 rounded-2xl border border-border bg-card/20 p-5 transition-colors hover:border-primary/30">
|
||||
<div class="flex size-14 shrink-0 items-center justify-center rounded-lg border border-primary">
|
||||
<LucideIcon name={feature.icon} className="size-7 text-primary" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<h3 class="text-base font-semibold text-foreground">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p class="text-sm leading-relaxed text-muted-foreground">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</SectionShell>
|
||||
68
src/components/landing/HeroSection.astro
Normal file
68
src/components/landing/HeroSection.astro
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import ActionLink from "@/components/landing/ActionLink.astro";
|
||||
import heroImage from "@/assets/image.png";
|
||||
---
|
||||
|
||||
<section
|
||||
class="relative overflow-hidden bg-background px-4 pt-12 pb-16 md:px-6 md:pt-24 md:pb-24 lg:px-8"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(165,255,1,0.08),transparent_55%)]"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mx-auto grid max-w-7xl items-center gap-10 lg:grid-cols-2 lg:gap-16"
|
||||
>
|
||||
<div class="space-y-6 text-center lg:space-y-8 lg:text-start">
|
||||
<h1
|
||||
class="text-3xl leading-tight font-bold text-primary md:text-4xl lg:text-5xl"
|
||||
>
|
||||
مدیریت هوشمند باشگاه،
|
||||
<span class="text-primary">سادهتر از همیشه</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mx-auto max-w-xl text-base leading-relaxed text-muted-foreground md:text-lg lg:mx-0"
|
||||
>
|
||||
یک پلتفرم کامل برای مدیریت اعضا، مربیان، برنامههای تمرینی و تمام
|
||||
فعالیتهای باشگاه.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:justify-start">
|
||||
<ActionLink
|
||||
href="/plans"
|
||||
size="lg"
|
||||
class="w-full justify-center rounded-full px-6 py-5 lg:w-auto"
|
||||
>
|
||||
همین الان امتحان کن!
|
||||
</ActionLink>
|
||||
<ActionLink
|
||||
href="/plans"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full justify-center rounded-full px-6 py-5 lg:w-auto"
|
||||
>
|
||||
مشاهده پلنها
|
||||
</ActionLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative mx-auto w-full max-w-sm md:max-w-lg lg:max-w-xl lg:ms-auto">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(50,217,203,0.15),transparent_70%)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<img
|
||||
src={heroImage.src}
|
||||
width={heroImage.width}
|
||||
height={heroImage.height}
|
||||
alt="پیشنمایش داشبورد نکستا در موبایل"
|
||||
class="relative w-full object-contain"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
73
src/components/landing/PricingSection.astro
Normal file
73
src/components/landing/PricingSection.astro
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
import SectionShell from "@/components/landing/SectionShell.astro";
|
||||
import SectionHeading from "@/components/landing/SectionHeading.astro";
|
||||
import { LucideIcon } from "@/components/ui/lucide-icon";
|
||||
import { plans } from "@/lib/landing-data";
|
||||
---
|
||||
|
||||
<SectionShell id="pricing" variant="default">
|
||||
<SectionHeading title="پلنها" subtitle="پلنهای متناسب با نیاز باشگاه شما" />
|
||||
|
||||
<div
|
||||
class="flex w-full flex-col-reverse items-stretch gap-4 lg:grid lg:grid-cols-3 lg:gap-5"
|
||||
>
|
||||
{
|
||||
plans.map((plan) => (
|
||||
<article
|
||||
class:list={[
|
||||
"relative w-full rounded-2xl border bg-card p-6",
|
||||
plan.popular ? "border-primary" : "border-border",
|
||||
]}
|
||||
>
|
||||
{plan.popular && (
|
||||
<span class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary px-4 py-1 text-xs font-medium whitespace-nowrap text-primary-foreground lg:left-auto lg:translate-x-0 lg:right-6">
|
||||
محبوبترین
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">{plan.name}</h3>
|
||||
<p class="shrink-0 text-left text-sm leading-snug text-muted-foreground">
|
||||
<span class="text-foreground text-lg">{plan.price}</span>
|
||||
<span class="mx-1 text-muted-foreground/60">/</span>
|
||||
{plan.period}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="flex flex-col gap-3">
|
||||
<li class="flex items-center gap-2 text-sm text-foreground">
|
||||
<LucideIcon
|
||||
name="users"
|
||||
className="size-4 shrink-0 text-primary"
|
||||
/>
|
||||
<span>{plan.memberLimit}</span>
|
||||
</li>
|
||||
|
||||
{plan.extras.map((feature) => (
|
||||
<li class="flex items-center gap-2 text-sm text-foreground">
|
||||
<span class="flex size-5 shrink-0 items-center justify-center rounded-full border border-primary">
|
||||
<LucideIcon name="check" className="size-3 text-primary" />
|
||||
</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p class="mt-6 text-sm leading-relaxed text-muted-foreground">
|
||||
{plan.description}
|
||||
</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-center md:mt-8 md:justify-end">
|
||||
<a
|
||||
href="/plans"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-primary transition-colors hover:text-primary/80"
|
||||
>
|
||||
مشاهده و خرید
|
||||
<span aria-hidden="true">←</span>
|
||||
</a>
|
||||
</div>
|
||||
</SectionShell>
|
||||
27
src/components/landing/SectionHeading.astro
Normal file
27
src/components/landing/SectionHeading.astro
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
const { title, subtitle, centered = true } = Astro.props;
|
||||
---
|
||||
|
||||
<div class:list={["mb-8 md:mb-10 lg:mb-14", centered && "text-center"]}>
|
||||
<h2 class="text-base font-bold text-primary md:text-xl lg:text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
{
|
||||
subtitle && (
|
||||
<p
|
||||
class:list={[
|
||||
"mt-2 max-w-3xl font-semibold text-foreground md:mt-3 md:text-2xl lg:text-3xl",
|
||||
centered && "mx-auto",
|
||||
]}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
30
src/components/landing/SectionShell.astro
Normal file
30
src/components/landing/SectionShell.astro
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
interface Props {
|
||||
variant?: "default" | "elevated";
|
||||
id?: string;
|
||||
class?: string;
|
||||
containerClass?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
variant = "default",
|
||||
id,
|
||||
class: className,
|
||||
containerClass,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<section
|
||||
id={id}
|
||||
class:list={[
|
||||
"px-4 py-12 md:px-6 md:py-16 lg:px-8 lg:py-24",
|
||||
variant === "elevated"
|
||||
? "border-y border-border bg-card"
|
||||
: "bg-background",
|
||||
className,
|
||||
]}
|
||||
>
|
||||
<div class:list={["mx-auto max-w-7xl", containerClass]}>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
34
src/components/landing/StatsSection.astro
Normal file
34
src/components/landing/StatsSection.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import SectionShell from "@/components/landing/SectionShell.astro";
|
||||
import { HeroSocialProof } from "@/components/landing/hero-social-proof";
|
||||
import { LucideIcon } from "@/components/ui/lucide-icon";
|
||||
import { stats } from "@/lib/landing-data";
|
||||
---
|
||||
|
||||
<SectionShell
|
||||
variant="default"
|
||||
class="pt-2 pb-10 md:pt-3 md:pb-16"
|
||||
containerClass="flex flex-col gap-4 md:gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center pb-2 md:justify-end md:pb-3 lg:max-w-xl lg:ms-auto"
|
||||
>
|
||||
<HeroSocialProof />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 md:gap-4 lg:grid-cols-4">
|
||||
{
|
||||
stats.map((stat) => (
|
||||
<article class="flex items-center gap-3 rounded-2xl border border-border bg-card/20 p-4 sm:gap-4 sm:p-5">
|
||||
<div class="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/10 sm:size-16">
|
||||
<LucideIcon name={stat.icon} className="size-6 text-primary sm:size-8" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xl font-bold text-primary sm:text-2xl">{stat.value}</p>
|
||||
<p class="text-xs text-muted-foreground sm:text-sm">{stat.label}</p>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</SectionShell>
|
||||
36
src/components/landing/StepsSection.astro
Normal file
36
src/components/landing/StepsSection.astro
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import SectionShell from "@/components/landing/SectionShell.astro";
|
||||
import SectionHeading from "@/components/landing/SectionHeading.astro";
|
||||
import { steps } from "@/lib/landing-data";
|
||||
import { cn } from "@/lib/utils";
|
||||
---
|
||||
|
||||
<SectionShell variant="default">
|
||||
<SectionHeading title="ساده برای شروع، قدرتمند در اجرا" subtitle="" />
|
||||
|
||||
<div class="flex flex-col gap-4 md:grid md:grid-cols-3 md:gap-0">
|
||||
{
|
||||
steps.map((step, i) => (
|
||||
<article
|
||||
class={cn(
|
||||
"relative flex items-start gap-4 border border-border bg-background p-5 md:p-6",
|
||||
"rounded-2xl md:rounded-none",
|
||||
i === 0 && "md:rounded-tr-2xl md:rounded-br-2xl",
|
||||
i === steps.length - 1 && "md:rounded-tl-2xl md:rounded-bl-2xl",
|
||||
)}
|
||||
>
|
||||
<span class="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-2xl font-bold text-primary-foreground md:size-14 md:text-3xl">
|
||||
{step.number}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<h3 class="text-lg font-semibold text-foreground">{step.title}</h3>
|
||||
<p class="text-sm leading-relaxed text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</SectionShell>
|
||||
49
src/components/landing/TestimonialsSection.astro
Normal file
49
src/components/landing/TestimonialsSection.astro
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import SectionShell from "@/components/landing/SectionShell.astro";
|
||||
import SectionHeading from "@/components/landing/SectionHeading.astro";
|
||||
import { TestimonialsCarousel } from "@/components/landing/testimonials-carousel";
|
||||
import { LucideIcon } from "@/components/ui/lucide-icon";
|
||||
import { testimonials } from "@/lib/landing-data";
|
||||
---
|
||||
|
||||
<SectionShell variant="default" containerClass="min-w-0 overflow-x-clip">
|
||||
<SectionHeading
|
||||
title="نظرات کاربران"
|
||||
subtitle="مورد اعتماد مدیران باشگاه."
|
||||
/>
|
||||
|
||||
<TestimonialsCarousel client:load />
|
||||
|
||||
<div class="hidden gap-6 md:grid md:grid-cols-3">
|
||||
{
|
||||
testimonials.map((item) => (
|
||||
<article class="flex flex-col rounded-2xl border border-border bg-card p-6">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex size-14 shrink-0 items-center justify-center rounded-full bg-primary/10 text-lg font-bold text-primary">
|
||||
{item.name.charAt(0)}
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{item.name}</p>
|
||||
<p class="text-xs text-muted-foreground">{item.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 gap-0.5">
|
||||
{Array.from({ length: item.rating }).map(() => (
|
||||
<LucideIcon
|
||||
name="star"
|
||||
className="size-4 fill-yellow-400 text-yellow-400"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm leading-relaxed text-muted-foreground">
|
||||
«{item.quote}»
|
||||
</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</SectionShell>
|
||||
53
src/components/landing/hero-social-proof.tsx
Normal file
53
src/components/landing/hero-social-proof.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Star } from "lucide-react";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar";
|
||||
|
||||
const avatars = [
|
||||
{ src: "https://i.pravatar.cc/80?img=11", fallback: "ا" },
|
||||
{ src: "https://i.pravatar.cc/80?img=12", fallback: "س" },
|
||||
{ src: "https://i.pravatar.cc/80?img=13", fallback: "ر" },
|
||||
{ src: "https://i.pravatar.cc/80?img=14", fallback: "ک" },
|
||||
] as const;
|
||||
|
||||
export function HeroSocialProof() {
|
||||
return (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="flex w-full max-w-sm flex-col items-center gap-3 sm:max-w-none sm:flex-row sm:items-center sm:gap-4"
|
||||
>
|
||||
<AvatarGroup className="shrink-0">
|
||||
{avatars.map((avatar) => (
|
||||
<Avatar key={avatar.src} size="lg">
|
||||
<AvatarImage src={avatar.src} alt="" />
|
||||
<AvatarFallback className="bg-card text-xs text-primary">
|
||||
{avatar.fallback}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
|
||||
<div className="flex flex-col items-center gap-1 text-center sm:items-start sm:text-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className="size-4 fill-yellow-400 text-yellow-400"
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">4.9/5</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground">
|
||||
بیش از 40 باشگاه به ما اعتماد کردهاند
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/landing/testimonials-carousel.tsx
Normal file
116
src/components/landing/testimonials-carousel.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { Star } from "lucide-react";
|
||||
|
||||
import { testimonials } from "@/lib/landing-data";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Testimonial = (typeof testimonials)[number];
|
||||
|
||||
function TestimonialCard({ item }: { item: Testimonial }) {
|
||||
return (
|
||||
<article className="flex min-w-0 flex-col overflow-hidden rounded-2xl border border-border bg-card p-6">
|
||||
<div className="mb-4 flex min-w-0 items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex size-14 shrink-0 items-center justify-center rounded-full bg-primary/10 text-lg font-bold text-primary">
|
||||
{item.name.charAt(0)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{item.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{item.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
{Array.from({ length: item.rating }).map((_, index) => (
|
||||
<Star
|
||||
key={index}
|
||||
className="size-4 fill-yellow-400 text-yellow-400"
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="break-words text-sm leading-relaxed text-muted-foreground">
|
||||
«{item.quote}»
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselDots({
|
||||
count,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
count: number;
|
||||
selected: number;
|
||||
onSelect: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-center gap-2 pt-5">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`نظر ${index + 1} از ${count}`}
|
||||
aria-current={index === selected ? "true" : undefined}
|
||||
onClick={() => onSelect(index)}
|
||||
className={cn(
|
||||
"size-2 rounded-full transition-colors",
|
||||
index === selected ? "bg-primary" : "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestimonialsCarousel() {
|
||||
const [selected, setSelected] = React.useState(0);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
align: "start",
|
||||
loop: false,
|
||||
direction: "rtl",
|
||||
containScroll: "trimSnaps",
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
const onSelect = () => setSelected(emblaApi.selectedScrollSnap());
|
||||
|
||||
onSelect();
|
||||
emblaApi.on("select", onSelect);
|
||||
emblaApi.on("reInit", onSelect);
|
||||
|
||||
return () => {
|
||||
emblaApi.off("select", onSelect);
|
||||
emblaApi.off("reInit", onSelect);
|
||||
};
|
||||
}, [emblaApi]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-full min-w-0 md:hidden">
|
||||
<div ref={emblaRef} className="w-full max-w-full overflow-hidden">
|
||||
<div className="flex touch-pan-y">
|
||||
{testimonials.map((item) => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="min-w-0 shrink-0 grow-0 basis-full"
|
||||
>
|
||||
<TestimonialCard item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CarouselDots
|
||||
count={testimonials.length}
|
||||
selected={selected}
|
||||
onSelect={(index) => emblaApi?.scrollTo(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user