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

@@ -18,6 +18,7 @@
"astro": "^6.4.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.17.0",
"radix-ui": "^1.5.0",
"react": "^19.2.7",

28
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.7)
lucide-react:
specifier: ^1.17.0
version: 1.17.0(react@19.2.7)
@@ -2078,6 +2081,19 @@ packages:
electron-to-chromium@1.5.368:
resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==}
embla-carousel-react@8.6.0:
resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==}
peerDependencies:
react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
embla-carousel-reactive-utils@8.6.0:
resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==}
peerDependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -5800,6 +5816,18 @@ snapshots:
electron-to-chromium@1.5.368: {}
embla-carousel-react@8.6.0(react@19.2.7):
dependencies:
embla-carousel: 8.6.0
embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0)
react: 19.2.7
embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0):
dependencies:
embla-carousel: 8.6.0
embla-carousel@8.6.0: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}

BIN
src/assets/cta-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

BIN
src/assets/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

View File

@@ -0,0 +1,67 @@
---
import { buttonVariants } from "@/components/ui/button";
import { LucideIcon } from "@/components/ui/lucide-icon";
import { cn } from "@/lib/utils";
---
<form
id="contact-form"
class="glass-surface p-6 md:p-8"
method="post"
action="#"
>
<div class="space-y-5">
<div class="space-y-3">
<label for="name" class="text-sm font-medium text-foreground">نام</label>
<input
id="name"
name="name"
type="text"
placeholder="نام شما"
class="glass-input h-11 mt-2 rounded-full px-4"
required
/>
</div>
<div class="space-y-3">
<label for="email" class="text-sm font-medium text-foreground m"
>ایمیل</label
>
<input
id="email"
name="email"
type="email"
placeholder="example@gmail.com"
class="glass-input mt-2 h-11 rounded-full px-4"
dir="ltr"
required
/>
</div>
<div class="space-y-3">
<label for="message" class="text-sm font-medium text-foreground"
>متن پیام</label
>
<textarea
id="message"
name="message"
rows="5"
placeholder="پیام خود را تایپ کنید..."
class={cn(
"glass-input h-auto min-h-32 resize-y rounded-2xl px-4 mt-2 py-3",
)}
required></textarea>
</div>
<button
type="submit"
class={cn(
buttonVariants({ size: "lg" }),
"h-11 w-full rounded-xl text-base [&_svg]:ms-1",
)}
>
ارسال پیام
<LucideIcon name="arrowLeft" className="size-4" />
</button>
</div>
</form>

View File

@@ -0,0 +1,30 @@
---
import ActionLink from "@/components/landing/ActionLink.astro";
import { LucideIcon } from "@/components/ui/lucide-icon";
---
<div
class="glass-surface flex flex-col gap-6 p-6 md:flex-row md:items-center md:justify-between md:p-8"
>
<div class="flex items-center gap-4">
<span class="contact-icon size-12 rounded-xl">
<LucideIcon name="headset" className="size-6" />
</span>
<div>
<p class="font-semibold text-foreground">نیاز به راهنمایی سریع دارید؟</p>
<p class="mt-1 text-sm text-muted-foreground">
تیم پشتیبانی ما آماده کمک به شماست.
</p>
</div>
</div>
<ActionLink
href="#contact-form"
variant="outline"
size="lg"
class="shrink-0 rounded-lg px-6 [&_svg]:ms-1"
>
ارسال پیام به پشتیبانی
<LucideIcon name="arrowLeft" className="size-4" />
</ActionLink>
</div>

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

View File

@@ -11,7 +11,7 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"border-primary bg-transparent text-primary hover:border-primary hover:bg-primary/80 hover:text-primary-foreground aria-expanded:border-primary aria-expanded:bg-primary aria-expanded:text-primary-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:

View File

