Compare commits

...

10 Commits

Author SHA1 Message Date
84f49a75ae change style 2026-05-07 10:17:47 +03:30
fa8ea8d8a9 build and format 2026-02-27 11:17:48 +03:30
3c1a082df3 add app section 2026-02-27 11:16:28 +03:30
eddf95f609 build and format 2026-02-26 11:06:48 +03:30
761be9e66a salons section 2026-02-26 11:06:05 +03:30
aea8fb8b3d build and format 2026-02-26 10:42:38 +03:30
632304c271 reservation style 2026-02-26 10:41:35 +03:30
2d7b48b5b7 build and format 2026-02-17 21:36:14 +03:30
53484a07f8 add category section 2026-02-17 21:33:47 +03:30
f355fd9bb6 add carousel 2026-02-08 16:11:13 +03:30
36 changed files with 2170 additions and 23 deletions

View File

@@ -16,6 +16,34 @@
"main_title_line2": "سریع و مطمئن",
"contact_us": "تماس با ما"
},
"Categories": {
"title": "دسته بندی ها",
"description": "از بین صدها مرکز زیبایی، دقیقا همونی که میخای رو پیدا کن."
},
"Salons": {
"title": "سالن ها و کلینیک های زیبایی",
"description": "از بین صدها مرکز، دقیقا همونی که میخای رو پیدا کن."
},
"App": {
"title": "رزرو راحت تر با اپلیکیشن",
"description": "با اپلیکیشن، سریع تر و راحت تر نوبت بگیر.",
"download_app": "دانلود مستقیم",
"download_google_play": "دانلود از گوگل پلی"
},
"Reservation": {
"title": "رزرو خدمات زیبایی، ساده\u200Cتر از همیشه",
"stations": "مراکز معتبر",
"online_reserve": "رزرو انلاین سریع",
"start_reserve": "شروع رزرو",
"customer": "مشتریان",
"customer_number": "+100K",
"clinic": "کلینیک فعال",
"clinic_number": "+336",
"salon": "سالن زیبایی",
"salon_number": "+345",
"score": "امتیازدهی واقعی کاربران",
"description": "ما این پلتفرم را ساختیم تا بتوانید بهترین سالن\u200Cهای زیبایی و کلینیک\u200Cهای معتبر را به راحتی پیدا کنید.\n مقایسه، رزرو آنلاین و تجربه\u200Cای بی\u200Cدردسر."
},
"Application": {
"title": "دانلود نرم افزار تلفن همراه 141",
"description": "نسخه‌ی تلفن همراه اپلیکیشن مرکز مدیریت راه‌های کشور با قابلیت‌هایی نظیر نمایش تصاویر دوربین‌های، نظارتی در سراسر کشور، مسیریابی همراه با نمایش مسافت و زمان تقریبی و جزئیات مسیر، مشاهده اخبار متنی و شنیداری، کروکی ترافیک استان‌های کشور، دریافت اطلاعیه‌های مربوط به مسیرهای پرتردد و ... آماده دریافت از مارکت‌های مختلف می‌باشد.",

View File

@@ -24,7 +24,9 @@
"@tailwindcss/postcss": "^4.1.4",
"@vis.gl/react-maplibre": "^8.0.4",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.9.2",
"js-cookie": "^3.0.5",
"maplibre-gl": "^5.4.0",
@@ -35,6 +37,7 @@
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.8.16",
"postcss": "^8.5.3",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",

1100
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
<svg width="15" height="12" viewBox="0 0 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.06694 0.183058C6.31102 0.427136 6.31102 0.822864 6.06694 1.06694L2.13388 5H13.9583C14.3035 5 14.5833 5.27982 14.5833 5.625C14.5833 5.97018 14.3035 6.25 13.9583 6.25H2.13388L6.06694 10.1831C6.31102 10.4271 6.31102 10.8229 6.06694 11.0669C5.82286 11.311 5.42714 11.311 5.18306 11.0669L0.183058 6.06694C-0.0610194 5.82286 -0.0610194 5.42714 0.183058 5.18306L5.18306 0.183058C5.42714 -0.0610194 5.82286 -0.0610194 6.06694 0.183058Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.2254 4.55806C11.4695 4.31398 11.8652 4.31398 12.1093 4.55806L17.1093 9.55806C17.3533 9.80214 17.3533 10.1979 17.1093 10.4419L12.1093 15.4419C11.8652 15.686 11.4695 15.686 11.2254 15.4419C10.9813 15.1979 10.9813 14.8021 11.2254 14.5581L15.1584 10.625H3.33398C2.98881 10.625 2.70898 10.3452 2.70898 10C2.70898 9.65482 2.98881 9.375 3.33398 9.375H15.1584L11.2254 5.44194C10.9813 5.19786 10.9813 4.80214 11.2254 4.55806Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 600 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99935 1.04102C10.3445 1.04102 10.6243 1.32084 10.6243 1.66602V10.8098L12.0248 9.17594C12.2495 8.91386 12.644 8.88351 12.9061 9.10815C13.1682 9.33279 13.1985 9.72735 12.9739 9.98943L10.4739 12.9061C10.3551 13.0446 10.1818 13.1243 9.99935 13.1243C9.8169 13.1243 9.64355 13.0446 9.52481 12.9061L7.02481 9.98943C6.80018 9.72735 6.83053 9.33279 7.09261 9.10815C7.35468 8.88351 7.74925 8.91386 7.97389 9.17594L9.37435 10.8098V1.66602C9.37435 1.32084 9.65417 1.04102 9.99935 1.04102ZM5.8292 6.87599C6.17438 6.87407 6.45575 7.15233 6.45767 7.4975C6.4596 7.84267 6.18133 8.12405 5.83616 8.12597C4.92491 8.13104 4.27901 8.1547 3.7888 8.24475C3.31646 8.33151 3.04307 8.47083 2.84019 8.6737C2.60956 8.90434 2.45918 9.22815 2.37697 9.83963C2.29234 10.4691 2.29102 11.3033 2.29102 12.4995V13.3329C2.29102 14.529 2.29234 15.3633 2.37697 15.9928C2.45918 16.6042 2.60956 16.9281 2.84019 17.1587C3.07083 17.3893 3.39464 17.5397 4.00612 17.6219C4.63558 17.7065 5.46984 17.7079 6.66602 17.7079H13.3327C14.5289 17.7079 15.3631 17.7065 15.9926 17.6219C16.6041 17.5397 16.9279 17.3893 17.1585 17.1587C17.3891 16.9281 17.5395 16.6042 17.6217 15.9928C17.7064 15.3633 17.7077 14.529 17.7077 13.3329V12.4995C17.7077 11.3033 17.7064 10.4691 17.6217 9.83963C17.5395 9.22815 17.3891 8.90434 17.1585 8.6737C16.9556 8.47083 16.6822 8.33151 16.2099 8.24475C15.7197 8.15471 15.0738 8.13104 14.1625 8.12597C13.8174 8.12405 13.5391 7.84267 13.541 7.4975C13.5429 7.15233 13.8243 6.87407 14.1695 6.87599C15.0708 6.881 15.8219 6.90257 16.4357 7.01532C17.0674 7.13135 17.605 7.35238 18.0424 7.78982C18.544 8.29142 18.7597 8.92298 18.8606 9.67307C18.9577 10.3955 18.9577 11.3141 18.9577 12.4538V13.3786C18.9577 14.5182 18.9577 15.4368 18.8606 16.1593C18.7597 16.9094 18.544 17.541 18.0424 18.0426C17.5408 18.5442 16.9092 18.7599 16.1591 18.8608C15.4367 18.9579 14.5181 18.9579 13.3784 18.9579H6.62029C5.48063 18.9579 4.56203 18.9579 3.83956 18.8608C3.08947 18.7599 2.4579 18.5442 1.95631 18.0426C1.45471 17.541 1.23897 16.9094 1.13812 16.1593C1.04099 15.4368 1.041 14.5182 1.04102 13.3786V12.4538C1.041 11.3141 1.04099 10.3955 1.13812 9.67307C1.23897 8.92298 1.45471 8.29142 1.95631 7.78982C2.39375 7.35238 2.93131 7.13134 3.56298 7.01532C4.17678 6.90257 4.92793 6.881 5.8292 6.87599Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,32 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2324_35498)">
<path d="M12.823 6.10533L0.755237 0.0771082C0.603595 0.000152232 0.412819 0.00442757 0.266069 0.0813835C0.114426 0.158339 0.0214844 0.29515 0.0214844 0.449062C0.0214844 0.449062 0.0263761 1.00486 0.0312677 1.91978L8.82162 9.60255L12.823 6.10533Z" fill="url(#paint0_linear_2324_35498)"/>
<path d="M0.03125 1.91992C0.0459251 5.22048 0.0899502 13.2324 0.109517 17.2213L8.8265 9.60269L0.03125 1.91992Z" fill="url(#paint1_linear_2324_35498)"/>
<path d="M19.7298 9.55566L12.8227 6.10547L8.81641 9.60269L13.3363 13.5531L19.7346 10.2953C19.8863 10.2183 19.9792 10.0773 19.9792 9.92761C19.9792 9.7737 19.8814 9.63262 19.7298 9.55566Z" fill="url(#paint2_linear_2324_35498)"/>
<path d="M0.103516 17.2218C0.113299 18.6455 0.118191 19.5561 0.118191 19.5561C0.118191 19.71 0.211133 19.8511 0.362775 19.9238C0.514417 20.0008 0.700301 20.0008 0.851943 19.9238L13.3404 13.5578L8.82049 9.60742L0.103516 17.2218Z" fill="url(#paint3_linear_2324_35498)"/>
</g>
<defs>
<linearGradient id="paint0_linear_2324_35498" x1="0.0214844" y1="4.81392" x2="12.8253" y2="4.81392" gradientUnits="userSpaceOnUse">
<stop stop-color="#63BE6B"/>
<stop offset="0.506" stop-color="#5BBC6A"/>
<stop offset="1" stop-color="#4AB96A"/>
</linearGradient>
<linearGradient id="paint1_linear_2324_35498" x1="0.028902" y1="9.57247" x2="8.82141" y2="9.57247" gradientUnits="userSpaceOnUse">
<stop stop-color="#3EC6F2"/>
<stop offset="1" stop-color="#45AFE3"/>
</linearGradient>
<linearGradient id="paint2_linear_2324_35498" x1="8.8211" y1="9.83202" x2="19.9781" y2="9.83202" gradientUnits="userSpaceOnUse">
<stop stop-color="#FAA51A"/>
<stop offset="0.387" stop-color="#FAB716"/>
<stop offset="0.741" stop-color="#FAC412"/>
<stop offset="1" stop-color="#FAC80F"/>
</linearGradient>
<linearGradient id="paint3_linear_2324_35498" x1="0.105668" y1="14.7943" x2="13.3402" y2="14.7943" gradientUnits="userSpaceOnUse">
<stop stop-color="#EC3B50"/>
<stop offset="1" stop-color="#E7515B"/>
</linearGradient>
<clipPath id="clip0_2324_35498">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

12
public/icons/qrcode.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@@ -17,3 +17,8 @@ export { default as CalenderIcon } from "&/icons/calender.svg";
export { default as LocationIcon } from "&/icons/location.svg";
export { default as MarkerIcon } from "&/icons/marker-icon.svg";
export { default as MarkerPreviewIcon } from "&/icons/marker-preview.svg";
export { default as ArrowRight } from "&/icons/arrow-right.svg";
export { default as ArrowLeft } from "&/icons/arrow-left.svg";
export { default as DownloadIcon } from "&/icons/download.svg";
export { default as GooglePlayDownloadIcon } from "&/icons/google-play.svg";
export { default as QrcodeIcon } from "&/icons/qrcode.svg";

View File

@@ -18,7 +18,7 @@ export default function SearchBar() {
<button
type="button"
onClick={() => openModal(<LocationModal />)}
className="w-full text-right text-gray-500"
className="w-full text-right text-text-primary"
>
آدرس
</button>

View File

@@ -18,28 +18,28 @@ export default function BannerComponent() {
initial={{ opacity: 0, y: 60 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease: "easeOut" }}
className="bg-home-page-banner relative mx-16 mt-16 rounded-tr-[200px] rounded-bl-[200px] px-2 pt-2"
className="bg-home-page-banner relative mx-16 mt-16 md:rounded-tr-[100px] md:rounded-bl-[80px] lg:rounded-tr-[150px] lg:rounded-bl-[150px] px-2 pt-2"
>
{/* ================= Header ================= */}
<header className="flex w-full items-center justify-center pt-8">
<div className="flex w-full items-center justify-center space-x-5">
<header className="flex w-full items-center justify-center pt-8 px-5">
<div className="flex w-full items-center justify-start space-x-5">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200 }}
>
<LogoIcon className="size-12" />
<LogoIcon className="size-8 lg:size-10" />
</motion.div>
<ThemeToggle />
{["home", "search", "salons", "about_us", "contact_us"].map((item, index) => (
{["home", "salons", "about_us", "contact_us"].map((item, index) => (
<motion.p
key={item}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 * index }}
className="text-text-primary hover:text-primary-200 cursor-pointer transition"
className="text-text-primary text-center hover:text-primary-200 cursor-pointer transition text-xs lg:text-sm"
>
{t(item)}
</motion.p>
@@ -49,17 +49,17 @@ export default function BannerComponent() {
<div className="flex w-full items-center justify-center">
{isAuth ? (
<motion.div whileHover={{ scale: 1.1 }} className="cursor-pointer">
<AvatarIcon className="h-6 w-6" />
<AvatarIcon className="size-4 lg:size-5" />
</motion.div>
) : (
<span className="flex w-full items-center justify-center gap-x-2">
<span className="flex w-full items-center justify-end gap-x-2">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-bg flex items-center justify-center gap-x-2 rounded-lg p-3"
>
<MyOrderIcon className="h-6 w-6" />
<p>{t("my_order")}</p>
<MyOrderIcon className="size-5 lg:size-6" />
<p className="text-xs lg:text-base">{t("my_order")}</p>
</motion.button>
<motion.button
@@ -67,8 +67,8 @@ export default function BannerComponent() {
whileTap={{ scale: 0.95 }}
className="bg-primary-200 flex items-center justify-center gap-x-2 rounded-lg p-3"
>
<AvatarIcon className="h-6 w-6" />
<p>{t("auth")}</p>
<AvatarIcon className="size-4 lg:size-5" />
<p className="text-xs lg:text-base">{t("auth")}</p>
</motion.button>
</span>
)}
@@ -80,7 +80,7 @@ export default function BannerComponent() {
{/* ===== Left Content ===== */}
<div className="flex w-full flex-col items-center justify-center space-y-7">
{/* Title */}
<motion.div className="text-4xl font-bold">
<motion.div className="text-2xl lg:text-4xl font-bold">
<motion.span
variants={{
hidden: { opacity: 0, y: 20 },
@@ -124,7 +124,7 @@ export default function BannerComponent() {
hidden: { opacity: 0 },
visible: { opacity: 1 },
}}
className="text-center text-xs"
className="text-center px-10 text-xs"
>
{t("des")}
</motion.p>
@@ -147,10 +147,17 @@ export default function BannerComponent() {
{/* ===== Right Image ===== */}
<motion.div
animate={{ y: [0, -12, 0] }}
transition={{ repeat: Infinity, duration: 4, ease: "easeInOut" }}
className="flex w-full items-center justify-center"
// animate={{ y: [0, -12, 0] }}
// transition={{ repeat: Infinity, duration: 4, ease: "easeInOut" }}
className="flex relative w-full items-center justify-center"
>
<motion.div
className="w-50 sm:w-52 md:w-78 lg:w-90 absolute -bottom-50 left-10"
initial={{ scale: 0.9, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.6 }}
viewport={{ once: true }}
>
<Image
src={BannerImage}
alt="main_image"
@@ -159,9 +166,10 @@ export default function BannerComponent() {
height={200}
priority
/>
</motion.div>
</motion.div>
</motion.main>
<div className="absolute -bottom-10 left-24">
<div className="absolute -bottom-10 left-10 lg:left-24">
<SearchBar />
</div>
</motion.div>

View File

@@ -0,0 +1,123 @@
"use client";
import { Card, CardContent } from "@/components/UI/card";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/UI/Carousel";
import { categories } from "@/data/mockCategories";
import Image from "next/image";
import { easeOut, motion } from "framer-motion";
type Props = {
title: string;
description: string;
};
const containerVariants = {
hidden: {},
show: {
transition: {
staggerChildren: 0.08,
},
},
};
const itemVariants = {
hidden: {
opacity: 0,
y: 30,
scale: 0.95,
},
show: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.4,
ease: easeOut,
},
},
};
export function CarouselComponent({ title, description }: Props) {
return (
<div className="relative w-full">
<Carousel dir="rtl" className="w-full" opts={{ direction: "rtl" }}>
{/* 🔥 Animated Header */}
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6 }}
className="my-5 flex h-full w-full items-center justify-between"
>
<span className="flex items-center justify-center gap-x-2">
<motion.div
initial={{ height: 0 }}
animate={{ height: 64 }}
transition={{ duration: 0.5 }}
className="bg-primary-100 w-4 rounded-2xl rounded-tr-none"
/>
<div>
<p className="text-lg font-semibold">{title}</p>
<p className="text-muted-foreground">{description}</p>
</div>
</span>
</motion.div>
{/* 🔥 Animated Carousel Items */}
<motion.div variants={containerVariants} initial="hidden" animate="show">
<CarouselContent>
{categories.map((category) => (
<CarouselItem
key={category.id}
className="after:bg-border relative after:absolute after:top-1/2 after:left-2 after:h-[50%] after:w-px after:-translate-y-1/2 after:content-[''] last:after:hidden md:basis-1/3 lg:basis-1/6"
>
<motion.div variants={itemVariants} className="p-1">
<motion.div
whileHover={{
scale: 1.05,
y: -5,
}}
transition={{ type: "spring", stiffness: 300 }}
>
<Card dir="rtl" className="cursor-pointer transition-shadow hover:shadow-xl">
<CardContent className="flex h-30 flex-col items-center justify-center gap-3 p-6">
<motion.div
whileHover={{ rotate: 5 }}
transition={{ type: "spring", stiffness: 200 }}
>
<Image
src={category.image}
alt={category.title}
width={80}
height={80}
className="rounded-full"
/>
</motion.div>
<p className="text-text-muted font-semibold">{category.title}</p>
</CardContent>
</Card>
</motion.div>
</motion.div>
</CarouselItem>
))}
</CarouselContent>
</motion.div>
{/* 🔥 Animated Controls */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="absolute top-10 left-16 flex gap-2"
>
<motion.div whileTap={{ scale: 0.85 }}>
<CarouselPrevious />
</motion.div>
<motion.div whileTap={{ scale: 0.85 }}>
<CarouselNext />
</motion.div>
</motion.div>
</Carousel>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { CarouselComponent } from "./CarouselComponent";
import { useTranslations } from "next-intl";
const Categories = () => {
const t = useTranslations("Categories");
return (
<div className="container mx-auto my-16">
<CarouselComponent title={t("title")} description={t("description")} />
</div>
);
};
export default Categories;

View File

@@ -0,0 +1,95 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { ArrowTopLeft } from "@/assets";
import Image from "next/image";
export default function Reservation() {
const t = useTranslations("Reservation");
return (
<section className="my-32 w-full lg:my-40">
<div className="container mx-auto px-6">
<motion.div
initial={{ opacity: 0, y: 80 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, ease: "easeOut" }}
className="bg-primary-50 relative grid grid-cols-2 items-center gap-16 rounded-tr-[60px] rounded-bl-[60px] p-8 md:rounded-tr-[80px] md:rounded-bl-[80px] md:px-12"
>
{/* ================= TEXT CONTENT ================= */}
<motion.div
initial={{ opacity: 0, x: -40 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.3, duration: 0.7 }}
className="order-1 w-full space-y-8 text-white lg:px-10"
>
<h2 className="text-xl leading-relaxed font-bold md:text-2xl lg:text-3xl">{t("title")}</h2>
<p className="text-primary-100 max-w-xl text-sm leading-8 md:text-base">{t("description")}</p>
{/* Features */}
<div className="flex flex-wrap items-center gap-x-8 gap-y-4 pt-2 text-sm">
{[t("stations"), t("online_reserve"), t("score")].map((item, i) => (
<div key={i} className="flex items-center gap-2">
<span className="bg-primary-200 h-3 w-3 rounded-full" />
<span className="text-xs md:text-sm">{item}</span>
</div>
))}
</div>
{/* Button */}
<motion.button
whileHover={{ scale: 1.07 }}
whileTap={{ scale: 0.95 }}
className="text-primary-100 mt-4 flex w-fit items-center gap-2 rounded-xl bg-white px-6 py-3 font-medium shadow-lg"
>
{t("start_reserve")}
<ArrowTopLeft className="size-5" />
</motion.button>
</motion.div>
{/* ================= IMAGE + STATS ================= */}
<motion.div
initial={{ opacity: 0, x: 60 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.4, duration: 0.9 }}
className="relative order-2 flex flex-col items-center"
>
{/* Image */}
<div className="relative h-[320px] w-[260px] overflow-hidden rounded-full border-8 border-white shadow-2xl md:h-[420px] md:w-[320px] lg:h-[580px] lg:w-[450px]">
<Image
src="/images/reservation/reservation.jpg"
alt="reservation"
fill
className="object-cover"
/>
</div>
{/* Stats Card */}
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.6 }}
className="bg-card mt-8 flex w-full max-w-md items-center justify-between gap-6 rounded-[40px] px-6 py-6 shadow-2xl md:rounded-[60px] lg:absolute lg:-bottom-16"
>
{[
{ number: t("customer_number"), label: t("customer") },
{ number: t("clinic_number"), label: t("clinic") },
{ number: t("salon_number"), label: t("salon") },
].map((item, i) => (
<div key={i} className="flex-1 text-center">
<p className="text-text-primary text-lg font-bold md:text-xl">{item.number}</p>
<p className="text-text-muted mt-1 text-xs md:text-sm">{item.label}</p>
</div>
))}
</motion.div>
</motion.div>
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,124 @@
"use client";
import { Card, CardContent } from "@/components/UI/card";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/UI/Carousel";
import { salons } from "@/data/mockCategories";
import Image from "next/image";
import { easeOut, motion } from "framer-motion";
type Props = {
title: string;
description: string;
};
const containerVariants = {
hidden: {},
show: {
transition: {
staggerChildren: 0.08,
},
},
};
const itemVariants = {
hidden: {
opacity: 0,
y: 30,
scale: 0.95,
},
show: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.4,
ease: easeOut,
},
},
};
export function CarouselComponent({ title, description }: Props) {
return (
<div className="relative w-full">
<Carousel dir="rtl" className="w-full" opts={{ direction: "rtl" }}>
{/* 🔥 Animated Header */}
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6 }}
className="my-5 flex h-full w-full items-center justify-between"
>
<span className="flex items-center justify-center gap-x-2">
<motion.div
initial={{ height: 0 }}
animate={{ height: 64 }}
transition={{ duration: 0.5 }}
className="bg-primary-100 w-4 rounded-2xl rounded-tr-none"
/>
<div>
<p className="text-lg font-semibold">{title}</p>
<p className="text-muted-foreground">{description}</p>
</div>
</span>
</motion.div>
{/* 🔥 Animated Carousel Items */}
<motion.div variants={containerVariants} initial="hidden" animate="show">
<CarouselContent>
{salons.map((category) => (
<CarouselItem
key={category.id}
className="after:bg-border relative after:absolute after:top-1/2 after:left-2 after:h-[60%] after:w-px after:-translate-y-1/2 after:content-[''] last:after:hidden md:basis-1/3 lg:basis-1/5"
>
<motion.div variants={itemVariants} className="p-1">
<motion.div
whileHover={{
scale: 1.05,
y: -5,
}}
transition={{ type: "spring", stiffness: 300 }}
>
<Card dir="rtl" className="cursor-pointer transition-shadow hover:shadow-xl">
<CardContent className="flex h-30 flex-col items-center justify-center gap-3">
<motion.div
whileHover={{ rotate: 5 }}
transition={{ type: "spring", stiffness: 200 }}
className="h-full"
>
<Image
src={category.image}
alt={category.salon_name}
width={200}
height={500}
className="rounded-xl"
/>
</motion.div>
<p className="text-text-muted font-semibold">{category.salon_name}</p>
</CardContent>
</Card>
</motion.div>
</motion.div>
</CarouselItem>
))}
</CarouselContent>
</motion.div>
{/* 🔥 Animated Controls */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="absolute top-10 left-16 flex gap-2"
>
<motion.div whileTap={{ scale: 0.85 }}>
<CarouselPrevious />
</motion.div>
<motion.div whileTap={{ scale: 0.85 }}>
<CarouselNext />
</motion.div>
</motion.div>
</Carousel>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { CarouselComponent } from "./CarouselComponent";
import { useTranslations } from "next-intl";
const Salons = () => {
const t = useTranslations("Salons");
return (
<div className="container mx-auto my-16">
<CarouselComponent title={t("title")} description={t("description")} />
</div>
);
};
export default Salons;

View File

@@ -0,0 +1,102 @@
import { useTranslations } from "next-intl";
import { motion } from "framer-motion";
import { DownloadIcon, GooglePlayDownloadIcon } from "@/assets";
import Image from "next/image";
export default function App() {
const t = useTranslations("App");
return (
<div className="bg-primary-500 relative container mx-auto mt-32 mb-16 w-full rounded-tl-[100px] rounded-br-[100px] p-8">
<section className="grid grid-cols-2 gap-5">
<div className="flex w-full items-center justify-center gap-2">
<section className="flex w-full flex-col items-center justify-center gap-2">
<p className="text-text-primary text-lg font-semibold lg:text-2xl">{t("title")}</p>
<p className="text-text-muted text-xs lg:text-sm">{t("description")}</p>
<span className="mt-4 flex w-full items-center justify-center gap-x-2">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-primary-200 flex items-center justify-center gap-x-2 rounded-lg p-3"
>
<DownloadIcon className="size-4 text-white lg:size-6" />
<p className="text-xs text-white lg:text-base">{t("download_app")}</p>
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-bg flex items-center justify-center gap-x-2 rounded-lg p-3"
>
<GooglePlayDownloadIcon className="size-4 lg:size-6" />
<p className="text-text-primary text-xs lg:text-base">{t("download_google_play")}</p>
</motion.button>
</span>
</section>
<div className="md:hidden lg:block">
<Image
src={"/images/Qrcode-sample.jpg"}
alt={"Qrcode sample..."}
width={200}
height={200}
className="rounded-3xl"
/>
</div>
</div>
<div className="absolute bottom-0 left-10 w-fit lg:left-20">
<motion.div
className="w-50 sm:w-52 md:w-80 lg:w-90"
initial={{ scale: 0.9, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.6 }}
viewport={{ once: true }}
>
<Image
src={"/images/app-section/ellipse.jpg"}
alt={"app ellipse..."}
width={500}
height={200}
className="rounded-t-full object-cover"
/>
</motion.div>
<motion.div
className="absolute -top-40 left-20 flex"
initial={{ opacity: 0, x: -100 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2, duration: 0.8, ease: "easeOut" }}
viewport={{ once: true }}
>
<motion.div
className="w-50 sm:w-52 md:w-60 lg:w-80"
initial={{ scale: 0.9, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.6 }}
viewport={{ once: true }}
>
<Image
src={"/images/app-section/mobile1.png"}
alt="گوشی سفید"
width={500}
height={500}
className="h-auto w-full"
/>
</motion.div>
<motion.div
className="absolute top-5 -left-30 z-10 w-50 sm:w-52 md:w-90 lg:w-110"
initial={{ scale: 0.9, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.6 }}
viewport={{ once: true }}
>
<Image
src={"/images/app-section/mobile2.png"}
alt="گوشی مشکی"
width={500}
height={500}
className="h-auto w-full"
/>
</motion.div>
</motion.div>
</div>
</section>
</div>
);
}

View File

@@ -1,9 +1,17 @@
import BannerComponent from "./Banner";
import Categories from "./Categories";
import Reservation from "./Reservation";
import Salons from "./Salons";
import App from "@/components/Home/Desktop/app";
export default function DesktopHome() {
return (
<div className="container mx-auto h-full w-full">
<BannerComponent />
<Categories />
<Reservation />
<Salons />
<App />
</div>
);
}

View File

@@ -1,3 +1,4 @@
"use client";
import { MarkerIcon, MarkerPreviewIcon } from "@/assets";
import { useMap } from "@vis.gl/react-maplibre";
import maplibregl from "maplibre-gl";
@@ -8,7 +9,7 @@ const createMarkerElement = (preview: boolean) => {
const el = document.createElement("div");
const Icon = preview ? MarkerPreviewIcon : MarkerIcon;
el.innerHTML = renderToStaticMarkup(<Icon className="text-neo-aqua size-10" />);
el.innerHTML = renderToStaticMarkup(<Icon className="text-primary-100 size-10" />);
return el;
};
@@ -19,7 +20,7 @@ type Props = {
};
const MarkerPosition = ({ preview = false, onChange }: Props) => {
const { homePageMap } = useMap();
const { current: homePageMap } = useMap();
const markerRef = useRef<maplibregl.Marker | null>(null);
useEffect(() => {
@@ -49,10 +50,10 @@ const MarkerPosition = ({ preview = false, onChange }: Props) => {
});
};
map.on("moveend", onMove);
map.on("move", onMove);
return () => {
map.off("moveend", onMove);
map.off("move", onMove);
markerRef.current?.remove();
markerRef.current = null;
};

View File

@@ -0,0 +1,227 @@
"use client";
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 { ArrowLeft, ArrowRight, ChevronIcon } from "@/assets";
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="border-text-primary/10 overflow-hidden rounded-2xl border p-1"
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(
"bg-card border-primary-300 absolute touch-manipulation rounded-xl border p-5",
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}
>
<ArrowLeft className="text-text-primary" />
<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(
"bg-card border-primary-300 absolute touch-manipulation rounded-xl p-5",
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}
>
<ArrowRight className="text-text-primary size-5" />
<span className="sr-only">Next slide</span>
</Button>
);
}
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel };

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive:
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,73 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, size = "default", ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"ring-foreground/10 text-card-foreground group/card flex flex-col gap-4 overflow-hidden rounded-xl py-4 text-sm has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-4 group-data-[size=sm]/card:px-3", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"bg-muted/50 flex items-center rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

103
src/data/mockCategories.ts Normal file
View File

@@ -0,0 +1,103 @@
export const categories = [
{
id: 1,
title: "خدمات مو",
image: "/images/categories/مو.jpg",
},
{
id: 2,
title: "خدمات میکاپ",
image: "/images/categories/میکاپ.jpg",
},
{
id: 3,
title: "خدمات ناخن",
image: "/images/categories/ناخن.jpg",
},
{
id: 4,
title: "خدمات فیشیال",
image: "/images/categories/فیشیال.jpg",
},
{
id: 5,
title: "خدمات مژه و ابرو",
image: "/images/categories/مژه.jpg",
},
{
id: 7,
title: "ژل و بوتاکس",
image: "/images/categories/ژل.jpg",
},
{
id: 8,
title: "ژل و بوتاکس",
image: "/images/categories/ژل.jpg",
},
{
id: 9,
title: "ژل و بوتاکس",
image: "/images/categories/ژل.jpg",
},
{
id: 10,
title: "ژل و بوتاکس",
image: "/images/categories/ژل.jpg",
},
];
export const salons = [
{
id: 1,
salon_name: "سالن همارو",
rate: "4",
image: "/images/salons/salon.jpg",
},
{
id: 2,
salon_name: "سالن بیوتی",
rate: "4",
image: "/images/salons/salon.jpg",
},
{
id: 3,
rate: "4",
salon_name: "سالن عروس",
image: "/images/salons/salon.jpg",
},
{
id: 4,
rate: "4",
salon_name: "سالن فیشیال",
image: "/images/salons/salon.jpg",
},
{
id: 5,
rate: "4",
salon_name: "سالن زیبایی",
image: "/images/salons/salon.jpg",
},
{
id: 7,
rate: "4",
salon_name: "سالن همارو",
image: "/images/salons/salon.jpg",
},
{
id: 8,
rate: "4",
salon_name: "سالن عروس",
image: "/images/salons/salon.jpg",
},
{
id: 9,
rate: "4",
salon_name: "سالن بیوتی",
image: "/images/salons/salon.jpg",
},
{
id: 10,
rate: "4",
salon_name: "سالن بیوتی",
image: "/images/salons/salon.jpg",
},
];

View File

@@ -131,6 +131,7 @@ body {
/* =====================
Primary (Purple)
====================== */
--color-primary-50: #140b4e;
--color-primary-100: #7d34b9;
--color-primary-200: #9a5cff;
--color-primary-300: #b38bff;
@@ -191,6 +192,7 @@ body {
--color-text-muted: #9ca3af;
--color-primary-100: #b38bff;
--color-primary-50: #140b4e;
--color-primary-200: #9a5cff;
--color-primary-300: #7d34b9;