first commit

This commit is contained in:
amirhosein.ashourloo
2026-09-02 17:03:23 +03:30
commit 1bd131e9d8
91 changed files with 11211 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { Swiper as SwiperType } from "swiper";
import { ChevronLeft, ChevronRight } from "../icons";
// Prev/next controls that drive a Swiper instance held by the parent, so a
// section can place them wherever its layout needs them.
export default function CarouselNav({
swiper,
tone = "light",
}: {
swiper: SwiperType | null;
tone?: "light" | "onBrand";
}) {
const style =
tone === "onBrand"
? "bg-white/20 text-white hover:bg-white/35"
: "border border-line bg-white text-ink hover:border-brand hover:text-brand";
return (
<div className="flex items-center gap-2">
<button
onClick={() => swiper?.slidePrev()}
className={`flex size-9 items-center justify-center rounded-full transition ${style}`}
aria-label="قبلی"
>
<ChevronRight className="size-5" />
</button>
<button
onClick={() => swiper?.slideNext()}
className={`flex size-9 items-center justify-center rounded-full transition ${style}`}
aria-label="بعدی"
>
<ChevronLeft className="size-5" />
</button>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import { toFaDigits } from "@/lib/format";
import { ChevronLeft, ChevronRight } from "../icons";
// Simple numbered pager. `buildHref` turns a page number into the URL for
// that page — callers own how query params are preserved (search, filters).
export default function Pagination({
page,
pageSize,
total,
buildHref,
}: {
page: number;
pageSize: number;
total: number;
buildHref: (page: number) => string;
}) {
const pageCount = Math.max(1, Math.ceil(total / pageSize));
if (pageCount <= 1) return null;
// A short window around the current page, plus first/last, keeps the
// control usable even with a few hundred pages.
const pages = new Set<number>([1, pageCount, page - 1, page, page + 1].filter((p) => p >= 1 && p <= pageCount));
const sorted = [...pages].sort((a, b) => a - b);
const linkClass = (active: boolean) =>
`flex h-9 min-w-9 items-center justify-center rounded-lg px-2 text-[13px] transition ${
active ? "bg-brand text-white" : "border border-line text-ink-soft hover:border-brand hover:text-brand"
}`;
return (
<nav className="mt-8 flex items-center justify-center gap-1.5" aria-label="صفحه‌بندی">
<Link
href={buildHref(Math.max(1, page - 1))}
aria-disabled={page <= 1}
className={`${linkClass(false)} ${page <= 1 ? "pointer-events-none opacity-40" : ""}`}
aria-label="صفحه قبل"
>
<ChevronRight className="size-4" />
</Link>
{sorted.map((p, i) => (
<span key={p} className="flex items-center gap-1.5">
{i > 0 && p - sorted[i - 1] > 1 && <span className="px-1 text-body">…</span>}
<Link href={buildHref(p)} className={linkClass(p === page)}>
{toFaDigits(p)}
</Link>
</span>
))}
<Link
href={buildHref(Math.min(pageCount, page + 1))}
aria-disabled={page >= pageCount}
className={`${linkClass(false)} ${page >= pageCount ? "pointer-events-none opacity-40" : ""}`}
aria-label="صفحه بعد"
>
<ChevronLeft className="size-4" />
</Link>
</nav>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Image from "next/image";
import Link from "next/link";
import { formatPrice, toFaDigits } from "@/lib/format";
import type { ProductCardData } from "@/lib/types";
export default function ProductCard({ product }: { product: ProductCardData }) {
const { href, title, image, price, oldPrice, percent, inStock } = product;
const hasOldPrice = price !== null && oldPrice !== null && oldPrice > price;
return (
<Link
href={href}
className="flex h-full flex-col rounded-lg border border-line bg-white p-2 transition hover:border-brand/40 hover:shadow-[0_6px_20px_rgba(0,0,0,0.07)]"
>
<span className="relative mb-2 block aspect-square w-full overflow-hidden rounded-md bg-white">
{image ? (
<Image
src={image}
alt={title}
fill
sizes="(max-width: 768px) 45vw, 180px"
className="object-contain p-1"
/>
) : (
<span className="flex size-full items-center justify-center text-[11px] text-body">
بدون تصویر
</span>
)}
{percent > 0 && (
<span className="absolute right-1.5 top-1.5 rounded-full bg-brand px-2 py-0.5 text-[11px] font-bold text-white">
{toFaDigits(percent)}٪
</span>
)}
</span>
<span className="clamp-2 mb-3 min-h-[42px] px-1 text-[12.5px] leading-[21px] text-ink-soft">
{title}
</span>
{/* The old-price line always occupies its row — rendered empty when there
is no discount — so a discounted card is exactly as tall as the rest
and every card in a carousel lines up. */}
<span className="mt-auto flex h-[44px] flex-col items-start justify-end px-1 pb-1">
{price !== null && inStock ? (
<>
<span
className={`h-[17px] text-[11px] text-body ${hasOldPrice ? "line-through" : ""}`}
>
{hasOldPrice ? formatPrice(oldPrice) : ""}
</span>
<span className="text-[15px] font-bold text-ink">
{formatPrice(price)}
<span className="mr-1 text-[11px] font-normal text-body">تومان</span>
</span>
</>
) : (
<>
<span className="h-[17px]" />
<span className="text-[12px] text-body">ناموجود</span>
</>
)}
</span>
</Link>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronLeft } from "../icons";
// Title + one-line description on the right; on the left the "مشاهده همه" pill
// with the carousel arrows beside it (desktop only — on mobile the arrows move
// under the track, where the thumb is).
export default function SectionHeader({
title,
desc,
href,
nav,
}: {
title: string;
desc?: string;
href: string;
nav?: ReactNode;
}) {
return (
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<h2 className="text-xl font-bold text-ink">{title}</h2>
{desc && <p className="mt-1 text-[13px] text-body">{desc}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
{nav && <div className="hidden lg:flex">{nav}</div>}
<Link
href={href}
className="flex items-center gap-1 rounded-full border border-line px-4 py-2 text-[13px] text-ink-soft transition hover:border-brand hover:text-brand"
>
مشاهده همه
<ChevronLeft className="size-4" />
</Link>
</div>
</div>
);
}