@@ -0,0 +1,240 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,64 @@
import {
ArrowLeft,
BarChart3,
Bell,
BicepsFlexed,
Building2,
Check,
Cloud,
Dumbbell,
FileText,
Headset,
Leaf,
Mail,
MapPin,
Phone,
SportShoe,
Star,
ThumbsUp,
TrendingUp,
UserCircle,
Users,
Wallet,
type LucideIcon as LucideIconComponent,
} from "lucide-react"
import { cn } from "@/lib/utils"
export const landingIcons = {
arrowLeft: ArrowLeft,
mail: Mail,
phone: Phone,
mapPin: MapPin,
headset: Headset,
users: Users,
userCircle: UserCircle,
coach: Dumbbell,
biceps: BicepsFlexed,
sportShoe: SportShoe,
dumbbell: Dumbbell,
trendingUp: TrendingUp,
chart: BarChart3,
fileText: FileText,
leaf: Leaf,
wallet: Wallet,
bell: Bell,
cloud: Cloud,
building: Building2,
thumbsUp: ThumbsUp,
star: Star,
check: Check,
} as const satisfies Record<string, LucideIconComponent>
export type LandingIconName = keyof typeof landingIcons
type LucideIconProps = {
name: LandingIconName
className?: string
}
export function LucideIcon({ name, className }: LucideIconProps) {
const Icon = landingIcons[name] ?? Users
return <Icon className={cn("size-6 shrink-0", className)} aria-hidden />
}

View File

@@ -32,7 +32,7 @@ const pathname = Astro.url.pathname;
<Link
href="/plans"
className="hidden shrink-0 rounded-full px-6 md:inline-flex lg:px-8"
className="hidden shrink-0 rounded-full px-6 py-5 md:inline-flex lg:px-8"
>
شروع کنید
</Link>

View File

