feat: landing & contact

This commit is contained in:
2026-06-07 18:28:58 +03:30
parent 2db6a63e38
commit 1988480ff6
31 changed files with 1319 additions and 10 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

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

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