Initial commit: Ali Alemi storefront (Next.js, Meshkee API integration)
Farsi RTL Next.js 16 storefront for استاد علی عالمی, cloned from the original Meshkee web-builder site and rebuilt with Tailwind 4, live Meshkee API integration (products, store items, portfolios, blog, workshops, comments, cart, customer auth), and standalone production output for the shared-VM deploy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { CONTACT, NAV_LINKS, SITE_NAME } from "@/lib/site";
|
||||
import { IconInstagram, IconLocation, IconPhone, IconTelegram, IconWhatsapp } from "./icons";
|
||||
|
||||
const BIO_LINES = [
|
||||
"متولد ۱۳۶۶/۱۲/۲۲",
|
||||
"مهندس مکانیک دانشگاه علموصنعت",
|
||||
"کارشناس ارشد هوافضا گرایش سازههای هوایی از دانشگاه علموصنعت",
|
||||
"رتبهی ۲۴۳ کنکور سراسری",
|
||||
"۱۳ سال سابقهی تدریس دروس ریاضی و حسابان کنکور",
|
||||
"همکاری با برترین آموزشگاههای کنکور بهصورت حضوری و آنلاین",
|
||||
];
|
||||
|
||||
const SOCIALS = [
|
||||
{ icon: IconWhatsapp, href: CONTACT.whatsapp, label: "واتساپ" },
|
||||
{ icon: IconInstagram, href: CONTACT.instagram, label: "اینستاگرام" },
|
||||
{ icon: IconTelegram, href: CONTACT.telegram, label: "تلگرام" },
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-line bg-white">
|
||||
<div className="boxed grid gap-12 py-16 text-center lg:grid-cols-[1.3fr_1fr_1fr] lg:text-right">
|
||||
<div className="mx-auto lg:mx-0">
|
||||
<Image src="/images/logo.png" alt={SITE_NAME} width={150} height={44} className="mx-auto h-10 w-auto lg:mx-0" />
|
||||
<ul className="mt-6 space-y-2 text-sm leading-7 text-body">
|
||||
{BIO_LINES.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex items-center justify-center gap-3 lg:justify-start">
|
||||
{SOCIALS.map(({ icon: Icon, href, label }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary-soft text-primary transition-colors hover:bg-primary hover:text-white"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-5 text-base font-bold text-ink">راههای ارتباطی</h3>
|
||||
<ul className="space-y-4 text-sm text-body">
|
||||
<li className="flex items-start justify-center gap-3 lg:justify-start">
|
||||
<IconLocation className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<span>{CONTACT.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-center gap-3 lg:justify-start">
|
||||
<IconPhone className="h-4 w-4 shrink-0 text-primary" />
|
||||
<a href={`tel:${CONTACT.phone}`} dir="ltr" className="hover:text-primary">
|
||||
{CONTACT.phoneLabel}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center justify-center gap-3 lg:justify-start">
|
||||
<IconInstagram className="h-4 w-4 shrink-0 text-primary" />
|
||||
<a href={CONTACT.instagram} target="_blank" rel="noopener noreferrer" dir="ltr" className="hover:text-primary">
|
||||
{CONTACT.instagramLabel}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-5 text-base font-bold text-ink">صفحات</h3>
|
||||
<ul className="space-y-3 text-sm text-body">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="transition-colors hover:text-primary">
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary">
|
||||
<div className="boxed footer-bottom-bar py-4">
|
||||
<div className="footer-bottom-site">
|
||||
© {new Date().getFullYear()} {SITE_NAME} — کلیه حقوق محفوظ است.
|
||||
</div>
|
||||
<a href="https://meshkee.com" target="_blank" rel="noopener noreferrer" dir="ltr" className="meshkee-credit">
|
||||
<span className="meshkee-credit__frame" aria-hidden>
|
||||
<span className="meshkee-credit__line meshkee-credit__line--l" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--t" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--r" />
|
||||
<span className="meshkee-credit__line meshkee-credit__line--b" />
|
||||
</span>
|
||||
<img src="/brand/meshkee-logo-white.png" alt="Meshkee" className="meshkee-credit__logo" width={38} height={38} />
|
||||
<span className="meshkee-credit__text">
|
||||
<span className="meshkee-credit__label">Designed And Developed By</span>
|
||||
<span className="meshkee-credit__name">Meshkee E-Commerce Team</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { CONTACT, NAV_LINKS } from "@/lib/site";
|
||||
import type { CustomerUser } from "@/lib/meshkee/types";
|
||||
import { CartButton } from "@/components/cart/CartButton";
|
||||
import { UserMenu } from "@/components/UserMenu";
|
||||
import { IconPhone } from "./icons";
|
||||
|
||||
export function Header({ user, isAdmin }: { user: CustomerUser | null; isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-secondary">
|
||||
<div className="boxed flex h-20 items-center justify-between gap-4">
|
||||
<Link href="/" className="shrink-0" onClick={() => setOpen(false)}>
|
||||
<Image
|
||||
src="/images/logo.png"
|
||||
alt="استاد علی عالمی"
|
||||
width={140}
|
||||
height={40}
|
||||
priority
|
||||
className="h-9 w-auto brightness-0 invert"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-5 xl:flex">
|
||||
{NAV_LINKS.map((link) => {
|
||||
const active = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`whitespace-nowrap text-sm font-medium transition-colors hover:text-primary ${
|
||||
active ? "text-primary" : "text-white/80"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="hidden items-center gap-3 lg:flex">
|
||||
<a
|
||||
href={`tel:${CONTACT.phone}`}
|
||||
className="flex items-center gap-2 text-sm font-medium text-white/80 transition-colors hover:text-primary"
|
||||
dir="ltr"
|
||||
>
|
||||
<IconPhone className="h-4 w-4 text-primary" />
|
||||
{CONTACT.phoneLabel}
|
||||
</a>
|
||||
<CartButton light />
|
||||
<UserMenu user={user} isAdmin={isAdmin} light />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<CartButton light />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="باز کردن منو"
|
||||
aria-expanded={open}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-white/20"
|
||||
>
|
||||
<span className="relative block h-3.5 w-5">
|
||||
<span
|
||||
className={`absolute inset-x-0 top-0 h-0.5 rounded bg-white transition-transform ${open ? "translate-y-[6px] rotate-45" : ""}`}
|
||||
/>
|
||||
<span className={`absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded bg-white transition-opacity ${open ? "opacity-0" : ""}`} />
|
||||
<span
|
||||
className={`absolute inset-x-0 bottom-0 h-0.5 rounded bg-white transition-transform ${open ? "-translate-y-[6px] -rotate-45" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-white/10 bg-secondary lg:hidden">
|
||||
<nav className="boxed flex flex-col gap-1 py-4">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/5 hover:text-primary"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-2 flex flex-col gap-3 border-t border-white/10 pt-4">
|
||||
<a href={`tel:${CONTACT.phone}`} className="flex items-center gap-2 px-3 text-sm text-white/80" dir="ltr">
|
||||
<IconPhone className="h-4 w-4 text-primary" />
|
||||
{CONTACT.phoneLabel}
|
||||
</a>
|
||||
<div className="px-3">
|
||||
<UserMenu user={user} isAdmin={isAdmin} light />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import { IconArrow } from "./icons";
|
||||
|
||||
export function PageHeader({ title, current }: { title: string; current: string }) {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-l from-secondary to-primary py-16 md:py-20">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1.5px, transparent 1.5px)",
|
||||
backgroundSize: "20px 20px",
|
||||
color: "#ffffff",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="boxed relative text-center">
|
||||
<h1 className="text-3xl font-bold text-white md:text-4xl">{title}</h1>
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-sm text-white/70">
|
||||
<Link href="/" className="hover:text-white">
|
||||
خانه
|
||||
</Link>
|
||||
<IconArrow className="h-3.5 w-3.5 -scale-x-100" />
|
||||
<span className="text-white">{current}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
// Lightweight stand-in for the original theme's WOW.js + animate.css
|
||||
// scroll-reveal (see old-site/assets/js/wow.min.js, assets/css/animate.css).
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`reveal ${visible ? "is-visible" : ""} ${className ?? ""}`}
|
||||
style={{ transitionDelay: visible ? `${delay}ms` : "0ms" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Stat = { value: number; suffix?: string; label: string };
|
||||
|
||||
function Counter({ value, suffix }: { value: number; suffix?: string }) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [display, setDisplay] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
let started = false;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting || started) return;
|
||||
started = true;
|
||||
const duration = 1200;
|
||||
const start = performance.now();
|
||||
|
||||
function tick(now: number) {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
setDisplay(Math.round(value * (1 - Math.pow(1 - progress, 3))));
|
||||
if (progress < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ threshold: 0.4 },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span ref={ref}>
|
||||
{display.toLocaleString("fa-IR")}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsBar({ items }: { items: Stat[] }) {
|
||||
return (
|
||||
<section className="border-t border-line bg-white py-14">
|
||||
<div className="boxed grid grid-cols-2 gap-8 text-center md:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<p className="text-3xl font-bold text-primary md:text-4xl">
|
||||
<Counter value={item.value} suffix={item.suffix ?? "+"} />
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-body">{item.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BUSINESS_URL, CUSTOMER_URL, customerLoginUrl } from "@/lib/site";
|
||||
import type { CustomerUser } from "@/lib/meshkee/types";
|
||||
import { IconChevronDown, IconUser } from "@/components/icons";
|
||||
|
||||
export function UserMenu({ user, isAdmin, light = false }: { user: CustomerUser | null; isAdmin: boolean; light?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<a
|
||||
href={customerLoginUrl()}
|
||||
className="flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
<IconUser className="h-4 w-4" />
|
||||
ورود / ثبتنام
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = user.firstName ? `${user.firstName} عزیز` : "کاربر عزیز";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex items-center gap-2 rounded-full px-4 py-2.5 text-sm font-semibold transition-colors ${
|
||||
light ? "text-white/90 hover:bg-white/10" : "text-ink-soft hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<IconUser className="h-4 w-4 text-primary" />
|
||||
{displayName}
|
||||
<IconChevronDown className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute end-0 z-20 mt-2 w-48 overflow-hidden rounded-2xl bg-white py-2 shadow-xl ring-1 ring-line">
|
||||
<a href={`${CUSTOMER_URL}/profile`} className="block px-4 py-2.5 text-sm text-ink-soft hover:bg-surface hover:text-primary">
|
||||
پروفایل من
|
||||
</a>
|
||||
{isAdmin && (
|
||||
<a href={BUSINESS_URL} className="block px-4 py-2.5 text-sm text-ink-soft hover:bg-surface hover:text-primary">
|
||||
مدیریت وبسایت
|
||||
</a>
|
||||
)}
|
||||
<a href={`${CUSTOMER_URL}/logout`} className="block px-4 py-2.5 text-sm text-rose-600 hover:bg-rose-50">
|
||||
خروج
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IconArrow, IconCheck, IconPencil, IconSearch } from "@/components/icons";
|
||||
|
||||
const BADGES = [
|
||||
{ icon: IconPencil, text: "بیش از ۱۵۰ ساعت آموزش" },
|
||||
{ icon: IconSearch, text: "نظارت دقیق و ارزیابی عملکرد" },
|
||||
{ icon: IconCheck, text: "آموزش با کیفیت" },
|
||||
];
|
||||
|
||||
export function BioIntro() {
|
||||
return (
|
||||
<section className="overflow-hidden py-16 md:py-24">
|
||||
<div className="boxed grid items-center gap-14 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">وبسایت استاد علی عالمی</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">درباره ما بیشتر بدانید</h2>
|
||||
<p className="mt-5 text-base leading-8 text-body">
|
||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و
|
||||
متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و
|
||||
کاربردهای متنوع با هدف بهبود ابزارهای کاربردی میباشد. کتابهای زیادی در شصت و سه درصد گذشته، حال و
|
||||
آینده شناخت فراوان جامعه و متخصصان را میطلبد.
|
||||
</p>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{BADGES.map(({ icon: Icon, text }) => (
|
||||
<li key={text} className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-white">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-ink-soft">{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href="/workshops"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
مشاهده دورههای آموزشی
|
||||
<IconArrow className="h-4 w-4 -scale-x-100" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-md">
|
||||
<div className="absolute -inset-x-6 -top-6 bottom-10 rounded-[2.5rem] bg-primary-soft" aria-hidden />
|
||||
<div className="relative overflow-hidden rounded-[2.25rem] bg-secondary">
|
||||
<Image
|
||||
src="/images/hero/character.png"
|
||||
alt="استاد علی عالمی"
|
||||
width={480}
|
||||
height={560}
|
||||
className="mx-auto h-[420px] w-auto object-contain object-bottom pt-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -bottom-6 -start-6 rounded-2xl bg-white px-6 py-4 text-center shadow-xl ring-1 ring-line">
|
||||
<p className="text-2xl font-bold text-primary">۱۴,۵۰۰+</p>
|
||||
<p className="text-xs font-medium text-ink-soft">نفر دانشجو</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { IconArrow } from "@/components/icons";
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "کلاسها به چه صورت برگزار میشود؟",
|
||||
a: "کلاسها هم بهصورت حضوری در منزل و هم بهصورت آنلاین برگزار میشود. بسته به شرایط و محل زندگیتان میتوانید هرکدام از این دو روش را انتخاب کنید.",
|
||||
},
|
||||
{
|
||||
q: "برای شروع کلاسها چه باید کرد؟",
|
||||
a: "کافیست از طریق دکمه «تماس با ما» یا شماره تماس درجشده در سایت با ما در ارتباط باشید تا زمان و نوع کلاس متناسب با نیاز شما هماهنگ شود.",
|
||||
},
|
||||
{
|
||||
q: "آیا امکان انتخاب زمان دلخواه برای کلاسها وجود دارد؟",
|
||||
a: "بله، زمانبندی کلاسها با هماهنگی و بر اساس برنامهی هفتگی شما تنظیم میشود.",
|
||||
},
|
||||
{
|
||||
q: "بستههای معلم خصوصی شامل چه مواردی میشود؟",
|
||||
a: "این بستهها شامل تدریس، رفع اشکال، برگزاری آزمونهای منظم و ارزیابی دورهای عملکرد دانشآموز است تا روند پیشرفت بهطور دقیق دنبال شود.",
|
||||
},
|
||||
];
|
||||
|
||||
export function Faq() {
|
||||
const [open, setOpen] = useState(0);
|
||||
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed mx-auto max-w-3xl">
|
||||
<h2 className="text-center text-3xl font-bold text-ink">برخی از سوالات متداول</h2>
|
||||
|
||||
<div className="mt-10 space-y-3">
|
||||
{FAQS.map((item, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<div key={item.q} className="overflow-hidden rounded-2xl bg-white ring-1 ring-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? -1 : i)}
|
||||
className="flex w-full items-center justify-between gap-4 px-6 py-5 text-right"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="text-sm font-bold text-ink md:text-base">{item.q}</span>
|
||||
<IconArrow className={`h-4 w-4 shrink-0 text-primary transition-transform ${isOpen ? "-rotate-90" : "rotate-90"}`} />
|
||||
</button>
|
||||
{isOpen && <p className="px-6 pb-5 text-sm leading-7 text-body">{item.a}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FEATURES } from "@/components/home/features-data";
|
||||
|
||||
export function FeatureStrip() {
|
||||
return (
|
||||
<section className="py-16 md:py-20">
|
||||
<div className="boxed">
|
||||
<h2 className="text-center text-3xl font-bold text-ink">ویژگیهای ما</h2>
|
||||
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map(({ icon: Icon, title, tone }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="flex items-center gap-3 rounded-xl border border-line px-5 py-4 transition-colors hover:border-primary/30 hover:bg-primary-soft/40"
|
||||
>
|
||||
<span
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-white ${
|
||||
tone === "primary" ? "bg-primary" : "bg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-sm font-bold text-ink">{title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
IconAward,
|
||||
IconBuilding,
|
||||
IconCalendar,
|
||||
IconClock,
|
||||
IconGraduationCap,
|
||||
} from "@/components/icons";
|
||||
|
||||
const RESUME = [
|
||||
{ icon: IconCalendar, title: "تاریخ تولد", text: "۱۳۶۶/۱۲/۲۲" },
|
||||
{ icon: IconGraduationCap, title: "کارشناسی مهندسی مکانیک", text: "دانشگاه علم و صنعت ایران" },
|
||||
{ icon: IconGraduationCap, title: "کارشناسی ارشد مهندسی هوافضا", text: "گرایش سازههای هوایی، دانشگاه علم و صنعت ایران" },
|
||||
{ icon: IconAward, title: "رتبهی ۲۴۳ کنکور سراسری", text: "در گروه آزمایشی ریاضی و فنی" },
|
||||
{ icon: IconClock, title: "۱۳ سال سابقهی تدریس", text: "دروس ریاضی و حسابان کنکور" },
|
||||
{ icon: IconBuilding, title: "همکاری با برترین آموزشگاهها", text: "بهصورت حضوری و آنلاین" },
|
||||
];
|
||||
|
||||
export function Resume() {
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed">
|
||||
<div className="mx-auto max-w-xl text-center">
|
||||
<p className="text-sm font-semibold text-primary">رزومه و تحصیلات</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">استاد علی عالمی را بیشتر بشناسید</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{RESUME.map(({ icon: Icon, title, text }) => (
|
||||
<div key={title} className="flex items-start gap-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-line">
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary-soft text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ink">{title}</p>
|
||||
<p className="mt-1 text-xs leading-6 text-body">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCart } from "@/components/providers/CartProvider";
|
||||
import { IconCart } from "@/components/icons";
|
||||
|
||||
export function CartButton({ light = false }: { light?: boolean }) {
|
||||
const { totalQuantity, open } = useCart();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
aria-label="سبد خرید"
|
||||
className={`relative flex h-10 w-10 items-center justify-center rounded-full transition-colors ${
|
||||
light ? "text-white/90 hover:bg-white/10" : "text-ink-soft hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<IconCart className="h-5 w-5" />
|
||||
{totalQuantity > 0 && (
|
||||
<span className="absolute -top-1 -end-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-white">
|
||||
{totalQuantity.toLocaleString("fa-IR")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/components/providers/CartProvider";
|
||||
import { continueToCheckoutUrl } from "@/lib/cart";
|
||||
import { formatToman } from "@/lib/meshkee/format";
|
||||
import { IconClose, IconMinus, IconPlus } from "@/components/icons";
|
||||
|
||||
export function MiniCart() {
|
||||
const { items, isOpen, close, totalPrice, removeItem, setQuantity } = useCart();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex justify-end bg-ink/40" onClick={close}>
|
||||
<div
|
||||
className="flex h-full w-full max-w-sm flex-col bg-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line px-5 py-4">
|
||||
<h2 className="text-base font-bold text-ink">سبد خرید</h2>
|
||||
<button type="button" onClick={close} aria-label="بستن" className="text-ink-soft hover:text-primary">
|
||||
<IconClose className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-10 text-center text-sm text-muted">سبد خرید شما خالی است.</p>
|
||||
) : (
|
||||
<ul className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex gap-3 border-b border-line pb-4">
|
||||
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-surface">
|
||||
{item.image && (
|
||||
<Image src={item.image} alt={item.name} width={64} height={64} className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-ink">{item.name}</p>
|
||||
<p className="mt-1 text-xs text-primary">{formatToman(item.price)}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity(item.id, item.quantity - 1)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-line text-ink-soft"
|
||||
aria-label="کم کردن تعداد"
|
||||
>
|
||||
<IconMinus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="w-5 text-center text-xs font-bold">{item.quantity.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity(item.id, item.quantity + 1)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-line text-ink-soft"
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
<IconPlus className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="ms-auto text-xs text-muted hover:text-rose-600"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="border-t border-line px-5 py-4">
|
||||
<div className="mb-4 flex items-center justify-between text-sm">
|
||||
<span className="text-ink-soft">جمع کل</span>
|
||||
<span className="font-bold text-ink">{formatToman(totalPrice)}</span>
|
||||
</div>
|
||||
<Link
|
||||
href={continueToCheckoutUrl(items)}
|
||||
className="block rounded-full bg-primary px-6 py-3.5 text-center text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
ادامه فرآیند خرید
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCart } from "@/components/providers/CartProvider";
|
||||
import { useToast } from "@/components/providers/ToastProvider";
|
||||
import { formatToman } from "@/lib/meshkee/format";
|
||||
import type { StoreItemVariant } from "@/lib/meshkee/types";
|
||||
import { IconMinus, IconPlus } from "@/components/icons";
|
||||
|
||||
function inStock(variant: StoreItemVariant) {
|
||||
return variant.stockQuantity === null || variant.stockQuantity === undefined || variant.stockQuantity > 0;
|
||||
}
|
||||
|
||||
export function AddToCartBar({
|
||||
variants,
|
||||
productName,
|
||||
image,
|
||||
}: {
|
||||
variants: StoreItemVariant[];
|
||||
productName: string;
|
||||
image: string | null;
|
||||
}) {
|
||||
const availableVariants = useMemo(() => variants.filter(inStock), [variants]);
|
||||
const [selectedId, setSelectedId] = useState(availableVariants[0]?.id ?? "");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const { addItem, open } = useCart();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const selected = availableVariants.find((v) => v.id === selectedId) ?? availableVariants[0];
|
||||
|
||||
if (availableVariants.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-surface px-5 py-4 text-center text-sm font-medium text-muted">
|
||||
این محصول در حال حاضر موجود نیست.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!selected) return;
|
||||
addItem({
|
||||
id: selected.id,
|
||||
name: [productName, selected.label].filter(Boolean).join(" - "),
|
||||
price: selected.discountedPrice ?? selected.price ?? 0,
|
||||
originalPrice: selected.discountedPrice ? selected.price : undefined,
|
||||
image,
|
||||
quantity,
|
||||
});
|
||||
showToast(`${productName} به سبد خرید اضافه شد.`);
|
||||
open();
|
||||
}
|
||||
|
||||
const price = selected?.discountedPrice ?? selected?.price ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl bg-surface p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl font-bold text-primary">{formatToman(price)}</span>
|
||||
{selected?.discountedPrice && selected.price && (
|
||||
<span className="text-sm text-muted line-through">{formatToman(selected.price)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableVariants.length > 1 && (
|
||||
<div>
|
||||
<label htmlFor="variant" className="mb-2 block text-sm font-medium text-ink-soft">
|
||||
انتخاب گزینه
|
||||
</label>
|
||||
<select
|
||||
id="variant"
|
||||
value={selectedId}
|
||||
onChange={(e) => setSelectedId(e.target.value)}
|
||||
className="w-full rounded-xl border border-line bg-white px-4 py-3 text-sm outline-none focus:border-primary"
|
||||
>
|
||||
{availableVariants.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.label || v.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3 rounded-full border border-line px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-ink-soft hover:bg-surface"
|
||||
>
|
||||
<IconMinus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-bold">{quantity.toLocaleString("fa-IR")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-ink-soft hover:bg-surface"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="flex-1 rounded-full bg-primary px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
افزودن به سبد خرید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { customerLoginUrl } from "@/lib/site";
|
||||
import { postComment, type EntityType } from "@/lib/meshkee/comments";
|
||||
import { formatDate } from "@/lib/meshkee/format";
|
||||
import type { Comment, CustomerUser } from "@/lib/meshkee/types";
|
||||
import { useToast } from "@/components/providers/ToastProvider";
|
||||
|
||||
export function CommentsPanel({
|
||||
entityType,
|
||||
entityId,
|
||||
initialComments,
|
||||
user,
|
||||
token,
|
||||
}: {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
initialComments: Comment[];
|
||||
user: CustomerUser | null;
|
||||
token: string | null;
|
||||
}) {
|
||||
const [comments, setComments] = useState(initialComments);
|
||||
const [text, setText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { showToast } = useToast();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!user || !text.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const authorName = [user.firstName, user.lastName].filter(Boolean).join(" ") || "کاربر";
|
||||
const { comment } = await postComment(entityType, entityId, { authorName, text: text.trim() }, token);
|
||||
setComments((prev) => [comment, ...prev]);
|
||||
setText("");
|
||||
showToast("نظر شما ثبت شد و پس از تایید نمایش داده میشود.");
|
||||
} catch {
|
||||
showToast("ثبت نظر با خطا مواجه شد. دوباره تلاش کنید.", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{user ? (
|
||||
<form onSubmit={handleSubmit} className="mb-8 rounded-2xl bg-surface p-5">
|
||||
<label htmlFor="comment-text" className="mb-2 block text-sm font-bold text-ink">
|
||||
ثبت نظر
|
||||
</label>
|
||||
<textarea
|
||||
id="comment-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={4}
|
||||
required
|
||||
placeholder="نظر خود را بنویسید..."
|
||||
className="w-full resize-none rounded-xl border border-line bg-white px-4 py-3 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="mt-3 rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-60"
|
||||
>
|
||||
{submitting ? "در حال ارسال..." : "ارسال نظر"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mb-8 flex flex-col items-center gap-3 rounded-2xl bg-surface p-6 text-center">
|
||||
<p className="text-sm text-ink-soft">برای ثبت نظر ابتدا وارد حساب کاربری خود شوید.</p>
|
||||
<a
|
||||
href={customerLoginUrl(pathname)}
|
||||
className="rounded-full bg-primary px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
ورود به حساب کاربری
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-sm text-muted">هنوز نظری ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="space-y-5">
|
||||
{comments.map((comment) => (
|
||||
<li key={comment.id} className="border-b border-line pb-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-ink">{comment.authorName || "کاربر"}</p>
|
||||
<p className="text-xs text-muted">{formatDate(comment.createdAt)}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-7 text-body">{comment.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function EmptyState({ title, text }: { title: string; text?: string }) {
|
||||
return (
|
||||
<div className="boxed flex flex-col items-center py-24 text-center">
|
||||
<h2 className="text-xl font-bold text-ink">{title}</h2>
|
||||
{text && <p className="mt-3 max-w-md text-sm leading-7 text-body">{text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { formatToman } from "@/lib/meshkee/format";
|
||||
|
||||
export function EntityCard({
|
||||
href,
|
||||
image,
|
||||
title,
|
||||
subtitle,
|
||||
price,
|
||||
originalPrice,
|
||||
}: {
|
||||
href: string;
|
||||
image: string | null;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
price?: number | null;
|
||||
originalPrice?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group block overflow-hidden rounded-2xl bg-white ring-1 ring-line transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<div className="relative aspect-square w-full overflow-hidden bg-surface">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted">بدون تصویر</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="line-clamp-2 text-sm font-bold text-ink">{title}</h3>
|
||||
{subtitle && <p className="mt-1 line-clamp-1 text-xs text-muted">{subtitle}</p>}
|
||||
{price !== undefined && price !== null && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-primary">{formatToman(price)}</span>
|
||||
{originalPrice && originalPrice > price && (
|
||||
<span className="text-xs text-muted line-through">{formatToman(originalPrice)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { Navigation, Thumbs } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import type { Swiper as SwiperClass } from "swiper";
|
||||
import { IconClose, IconZoom } from "@/components/icons";
|
||||
|
||||
// Hover-zoom main image + thumbnail strip + a fullscreen lightbox on click.
|
||||
// No extra dependency: reuses Swiper (already in the project for carousels).
|
||||
function ZoomableImage({ src, alt, onOpen }: { src: string; alt: string; onOpen: () => void }) {
|
||||
const [pos, setPos] = useState({ x: 50, y: 50 });
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
|
||||
function handleMove(e: MouseEvent<HTMLDivElement>) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
setPos({ x, y });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative aspect-square w-full cursor-zoom-in overflow-hidden rounded-2xl bg-surface"
|
||||
onMouseEnter={() => setZoomed(true)}
|
||||
onMouseLeave={() => setZoomed(false)}
|
||||
onMouseMove={handleMove}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<Image src={src} alt={alt} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-contain" />
|
||||
{zoomed && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 hidden bg-white bg-no-repeat md:block"
|
||||
style={{
|
||||
backgroundImage: `url(${src})`,
|
||||
backgroundSize: "220%",
|
||||
backgroundPosition: `${pos.x}% ${pos.y}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="absolute bottom-3 end-3 flex h-9 w-9 items-center justify-center rounded-full bg-white/90 text-ink-soft opacity-0 shadow transition-opacity group-hover:opacity-100">
|
||||
<IconZoom className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lightbox({ images, startAt, alt, onClose }: { images: string[]; startAt: number; alt: string; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 p-4" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="بستن"
|
||||
className="absolute top-5 end-5 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||
>
|
||||
<IconClose className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="relative h-[80vh] w-full max-w-4xl" onClick={(e) => e.stopPropagation()}>
|
||||
<Swiper
|
||||
modules={[Navigation]}
|
||||
navigation
|
||||
initialSlide={startAt}
|
||||
className="h-full w-full lightbox-swiper"
|
||||
>
|
||||
{images.map((src, i) => (
|
||||
<SwiperSlide key={i} className="flex items-center justify-center">
|
||||
<div className="relative h-full w-full">
|
||||
<Image src={src} alt={alt} fill sizes="90vw" className="object-contain" />
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Gallery({ images, alt }: { images: string[]; alt: string }) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [thumbsSwiper, setThumbsSwiper] = useState<SwiperClass | null>(null);
|
||||
|
||||
if (images.length === 0) {
|
||||
return <div className="aspect-square w-full rounded-2xl bg-surface" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ZoomableImage src={images[activeIndex]} alt={alt} onOpen={() => setLightboxOpen(true)} />
|
||||
|
||||
{images.length > 1 && (
|
||||
<Swiper
|
||||
modules={[Thumbs, Navigation]}
|
||||
onSwiper={setThumbsSwiper}
|
||||
watchSlidesProgress
|
||||
slidesPerView={4}
|
||||
spaceBetween={12}
|
||||
className="mt-4"
|
||||
>
|
||||
{images.map((src, i) => (
|
||||
<SwiperSlide key={i}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveIndex(i);
|
||||
thumbsSwiper?.slideTo(i);
|
||||
}}
|
||||
className={`block aspect-square w-full overflow-hidden rounded-xl ring-2 transition-colors ${
|
||||
activeIndex === i ? "ring-primary" : "ring-transparent"
|
||||
}`}
|
||||
>
|
||||
<Image src={src} alt="" width={100} height={100} className="h-full w-full object-cover" />
|
||||
</button>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
)}
|
||||
|
||||
{lightboxOpen && (
|
||||
<Lightbox images={images} startAt={activeIndex} alt={alt} onClose={() => setLightboxOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SimilarRow({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="border-t border-line py-12">
|
||||
<div className="boxed">
|
||||
<h2 className="mb-6 text-2xl font-bold text-ink">{title}</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
export function Tabs({ tabs }: { tabs: Array<{ key: string; label: string; content: ReactNode }> }) {
|
||||
const [active, setActive] = useState(tabs[0]?.key);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 border-b border-line">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActive(tab.key)}
|
||||
className={`relative px-5 py-3 text-sm font-bold transition-colors ${
|
||||
active === tab.key ? "text-primary" : "text-muted hover:text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{active === tab.key && <span className="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-primary" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="py-6">{tabs.find((t) => t.key === active)?.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { TechnicalInfoResponse } from "@/lib/meshkee/types";
|
||||
|
||||
export function TechnicalDataTable({ info }: { info: TechnicalInfoResponse | null }) {
|
||||
const fields = info?.form?.fields ?? [];
|
||||
const values = info?.values ?? [];
|
||||
|
||||
if (fields.length === 0 || values.length === 0) {
|
||||
return <p className="text-sm text-muted">مشخصات فنی برای این مورد ثبت نشده است.</p>;
|
||||
}
|
||||
|
||||
const rows = [...fields]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((field) => {
|
||||
const value = values.find((v) => v.fieldId === field.id);
|
||||
if (!value) return null;
|
||||
|
||||
let display = value.textValue;
|
||||
if (!display && value.optionId) {
|
||||
display = field.options?.find((o) => o.id === value.optionId)?.label;
|
||||
}
|
||||
if (!display && value.optionIds?.length) {
|
||||
display = value.optionIds
|
||||
.map((id) => field.options?.find((o) => o.id === id)?.label)
|
||||
.filter(Boolean)
|
||||
.join("، ");
|
||||
}
|
||||
|
||||
if (!display) return null;
|
||||
return { label: field.labelFa || field.label || field.labelEn || field.id, value: display };
|
||||
})
|
||||
.filter((row): row is { label: string; value: string } => Boolean(row));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-sm text-muted">مشخصات فنی برای این مورد ثبت نشده است.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full overflow-hidden rounded-2xl text-sm">
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.label} className={i % 2 === 0 ? "bg-surface" : "bg-white"}>
|
||||
<td className="w-1/3 px-4 py-3 font-medium text-ink-soft">{row.label}</td>
|
||||
<td className="px-4 py-3 text-ink">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { IconCheck } from "@/components/icons";
|
||||
import { useToast } from "@/components/providers/ToastProvider";
|
||||
import { submitContactForm } from "@/lib/meshkee/contact";
|
||||
|
||||
export function ContactForm() {
|
||||
const [sent, setSent] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const data = new FormData(form);
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitContactForm({
|
||||
title: "پیام از فرم تماس با ما",
|
||||
name: String(data.get("name") ?? ""),
|
||||
cellNumber: String(data.get("phone") ?? ""),
|
||||
text: String(data.get("message") ?? ""),
|
||||
});
|
||||
showToast("پیام شما با موفقیت ارسال شد.");
|
||||
setSent(true);
|
||||
form.reset();
|
||||
setTimeout(() => setSent(false), 4000);
|
||||
} catch {
|
||||
showToast("ارسال پیام با خطا مواجه شد. لطفاً دوباره تلاش کنید.", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid gap-5 rounded-3xl bg-white p-8 shadow-sm ring-1 ring-line md:grid-cols-2"
|
||||
>
|
||||
{sent && (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-primary-soft px-5 py-4 md:col-span-2">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-white">
|
||||
<IconCheck className="h-4 w-4" />
|
||||
</span>
|
||||
<p className="text-sm font-medium text-ink-soft">
|
||||
پیام شما ارسال شد. از اینکه با استاد علی عالمی در ارتباط هستید سپاسگزاریم.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-ink-soft">
|
||||
نام و نام خانوادگی
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
type="text"
|
||||
className="w-full rounded-xl border border-line px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||
placeholder="نام شما"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-ink-soft">
|
||||
شماره تماس
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
required
|
||||
type="tel"
|
||||
dir="ltr"
|
||||
className="w-full rounded-xl border border-line px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||
placeholder="0912 000 0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="message" className="mb-2 block text-sm font-medium text-ink-soft">
|
||||
پیام شما
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-xl border border-line px-4 py-3 text-sm outline-none transition-colors focus:border-primary"
|
||||
placeholder="متن پیام..."
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-60 md:w-auto"
|
||||
>
|
||||
{submitting ? "در حال ارسال..." : "ارسال پیام"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Image from "next/image";
|
||||
import { CONTACT } from "@/lib/site";
|
||||
import { IconInstagram, IconLocation, IconPhone } from "@/components/icons";
|
||||
|
||||
const ITEMS = [
|
||||
{ icon: IconLocation, text: CONTACT.address, href: undefined },
|
||||
{ icon: IconPhone, text: CONTACT.phoneLabel, href: `tel:${CONTACT.phone}`, dir: "ltr" as const },
|
||||
{ icon: IconInstagram, text: CONTACT.instagramLabel, href: CONTACT.instagram, dir: "ltr" as const },
|
||||
];
|
||||
|
||||
export function ContactInfo() {
|
||||
return (
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="boxed grid items-center gap-14 md:grid-cols-2">
|
||||
<div className="relative mx-auto w-full max-w-md">
|
||||
<div className="absolute -inset-x-6 -top-6 bottom-10 rounded-[2.5rem] bg-primary-soft" aria-hidden />
|
||||
<Image
|
||||
src="/images/contact-photo.jpg"
|
||||
alt="ارتباط با استاد علی عالمی"
|
||||
width={520}
|
||||
height={600}
|
||||
className="relative w-full rounded-[2.25rem] object-cover shadow-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">راههای ارتباطی</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">با ما تماس بگیرید</h2>
|
||||
<p className="mt-4 text-base leading-8 text-body">ما هر روز هفته آمادهی شنیدن نظرات شما هستیم.</p>
|
||||
|
||||
<ul className="mt-8 space-y-4">
|
||||
{ITEMS.map(({ icon: Icon, text, href, dir }) => (
|
||||
<li key={text} className="flex items-center gap-4 rounded-2xl bg-primary-soft p-4">
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary text-white">
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
{href ? (
|
||||
<a href={href} target={href.startsWith("http") ? "_blank" : undefined} rel="noopener noreferrer" dir={dir} className="text-sm font-medium text-ink-soft hover:text-primary">
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-ink-soft">{text}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CUSTOMER_URL } from "@/lib/site";
|
||||
import { IconArrow } from "@/components/icons";
|
||||
|
||||
export function CustomerClubBanner() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-l from-secondary to-primary py-14">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1.5px, transparent 1.5px)",
|
||||
backgroundSize: "20px 20px",
|
||||
color: "#ffffff",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="boxed relative flex flex-col items-center justify-between gap-6 text-center md:flex-row md:text-right">
|
||||
<h2 className="text-2xl font-bold text-white md:text-3xl">در باشگاه مشتریان ما عضو شوید</h2>
|
||||
<a
|
||||
href={`${CUSTOMER_URL}/register`}
|
||||
className="flex shrink-0 items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-secondary transition-transform hover:scale-105"
|
||||
>
|
||||
عضویت رایگان
|
||||
<IconArrow className="h-4 w-4 -scale-x-100" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CONTACT } from "@/lib/site";
|
||||
import { IconLocation } from "@/components/icons";
|
||||
import { ContactForm } from "./ContactForm";
|
||||
|
||||
export function MessageSection() {
|
||||
const mapsHref = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(CONTACT.address)}`;
|
||||
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed">
|
||||
<h2 className="text-center text-3xl font-bold text-ink">به ما پیام بفرستید!</h2>
|
||||
|
||||
<div className="mt-12 grid gap-8 lg:grid-cols-[1.4fr_1fr]">
|
||||
<ContactForm />
|
||||
|
||||
<a
|
||||
href={mapsHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative flex min-h-[280px] flex-col justify-between overflow-hidden rounded-3xl bg-secondary p-8 text-white transition-colors hover:bg-secondary-dark"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(currentColor 1.5px, transparent 1.5px)",
|
||||
backgroundSize: "18px 18px",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="relative flex h-12 w-12 items-center justify-center rounded-xl bg-white/10 text-primary">
|
||||
<IconLocation className="h-6 w-6" />
|
||||
</span>
|
||||
<div className="relative">
|
||||
<p className="text-lg font-bold">موقعیت مکانی ما</p>
|
||||
<p className="mt-2 text-sm leading-7 text-white/70">{CONTACT.address}</p>
|
||||
<p className="mt-4 text-xs font-semibold text-primary group-hover:underline">
|
||||
مشاهده در نقشه ←
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import type { Swiper as SwiperClass } from "swiper";
|
||||
import { Autoplay, Navigation } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { IconChevron } from "@/components/icons";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
import { FEATURES } from "./features-data";
|
||||
|
||||
// Cards + a right-hand heading/nav column, matching the original theme's
|
||||
// feature section exactly (Owl Carousel there, Swiper here per instruction).
|
||||
export function FeatureCards() {
|
||||
const swiperRef = useRef<SwiperClass | null>(null);
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="boxed grid gap-10 md:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<Reveal className="text-center md:text-right">
|
||||
<h2 className="text-3xl font-bold text-ink">ویژگیهای ما</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-body">
|
||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-3 md:justify-start">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="اسلاید بعدی"
|
||||
onClick={() => swiperRef.current?.slideNext()}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-line text-ink-soft transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<IconChevron className="h-4 w-4 -scale-x-100" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="اسلاید قبلی"
|
||||
onClick={() => swiperRef.current?.slidePrev()}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-line text-ink-soft transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<IconChevron className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={100} className="min-w-0">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation]}
|
||||
slidesPerView={1}
|
||||
spaceBetween={24}
|
||||
autoplay={{ delay: 4500, disableOnInteraction: false }}
|
||||
onSwiper={(instance) => {
|
||||
swiperRef.current = instance;
|
||||
}}
|
||||
breakpoints={{ 640: { slidesPerView: 2 }, 1024: { slidesPerView: 3 } }}
|
||||
>
|
||||
{FEATURES.map(({ icon: Icon, title, text, tone }) => (
|
||||
<SwiperSlide key={title}>
|
||||
<div className="flex h-full flex-col rounded-2xl bg-white p-7 shadow-sm ring-1 ring-line">
|
||||
<span
|
||||
className={`flex h-16 w-16 items-center justify-center rounded-2xl text-white ${
|
||||
tone === "primary" ? "bg-primary" : "bg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
</span>
|
||||
<h3 className="mt-6 text-lg font-bold text-ink">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-body">{text}</p>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-white">
|
||||
<div className="boxed grid items-center gap-10 py-14 md:grid-cols-2 md:gap-8 md:py-20">
|
||||
<Reveal className="relative z-10 text-center md:text-right">
|
||||
<h1 className="text-4xl font-bold leading-tight text-ink md:text-5xl">
|
||||
وبسایت
|
||||
<br />
|
||||
<span className="text-ink">استاد علی عالمی</span>
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-6 max-w-xl text-base leading-8 text-body md:mx-0">
|
||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و متون
|
||||
بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و کاربردهای متنوع
|
||||
با هدف بهبود ابزارهای کاربردی میباشد.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
delay={150}
|
||||
className="relative mx-auto flex h-[360px] w-[360px] items-center justify-center md:h-[440px] md:w-[440px]"
|
||||
>
|
||||
{/* Big dotted ring backdrop — old-site/assets/files/RHLYjNnYj6.png (the accent dots are baked into the asset). */}
|
||||
<Image
|
||||
src="/images/hero/ring.png"
|
||||
alt=""
|
||||
width={560}
|
||||
height={560}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 h-full w-full animate-[spin_60s_linear_infinite]"
|
||||
/>
|
||||
|
||||
{/* Decorative floaters — positions mirror the original theme's slider frame (old-site/custom/fa/frames/public-slider-zI2Qc.php). */}
|
||||
<Image
|
||||
src="/images/hero/shape-1.png"
|
||||
alt=""
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden
|
||||
className="absolute top-2 start-10 h-14 w-14 opacity-70 md:h-16 md:w-16"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/images/hero/paper-plane.png"
|
||||
alt=""
|
||||
width={90}
|
||||
height={70}
|
||||
aria-hidden
|
||||
className="absolute top-0 end-2 h-16 w-20 opacity-80 md:h-20 md:w-24"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/images/hero/grad-cap.png"
|
||||
alt=""
|
||||
width={56}
|
||||
height={56}
|
||||
aria-hidden
|
||||
className="absolute top-1/2 -start-3 h-12 w-12 animate-bounce [animation-duration:3s] md:h-14 md:w-14"
|
||||
/>
|
||||
|
||||
{/* Second student peeking from behind the main character. */}
|
||||
<Image
|
||||
src="/images/hero/avatar-1.png"
|
||||
alt=""
|
||||
width={140}
|
||||
height={140}
|
||||
aria-hidden
|
||||
className="absolute bottom-6 end-2 z-[5] h-24 w-24 md:h-28 md:w-28"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/images/hero/character.png"
|
||||
alt="استاد علی عالمی"
|
||||
width={420}
|
||||
height={520}
|
||||
priority
|
||||
className="relative z-10 h-[110%] w-auto max-w-none object-contain object-bottom drop-shadow-2xl"
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/images/hero/shape-2.png"
|
||||
alt=""
|
||||
width={60}
|
||||
height={60}
|
||||
aria-hidden
|
||||
className="absolute bottom-0 start-0 h-10 w-10 opacity-70"
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
|
||||
const ITEMS = [
|
||||
{ icon: "/images/icons/play.png", eyebrow: "TIP AND TEST", title: "کلاسهای نکته و تست" },
|
||||
{ icon: "/images/icons/exam.png", eyebrow: "TEST", title: "برگزاری آزمونهای منظم" },
|
||||
{ icon: "/images/icons/price.png", eyebrow: "PRICE", title: "تضمین قیمت" },
|
||||
{ icon: "/images/icons/support.png", eyebrow: "SUPPORT", title: "پشتیبانی دقیق" },
|
||||
];
|
||||
|
||||
export function QuickLinks() {
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed">
|
||||
<Reveal className="mx-auto max-w-lg text-center">
|
||||
<p className="text-sm leading-7 text-body">
|
||||
استاد علی عالمی، ۱۳ سال سابقهی تدریس دروس ریاضی و حسابان کنکور
|
||||
</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">چرا استاد علی عالمی؟</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{ITEMS.map((item, i) => (
|
||||
<Reveal key={item.title} delay={i * 100}>
|
||||
<div className="flex h-full flex-col items-center rounded-3xl bg-white px-8 py-10 text-center shadow-sm ring-1 ring-line transition-shadow hover:shadow-lg">
|
||||
<Image src={item.icon} alt="" width={64} height={64} className="h-16 w-16" />
|
||||
<p className="mt-6 text-lg font-bold text-ink">{item.title}</p>
|
||||
<p className="eyebrow-en mt-2 text-xs font-semibold text-muted">{item.eyebrow}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal delay={200} className="mt-12 text-center">
|
||||
<Link
|
||||
href="/about-us"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-secondary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-secondary-dark"
|
||||
>
|
||||
درباره ما بیشتر بدانید
|
||||
</Link>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Autoplay, Pagination } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
|
||||
const REVIEWS = [
|
||||
{
|
||||
name: "احمدرضا عابدی",
|
||||
role: "دانشجو",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.",
|
||||
},
|
||||
{
|
||||
name: "احمدرضا عابدی",
|
||||
role: "دانشجو",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.",
|
||||
},
|
||||
{
|
||||
name: "احمدرضا عابدی",
|
||||
role: "دانشجو",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است.",
|
||||
},
|
||||
];
|
||||
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<section className="bg-surface py-16 md:py-24">
|
||||
<div className="boxed">
|
||||
<Reveal className="flex flex-col items-center justify-between gap-6 text-center md:flex-row md:text-right">
|
||||
<div>
|
||||
<p className="text-sm text-body">نظرات برخی از دانشجویان مارا مشاهده کنید.</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">نظرات برخی از دانشجویان</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/workshops"
|
||||
className="shrink-0 rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
مشاهده دورههای آموزشی
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={100}>
|
||||
<Swiper
|
||||
modules={[Autoplay, Pagination]}
|
||||
spaceBetween={24}
|
||||
slidesPerView={1}
|
||||
pagination={{ clickable: true }}
|
||||
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||
breakpoints={{ 768: { slidesPerView: 2 }, 1024: { slidesPerView: 3 } }}
|
||||
className="testimonial-swiper mt-12"
|
||||
>
|
||||
{REVIEWS.map((review, i) => (
|
||||
<SwiperSlide key={i}>
|
||||
<div className="flex h-full flex-col rounded-2xl bg-white p-8 shadow-sm ring-1 ring-line">
|
||||
<div className="flex items-center gap-3">
|
||||
<Image
|
||||
src="/images/testimonial/avatar.png"
|
||||
alt={review.name}
|
||||
width={52}
|
||||
height={52}
|
||||
className="h-13 w-13 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-ink">{review.name}</p>
|
||||
<p className="text-xs text-muted">{review.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-5 flex-1 text-sm leading-7 text-body">{review.text}</p>
|
||||
<Image
|
||||
src="/images/icons/quote.png"
|
||||
alt=""
|
||||
width={36}
|
||||
height={30}
|
||||
className="mt-4 h-7 w-auto self-end opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IconArrow, IconCheck, IconPencil, IconSearch } from "@/components/icons";
|
||||
import { Reveal } from "@/components/Reveal";
|
||||
|
||||
export function WhyChooseUs() {
|
||||
return (
|
||||
<section className="overflow-hidden py-20 md:py-28">
|
||||
<div className="boxed grid items-center gap-20 md:grid-cols-2">
|
||||
<Reveal className="text-center md:text-right">
|
||||
<p className="text-sm font-semibold text-primary">وبسایت استاد علی عالمی</p>
|
||||
<h2 className="mt-2 text-3xl font-bold text-ink">چرا مارا انتخاب کنید؟</h2>
|
||||
<p className="mt-5 text-base leading-8 text-body">
|
||||
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است. چاپگرها و
|
||||
متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است و برای شرایط فعلی تکنولوژی مورد نیاز و
|
||||
کاربردهای متنوع با هدف بهبود ابزارهای کاربردی میباشد.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/workshops"
|
||||
className="mt-8 inline-flex items-center gap-2 rounded-full bg-primary px-7 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
مشاهده دورههای آموزشی
|
||||
<IconArrow className="h-4 w-4 -scale-x-100" />
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={150} className="relative mx-auto w-full max-w-md">
|
||||
<div className="relative overflow-hidden rounded-[2.25rem] bg-secondary">
|
||||
<Image
|
||||
src="/images/hero/character.png"
|
||||
alt="استاد علی عالمی در حال تدریس"
|
||||
width={480}
|
||||
height={560}
|
||||
className="mx-auto h-[420px] w-auto -scale-x-100 object-contain object-bottom pt-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating stat badges scattered around the image, matching the original layout. */}
|
||||
<div className="absolute -top-5 right-4 flex items-center gap-2 rounded-2xl bg-white px-4 py-3 shadow-xl ring-1 ring-line">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-white">
|
||||
<IconCheck className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-ink-soft">آموزش با کیفیت</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-1/3 -left-8 flex max-w-[180px] items-center gap-2 rounded-2xl bg-white px-4 py-3 shadow-xl ring-1 ring-line">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-rose-500 text-white">
|
||||
<IconSearch className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-ink-soft">نظارت دقیق و ارزیابی عملکرد</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-5 right-1/3 flex items-center gap-2 rounded-2xl bg-white px-4 py-3 shadow-xl ring-1 ring-line">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-secondary text-white">
|
||||
<IconPencil className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-ink-soft">بیش از ۱۵۰ ساعت آموزش</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-6 -left-6 flex items-center gap-3 rounded-2xl bg-white px-5 py-4 shadow-xl ring-1 ring-line">
|
||||
<div className="flex -space-x-3 space-x-reverse">
|
||||
{["avatar-1", "avatar-2", "avatar-1"].map((src, i) => (
|
||||
<span key={i} className="block h-9 w-9 overflow-hidden rounded-full ring-2 ring-white">
|
||||
<Image src={`/images/hero/${src}.png`} alt="" width={36} height={36} className="h-full w-full object-cover" />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs font-bold text-ink-soft">
|
||||
۱۴,۵۰۰
|
||||
<span className="block font-normal text-muted">نفر دانشجو</span>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IconHome, IconUser, IconWifi } from "@/components/icons";
|
||||
|
||||
export const FEATURES = [
|
||||
{
|
||||
icon: IconHome,
|
||||
title: "تدریس حضوری در منزل",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است",
|
||||
tone: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: IconUser,
|
||||
title: "بستههای معلم خصوصی",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است",
|
||||
tone: "secondary" as const,
|
||||
},
|
||||
{
|
||||
icon: IconWifi,
|
||||
title: "تدریس خصوصی آنلاین",
|
||||
text: "لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ و با استفاده از طراحان گرافیک است",
|
||||
tone: "primary" as const,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,312 @@
|
||||
type IconProps = { className?: string };
|
||||
|
||||
// Small line icons for feature/list items that had no matching asset in the
|
||||
// exported theme files. Single stroke, currentColor, so they inherit the
|
||||
// badge's text color.
|
||||
|
||||
export function IconLaptop({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect x="4" y="5" width="16" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M2.5 19h19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M9.5 16l-1 3M14.5 16l1 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHome({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 11.5 12 4l8 7.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6 10v8.5a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M10 19.5V14h4v5.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCalendar({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect x="4" y="5.5" width="16" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M4 9.5h16" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M8 3.5v3.5M16 3.5v3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M8 13h3M8 16h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPackage({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M12 3.5 20 7.5v9L12 20.5 4 16.5v-9L12 3.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M4 7.5 12 11.5m0 0 8-4M12 11.5v9" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPencil({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 20l.9-3.6L15.6 5.7a1.5 1.5 0 0 1 2.1 0l.6.6a1.5 1.5 0 0 1 0 2.1L7.6 19.1 4 20Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M14 7.5l2.5 2.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M15.2 15.2 20 20" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheck({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M5 12.5 9.5 17 19 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPhone({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M6.5 3.5h2.2l1.4 4-2 1.4a11 11 0 0 0 5 5l1.4-2 4 1.4v2.2a1.5 1.5 0 0 1-1.6 1.5A15.5 15.5 0 0 1 5 5.1a1.5 1.5 0 0 1 1.5-1.6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLocation({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M12 21s7-6.1 7-11.5A7 7 0 0 0 5 9.5C5 14.9 12 21 12 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="9.5" r="2.4" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconInstagram({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="12" cy="12" r="4.2" stroke="currentColor" strokeWidth="1.6" />
|
||||
<circle cx="17.2" cy="6.8" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconWhatsapp({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M6.5 17.5 4.5 20l2.6-.9a8.3 8.3 0 1 0-3-3.1l2.4 1.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.3 9.6c.2-.6.9-1.5 1.5-1.1.6.4 1.1 1.6 1 2-.2.5-.7.6-.5 1.1.3.8 1.5 2 2.3 2.3.5.2.6-.3 1.1-.5.4-.1 1.6.4 2 1 .4.6-.5 1.3-1.1 1.5-1 .3-2.4-.3-3.8-1.7-1.4-1.4-2-2.8-1.5-4.6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTelegram({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="m7.5 12.3 8-3.4-2.7 8-2-2.6-1.7 1.4-.4-2.4Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrow({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconAward({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="9" r="5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M9 13.2 7.5 20l4.5-2.4 4.5 2.4-1.5-6.8" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClock({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M12 7.5V12l3 2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGraduationCap({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M2.5 9.5 12 5l9.5 4.5-9.5 4.5-9.5-4.5Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M6.5 11.6v3.6c0 1.4 2.5 2.6 5.5 2.6s5.5-1.2 5.5-2.6v-3.6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M21.5 9.5v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBuilding({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<rect x="5" y="3.5" width="10" height="17" rx="1" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M15 9.5h4v11h-4" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M8 7h1M11 7h1M8 10.5h1M11 10.5h1M8 14h1M11 14h1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconWifi({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M4 9.5c4.5-4 11.5-4 16 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M7 13.2c2.9-2.5 7.1-2.5 10 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M10.2 16.8c1.1-.9 2.5-.9 3.6 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<circle cx="12" cy="19.3" r="1.1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevron({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M15 6l-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCart({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 5h1.6l1.2 10.4a2 2 0 0 0 2 1.8h7.6a2 2 0 0 0 2-1.7L19.5 8.5H6.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="9.5" cy="20" r="1.3" fill="currentColor" />
|
||||
<circle cx="16.5" cy="20" r="1.3" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconClose({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMinus({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconZoom({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="10.5" cy="10.5" r="6.5" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M15.5 15.5 20 20" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M10.5 8v5M8 10.5h5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronDown({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMessageCircle({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<path
|
||||
d="M4 12a8 8 0 1 1 3.2 6.4L4 19.5l1.1-3.1A7.9 7.9 0 0 1 4 12Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconUser({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden>
|
||||
<circle cx="12" cy="8.2" r="3.4" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M5 19c1-3 3.8-4.8 7-4.8s6 1.8 7 4.8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { cartTotalPrice, cartTotalQuantity, readGuestCart, writeGuestCart, type CartLine } from "@/lib/cart";
|
||||
|
||||
type CartContextValue = {
|
||||
items: CartLine[];
|
||||
totalQuantity: number;
|
||||
totalPrice: number;
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
addItem: (line: CartLine) => void;
|
||||
removeItem: (id: string) => void;
|
||||
setQuantity: (id: string, quantity: number) => void;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<CartLine[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
// Reading localStorage during the initial render would mismatch the
|
||||
// server-rendered (empty) markup, so the real cart loads post-mount —
|
||||
// deferred to a microtask so the effect body itself never calls setState
|
||||
// synchronously.
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setItems(readGuestCart());
|
||||
setHydrated(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
writeGuestCart(items);
|
||||
}, [items, hydrated]);
|
||||
|
||||
function addItem(line: CartLine) {
|
||||
setItems((prev) => {
|
||||
const existing = prev.find((item) => item.id === line.id);
|
||||
if (existing) {
|
||||
return prev.map((item) => (item.id === line.id ? { ...item, quantity: item.quantity + line.quantity } : item));
|
||||
}
|
||||
return [...prev, line];
|
||||
});
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
function setQuantity(id: string, quantity: number) {
|
||||
if (quantity <= 0) {
|
||||
removeItem(id);
|
||||
return;
|
||||
}
|
||||
setItems((prev) => prev.map((item) => (item.id === id ? { ...item, quantity } : item)));
|
||||
}
|
||||
|
||||
const value: CartContextValue = {
|
||||
items,
|
||||
totalQuantity: cartTotalQuantity(items),
|
||||
totalPrice: cartTotalPrice(items),
|
||||
isOpen,
|
||||
open: () => setIsOpen(true),
|
||||
close: () => setIsOpen(false),
|
||||
addItem,
|
||||
removeItem,
|
||||
setQuantity,
|
||||
};
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
import { IconCheck } from "@/components/icons";
|
||||
|
||||
type Toast = { id: number; message: string; tone: "success" | "error" };
|
||||
|
||||
const ToastContext = createContext<{ showToast: (message: string, tone?: Toast["tone"]) => void } | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, tone: Toast["tone"] = "success") => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, tone }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-[100] flex flex-col items-center gap-3 px-4">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto flex items-center gap-3 rounded-2xl px-5 py-3.5 text-sm font-medium text-white shadow-xl ${
|
||||
toast.tone === "success" ? "bg-secondary" : "bg-rose-600"
|
||||
}`}
|
||||
>
|
||||
{toast.tone === "success" && (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary">
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user