@@ -11,7 +11,7 @@ export function HeaderNav({ pathname = "/", className }: HeaderNavProps) {
const activeTab = getActiveNavTab(pathname)
return (
<Tabs value={activeTab} className={cn("gap-0", className)}>
<Tabs value={activeTab ?? ""} className={cn("gap-0", className)}>
<TabsList
variant="line"
className="h-auto gap-3 bg-transparent p-0 pb-1 sm:gap-5 md:gap-8"

View File

@@ -14,7 +14,7 @@ const { title = "نکستا" } = Astro.props;
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/font/font.css" />

19
src/lib/contact-data.ts Normal file
View File

@@ -0,0 +1,19 @@
export const contactDetails = [
{
icon: "mail" as const,
label: "ایمیل ما",
value: "hello@Nxta.com",
href: "mailto:hello@Nxta.com",
},
{
icon: "phone" as const,
label: "شماره تماس ما",
value: "09123456789",
href: "tel:09123456789",
},
{
icon: "mapPin" as const,
label: "آدرس ما",
value: "استان، شهر، کوچه، پلاک، واحد",
},
] as const

112
src/lib/landing-data.ts Normal file
View File

@@ -0,0 +1,112 @@
export const stats = [
{ value: "+۵۰", label: "مربی", icon: "biceps" },
{ value: "+۱۰۰", label: "ورزشکار", icon: "sportShoe" },
{ value: "+۵۰", label: "باشگاه فعال", icon: "dumbbell" },
{ value: "۹۸٪", label: "رضایت کاربران", icon: "star" },
] as const;
export const features = [
{
title: "مدیریت اعضا",
description: "مدیریت اطلاعات اعضا، پروفایل‌ها و روند پیشرفت به سادگی.",
icon: "users",
},
{
title: "پنل مربیان",
description: "اختصاص برنامه تمرینی و غذایی تنها در چند ثانیه.",
icon: "userCircle",
},
{
title: "پیگیری پیشرفت",
description: "بررسی فعالیت‌ها، اهداف و عملکرد اعضا به صورت کامل.",
icon: "trendingUp",
},
{
title: "مدیریت اشتراک‌ها",
description: "مدیریت پلن‌ها، تمدیدها و پرداخت‌ها بدون دردسر.",
icon: "fileText",
},
{
title: "برنامه‌های غذایی",
description: "ساخت و اختصاص برنامه‌های تغذیه حرفه‌ای و شخصی‌سازی شده.",
icon: "leaf",
},
{
title: "اعلان‌های هوشمند",
description: "اطلاع‌رسانی خودکار به مربیان و اعضا.",
icon: "bell",
},
] as const;
export const steps = [
{
number: "۱",
title: "باشگاه خود را ایجاد کنید",
description: "پروفایل باشگاه را راه‌اندازی کرده و مربیان را دعوت کنید.",
},
{
number: "۲",
title: "اعضا را مدیریت کنید",
description: "اعضا را اضافه کرده و برای آن‌ها برنامه اختصاصی تعریف کنید.",
},
{
number: "۳",
title: "باشگاه خود را توسعه دهید",
description: "فعالیت‌ها، پیشرفت اعضا و عملکرد باشگاه را بررسی کنید.",
},
] as const;
export const plans = [
{
name: "پلن حرفه‌ای",
price: "2 میلیون تومان",
period: "ماهیانه",
popular: false,
memberLimit: "تا 5 مربی و 50 ورزشکار",
extras: ["دسترسی به حرکات عمومی", "دسترسی به وعده‌های عمومی"],
description:
"مناسب برای باشگاه‌های بزرگ که به دسترسی کامل و انعطاف‌پذیری بالا نیاز دارند.",
},
{
name: "پلن استاندارد",
price: "2 میلیون تومان",
period: "ماهیانه",
popular: true,
memberLimit: "تا 5 مربی و 50 ورزشکار",
extras: ["دسترسی به حرکات عمومی"],
description: "ایده‌آل برای باشگاه‌های در حال رشد با اعضای فعال.",
},
{
name: "پلن ساده",
price: "2 میلیون تومان",
period: "ماهیانه",
popular: false,
memberLimit: "تا 5 مربی و 50 ورزشکار",
extras: [],
description: "بهترین انتخاب برای باشگاه‌های تازه‌به‌راه‌افتاده.",
},
] as const;
export const testimonials = [
{
name: "امیر حسینی",
role: "مدیر باشگاه فیت‌پلاس",
quote:
"از وقتی نکستا را استفاده می‌کنیم، مدیریت اعضا و مالی یک‌سوم زمان قبلی طول می‌کشد. واقعاً ساده و کاربردی است.",
rating: 5,
},
{
name: "سارا محمدی",
role: "مربی بدنسازی",
quote:
"پنل مربی عالیه. برنامه تمرینی و پیشرفت شاگردها را یکجا می‌بینم و دیگر نیازی به دفتر و اکسل نیست.",
rating: 5,
},
{
name: "رضا کریمی",
role: "مالک باشگاه آیرون",
quote:
"گزارش‌های مالی و آماری دقیقاً همان چیزی بود که نیاز داشتم. تیم پشتیبانی هم سریع و حرفه‌ای پاسخ می‌دهند.",
rating: 5,
},
] as const;

View File

@@ -18,10 +18,12 @@ export const socialLinks = [
export type HeaderNavValue = (typeof headerNavLinks)[number]["value"]
export function getActiveNavTab(pathname: string): HeaderNavValue {
export function getActiveNavTab(pathname: string): HeaderNavValue | undefined {
const path = pathname.replace(/\/$/, "") || "/"
if (path === "/" || path === "") return undefined
if (path === "/blog" || path.startsWith("/blog/")) return "blog"
if (path === "/contact" || path.startsWith("/contact/")) return "contact"
return "plans"
if (path === "/plans" || path.startsWith("/plans/")) return "plans"
return undefined
}

View File

@@ -1,9 +1,85 @@
---
import Layout from "../layouts/Layout.astro";
import Layout from "@/layouts/Layout.astro";
import ContactForm from "@/components/contact/ContactForm.astro";
import SupportBanner from "@/components/contact/SupportBanner.astro";
import { LucideIcon } from "@/components/ui/lucide-icon";
import { contactDetails } from "@/lib/contact-data";
---
<Layout title="تماس با ما | نکستا">
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
<h1 class="text-3xl font-bold text-foreground">تماس با ما</h1>
<section
class="relative overflow-hidden bg-background px-4 py-16 md:px-6 md:py-20 lg:px-8"
>
<div
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(50,217,203,0.08),transparent_65%)]"
aria-hidden="true"
>
</div>
<div class="relative mx-auto max-w-7xl">
<div class="grid gap-10 lg:grid-cols-2 lg:gap-16">
<div class="space-y-8">
<h1 class="text-3xl font-bold text-foreground lg:hidden">
با ما در <span class="text-primary">ارتباط</span> باشید
</h1>
<div class="hidden space-y-4 lg:block">
<h1 class="text-3xl font-bold text-foreground md:text-4xl">
در کنار <span class="text-primary">شما</span> هستیم
</h1>
<p
class="max-w-lg text-sm leading-relaxed text-muted-foreground md:text-base"
>
هر سوال یا درخواستی دارید، تیم ما آماده پاسخگویی است. فرم را
پر کنید یا از راه‌های ارتباطی زیر با ما در تماس باشید.
</p>
</div>
<div class="glass-surface space-y-4 p-6">
{
contactDetails.map((item) => (
<div class="flex items-center gap-4">
<span class="contact-icon size-11">
<LucideIcon name={item.icon} className="size-5" />
</span>
<div class="min-w-0">
<p class="text-sm text-muted-foreground">{item.label}</p>
{item.href ? (
<a
href={item.href}
class="mt-0.5 block font-medium text-foreground transition-colors hover:text-primary"
dir={item.icon === "phone" ? "ltr" : undefined}
>
{item.value}
</a>
) : (
<p class="mt-0.5 font-medium text-foreground">
{item.value}
</p>
)}
</div>
</div>
))
}
</div>
<div class="space-y-4 lg:hidden">
<h2 class="text-3xl font-bold text-foreground">
در کنار <span class="text-primary">شما</span> هستیم
</h2>
<p class="text-sm leading-relaxed text-muted-foreground">
برای دریافت راهنمایی، پشتیبانی یا اطلاعات بیشتر، فرم را تکمیل
کنید تا با شما در ارتباط باشیم.
</p>
</div>
</div>
<ContactForm />
</div>
<div class="mt-12 md:mt-16">
<SupportBanner />
</div>
</div>
</section>
</Layout>

View File

@@ -1,3 +1,20 @@
---
return Astro.redirect("/plans");
import Layout from "../layouts/Layout.astro";
import HeroSection from "@/components/landing/HeroSection.astro";
import StatsSection from "@/components/landing/StatsSection.astro";
import FeaturesSection from "@/components/landing/FeaturesSection.astro";
import StepsSection from "@/components/landing/StepsSection.astro";
import PricingSection from "@/components/landing/PricingSection.astro";
import TestimonialsSection from "@/components/landing/TestimonialsSection.astro";
import CtaSection from "@/components/landing/CtaSection.astro";
---
<Layout title="نکستا | مدیریت هوشمند باشگاه">
<HeroSection />
<StatsSection />
<FeaturesSection />
<StepsSection />
<PricingSection />
<TestimonialsSection />
<CtaSection />
</Layout>

View File

@@ -151,6 +151,29 @@
display: grid;
grid-template-rows: auto 1fr auto;
font-family: "iransansxv", sans-serif;
overflow-x: clip;
}
main {
min-width: 0;
overflow-x: clip;
}
}
@layer components {
.glass-surface {
@apply rounded-2xl border border-white/5 bg-card/15 backdrop-blur-xl;
}
.glass-input {
@apply w-full border-0 text-sm text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary/20;
background: rgb(239 238 234 / 0.06);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.contact-icon {
@apply flex shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary;
}
}