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:
amirhosein.ashourloo
2026-09-16 09:43:10 +03:30
co-authored by Claude Sonnet 5
commit 8f102a8250
106 changed files with 11605 additions and 0 deletions
+106
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+93
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+8
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+12
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+22
View File
@@ -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,
},
];
+312
View File
@@ -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>
);
}
+83
View File
@@ -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;
}