base product single page

This commit is contained in:
hamid zarghami
2026-07-21 11:25:41 +03:30
parent 6f22885ae9
commit ec39e4448d
9 changed files with 444 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
import ProductDetail from "../components/ProductDetail";
const ProductPage = () => {
return <ProductDetail />;
};
export default ProductPage;
@@ -0,0 +1,25 @@
import { ArrowLeft2 } from "iconsax-reactjs";
import Link from "next/link";
import { type FC, Fragment } from "react";
import { BREADCRUMB_ITEMS } from "../constants";
const ProductBreadcrumb: FC = () => {
return (
<nav aria-label="مسیر صفحه" className="flex items-center justify-start gap-2 bg-secondary px-4 py-3 text-sm text-[#3F4D5A] sm:px-8 lg:px-30">
{BREADCRUMB_ITEMS.map((item, index) => (
<Fragment key={item.label}>
{index > 0 && <ArrowLeft2 size={14} color="currentColor" className="shrink-0" />}
{"href" in item ? (
<Link href={item.href} className="transition-colors hover:text-primary">
{item.label}
</Link>
) : (
<span className="font-bold text-[#0F2438]">{item.label}</span>
)}
</Fragment>
))}
</nav>
);
};
export default ProductBreadcrumb;
+37
View File
@@ -0,0 +1,37 @@
"use client";
import ContactCtaSection from "@/app/home/components/ContactCtaSection";
import { useState, type FC } from "react";
import { PRODUCT_GALLERY_IMAGES, PRODUCT_TITLE, QUANTITY_TIERS, type QuantityTier } from "../constants";
import ProductBreadcrumb from "./ProductBreadcrumb";
import ProductGallery from "./ProductGallery";
import ProductOrderActions from "./ProductOrderActions";
import ProductPurchaseOptions from "./ProductPurchaseOptions";
import ProductRelated from "./ProductRelated";
import ProductTabs from "./ProductTabs";
const ProductDetail: FC = () => {
const [selectedTier, setSelectedTier] = useState<QuantityTier>(QUANTITY_TIERS[0]);
return (
<>
<ProductBreadcrumb />
<div className="px-4 py-6 sm:px-8 sm:py-8 lg:px-30">
<div className="flex flex-col items-start gap-5 lg:flex-row lg:gap-6">
<ProductGallery images={PRODUCT_GALLERY_IMAGES} alt={PRODUCT_TITLE} />
<div className="flex w-full min-w-0 flex-1 flex-col items-end">
<ProductPurchaseOptions selectedTier={selectedTier} onSelectTier={setSelectedTier} />
<ProductOrderActions selectedTier={selectedTier} />
</div>
</div>
</div>
<ProductTabs />
<ProductRelated />
<ContactCtaSection />
</>
);
};
export default ProductDetail;
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { cn } from "@/app/lib/cn";
import Image, { type StaticImageData } from "next/image";
import { useState, type FC } from "react";
type ProductGalleryProps = {
images: readonly StaticImageData[];
alt: string;
};
const ProductGallery: FC<ProductGalleryProps> = ({ images, alt }) => {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div className="flex w-full shrink-0 flex-col gap-2.5 sm:gap-3 lg:w-[42%] lg:max-w-100">
<div className="relative aspect-square w-full overflow-hidden rounded-xl sm:rounded-2xl">
<Image src={images[activeIndex]} alt={alt} fill priority className="object-cover" sizes="(max-width: 1024px) 100vw, 400px" />
</div>
<div className="grid grid-cols-4 gap-2 sm:gap-2.5">
{images.slice(0, 4).map((image, index) => (
<button
key={index}
type="button"
aria-label={`تصویر ${index + 1}`}
aria-pressed={activeIndex === index}
onClick={() => setActiveIndex(index)}
className={cn("relative aspect-square overflow-hidden rounded-lg transition-shadow sm:rounded-xl", activeIndex === index && "ring-2 ring-primary ring-offset-1")}
>
<Image src={image} alt="" fill className="object-cover" sizes="(max-width: 1024px) 22vw, 96px" />
</button>
))}
</div>
</div>
);
};
export default ProductGallery;
@@ -0,0 +1,41 @@
"use client";
import Button from "@/app/components/Button";
import Select from "@/app/components/Select";
import { formatPrice, PRODUCT_PROFIT_PERCENT, STICKY_QUANTITY_OPTIONS, type QuantityTier } from "../constants";
type ProductOrderActionsProps = {
selectedTier: QuantityTier;
};
const ProductOrderActions = ({ selectedTier }: ProductOrderActionsProps) => {
return (
<div className="mt-5 flex w-full flex-col gap-3 self-end rounded-t-2xl bg-white px-4 py-4 shadow-[0px_-4px_34px_0px_#1A456C14] sm:flex-row sm:items-center sm:justify-between sm:gap-5 sm:px-5 sm:py-4">
<div className="space-y-1 text-right">
<p className="text-base font-bold text-[#0A1B2C] sm:text-lg">{formatPrice(selectedTier.totalPrice)}</p>
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-[#0A1B2C] sm:text-sm">{formatPrice(selectedTier.unitPrice)} به ازای هر عدد</span>
<span className="rounded-lg bg-[#00893E] px-2 py-0.5 text-[10px] font-medium text-white sm:text-xs">{PRODUCT_PROFIT_PERCENT}٪ سود</span>
</div>
</div>
<div className="hidden h-16 w-0 shrink-0 border-s-2 border-primary sm:block" />
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-5">
<div className="w-full space-y-1 sm:w-32">
<span className="block text-right text-xs font-medium text-[#0A1B2C]">تعداد</span>
<Select options={[...STICKY_QUANTITY_OPTIONS]} defaultValue="1000" className="h-10 rounded-xl border-[#E9EEF2] text-sm" />
</div>
<div className="flex w-full flex-col gap-1.5 sm:w-44">
<Button className="h-10 w-full text-sm font-bold">انتخاب روش طراحی</Button>
<Button variant="outline" className="h-10 w-full text-sm font-bold">
استعلام قیمت
</Button>
</div>
</div>
</div>
);
};
export default ProductOrderActions;
@@ -0,0 +1,96 @@
"use client";
import Select from "@/app/components/Select";
import { cn } from "@/app/lib/cn";
import { InfoCircle } from "iconsax-reactjs";
import { type FC } from "react";
import { Rating } from "react-simple-star-rating";
import { BOX_BAND_OPTIONS, formatPrice, PAPER_TYPE_OPTIONS, PRODUCT_PACKAGE_SIZE, PRODUCT_RATING, PRODUCT_SHORT_DESCRIPTION, PRODUCT_TITLE, QUANTITY_TIERS, type QuantityTier } from "../constants";
type ProductPurchaseOptionsProps = {
selectedTier: QuantityTier;
onSelectTier: (tier: QuantityTier) => void;
};
const ProductPurchaseOptions: FC<ProductPurchaseOptionsProps> = ({ selectedTier, onSelectTier }) => {
return (
<div className="flex w-full min-w-0 flex-1 flex-col items-end gap-4">
<div className="w-full space-y-2 text-right">
<h1 className="text-xl font-bold text-[#0A1B2C] sm:text-2xl">{PRODUCT_TITLE}</h1>
<div className="flex items-center gap-2">
<Rating initialValue={PRODUCT_RATING} size={16} readonly rtl allowFraction SVGstyle={{ display: "inline-block" }} />
<span className="text-sm font-medium text-[#0A1B2C]">{PRODUCT_RATING}</span>
</div>
</div>
<p className="w-full whitespace-pre-line text-right text-xs leading-6 text-[#0A1B2C] sm:text-sm sm:leading-7">{PRODUCT_SHORT_DESCRIPTION}</p>
<div className="w-full space-y-0.5 text-right">
<p className="text-base font-bold text-[#0A1B2C] sm:text-lg">{formatPrice(selectedTier.totalPrice)}</p>
<p className="text-xs text-[#4D4D4D] sm:text-sm">
{formatPrice(selectedTier.unitPrice)} برای هر عدد ( بسته {formatPrice(PRODUCT_PACKAGE_SIZE)} عددی)
</p>
</div>
<div className="w-full space-y-3">
<Field label="جنس ورق">
<Select options={[...PAPER_TYPE_OPTIONS]} defaultValue={PAPER_TYPE_OPTIONS[0].value} className="h-10 rounded-xl border-[#E9EEF2] text-sm" />
</Field>
<Field label="بند جعبه">
<Select options={[...BOX_BAND_OPTIONS]} defaultValue={BOX_BAND_OPTIONS[0].value} className="h-10 rounded-xl border-[#E9EEF2] text-sm" />
</Field>
<div className="space-y-1.5">
<span className="block text-right text-xs font-medium text-[#0A1B2C] sm:text-sm">تعداد</span>
<div className="space-y-1.5">
{QUANTITY_TIERS.map((tier) => {
const isSelected = selectedTier.quantity === tier.quantity;
return (
<button
key={tier.quantity}
type="button"
onClick={() => onSelectTier(tier)}
className={cn(
"flex h-10 w-full items-center justify-between gap-2 rounded-xl border border-[#E9EEF2] px-3 transition-colors",
isSelected ? "bg-secondary" : "bg-white hover:bg-secondary/50",
)}
>
<span className="w-6 shrink-0 text-end text-xs font-medium text-[#0A1B2C]">{tier.quantity}</span>
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
<span className="text-[11px] text-[#0A1B2C] sm:text-xs">{formatPrice(tier.unitPrice)} / هر عدد</span>
<span className="text-xs font-medium text-[#0A1B2C]">{formatPrice(tier.totalPrice)}</span>
</div>
<span className={cn("w-6 shrink-0 text-start text-xs font-medium", tier.discount ? "text-[#DC0000]" : "text-[#0A1B2C]")}>{tier.discount ? `-${tier.discount}%` : tier.quantity}</span>
</button>
);
})}
</div>
<button type="button" className="flex items-center gap-1 text-[11px] text-[#21588C] sm:text-xs">
<InfoCircle size={14} color="currentColor" variant="Linear" />
تعداد بیشتر
</button>
</div>
</div>
</div>
);
};
type FieldProps = {
label: string;
children: React.ReactNode;
};
const Field: FC<FieldProps> = ({ label, children }) => (
<div className="space-y-1.5">
<span className="block text-right text-xs font-medium text-[#0A1B2C] sm:text-sm">{label}</span>
{children}
</div>
);
export default ProductPurchaseOptions;
+27
View File
@@ -0,0 +1,27 @@
import { Carousel, CarouselControls, CarouselSlide, CarouselTrack } from "@/app/components/Carousel";
import ProductCard from "@/app/home/components/ProductCard";
import { type FC } from "react";
import { RELATED_PRODUCTS } from "../constants";
const ProductRelated: FC = () => {
return (
<section className="mt-10 px-4 sm:mt-12 sm:px-8 lg:px-30">
<Carousel slidesPerView={{ base: 1.4, md: 3, lg: 5 }} gap={{ base: 12, md: 16, lg: 24 }}>
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-bold text-[#0A1B2C] sm:text-xl">محصولات مرتبط</h2>
<CarouselControls prevLabel="محصولات قبلی" nextLabel="محصولات بعدی" />
</div>
<CarouselTrack className="mt-6 px-2 py-4 -mx-2 sm:mt-8">
{RELATED_PRODUCTS.map((title) => (
<CarouselSlide key={title}>
<ProductCard title={title} />
</CarouselSlide>
))}
</CarouselTrack>
</Carousel>
</section>
);
};
export default ProductRelated;
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { cn } from "@/app/lib/cn";
import productImage from "@/assets/images/product_image.png";
import Image from "next/image";
import { useState, type FC } from "react";
import { PRODUCT_DESCRIPTION_CONTENT, PRODUCT_TABS, type ProductTabId } from "../constants";
const ProductTabs: FC = () => {
const [activeTab, setActiveTab] = useState<ProductTabId>("description");
return (
<section className="mt-8 sm:mt-10">
<div className="px-4 sm:px-8 lg:px-30">
<div className="overflow-x-auto border-b border-[#E9EEF2]">
<div className="flex min-w-max items-end justify-start gap-6 sm:gap-8 lg:gap-10">
{PRODUCT_TABS.map((tab) => {
const isActive = activeTab === tab.id;
return (
<button key={tab.id} type="button" onClick={() => setActiveTab(tab.id)} className="relative shrink-0">
<span className={cn("block pb-3 text-sm leading-5 transition-colors", isActive ? "font-bold text-[#0A1B2C]" : "font-normal text-[#3F4D5A]")}>{tab.label}</span>
{isActive && <span className="absolute bottom-0 left-1/2 h-0.75 w-[72%] -translate-x-1/2 rounded-full bg-primary" />}
</button>
);
})}
</div>
</div>
<div className="mt-6 rounded-2xl bg-[#F7FAFC] p-5 sm:mt-8 sm:p-6 lg:p-8">
{activeTab === "description" && <DescriptionPanel />}
{activeTab !== "description" && <p className="py-6 text-center text-xs text-[#53606B] sm:text-sm">محتوای این بخش بهزودی اضافه میشود.</p>}
</div>
</div>
</section>
);
};
const DescriptionPanel: FC = () => {
const { title, bullets, sections } = PRODUCT_DESCRIPTION_CONTENT;
return (
<div className="flex flex-col items-center gap-6 lg:flex-row lg:items-center lg:gap-12" dir="ltr">
<div className="mx-auto w-full max-w-85 shrink-0 rounded-2xl bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.08)] sm:max-w-[360px] lg:max-w-[400px] lg:p-4">
<div className="relative aspect-square w-full overflow-hidden rounded-xl">
<Image src={productImage} alt="" fill className="object-cover" sizes="(max-width: 1024px) 80vw, 400px" />
</div>
</div>
<div className="min-w-0 flex-1 space-y-4 text-right" dir="rtl">
<h2 className="text-sm font-bold leading-6 text-[#0A1B2C] sm:text-base sm:leading-7">{title}</h2>
<ul className="space-y-1.5 text-xs leading-6 text-[#0A1B2C] sm:text-sm sm:leading-7">
{bullets.map((item) => (
<li key={item} className="flex items-start gap-2">
<span className="mt-2 size-1 shrink-0 rounded-full bg-[#0A1B2C]" />
<span>{item}</span>
</li>
))}
</ul>
{sections.map((section) => (
<div key={section.title} className="space-y-1.5">
<h3 className="text-xs font-bold leading-6 text-[#0A1B2C] sm:text-sm sm:leading-7">{section.title}</h3>
<p className="text-xs leading-6 text-[#0A1B2C] sm:text-sm sm:leading-7">{section.body}</p>
</div>
))}
</div>
</div>
);
};
export default ProductTabs;
+99
View File
@@ -0,0 +1,99 @@
import productImage from "@/assets/images/product_image.png";
export const PRODUCT_TITLE = "جعبه کیبوردی آفتاب";
export const PRODUCT_SHORT_DESCRIPTION =
"جعبه کیبوردی، بسته‌بندی مطمئن و حرفه‌ای\nبا جعبه‌های کیبوردی اختصاصی، محصولات خود را با ظاهری شیک و مقاوم ارائه دهید. قابل سفارش در ابعاد مختلف، جنس‌های متنوع و چاپ اختصاصی با کیفیت بالا.";
export const PRODUCT_BASE_PRICE = 20_000_000;
export const PRODUCT_UNIT_PRICE = 2_000;
export const PRODUCT_PACKAGE_SIZE = 1_000;
export const PRODUCT_RATING = 4.5;
export const PRODUCT_PROFIT_PERCENT = 20;
export const BREADCRUMB_ITEMS = [
{ label: "صفحه اصلی", href: "/" },
{ label: "جعبه", href: "/category" },
{ label: PRODUCT_TITLE },
] as const;
export const PAPER_TYPE_OPTIONS = [
{ label: "سه لا E فلوت کرافت", value: "craft-e-flute" },
{ label: "سه لا یک رو سفید", value: "white-single" },
{ label: "سه لا E فلوت سفید", value: "white-e-flute" },
] as const;
export const BOX_BAND_OPTIONS = [
{ label: "بند دارد", value: "with-band" },
{ label: "بدون بند", value: "without-band" },
] as const;
export const STICKY_QUANTITY_OPTIONS = [
{ label: "۵۰ عدد", value: "50" },
{ label: "۱۰۰ عدد", value: "100" },
{ label: "۲۰۰ عدد", value: "200" },
{ label: "۳۰۰ عدد", value: "300" },
{ label: "۱۰۰۰ عدد", value: "1000" },
] as const;
export type QuantityTier = {
quantity: number;
unitPrice: number;
totalPrice: number;
discount?: number;
};
export const QUANTITY_TIERS: readonly QuantityTier[] = [
{ quantity: 50, unitPrice: 2_000, totalPrice: 1_000_000 },
{ quantity: 100, unitPrice: 1_900, totalPrice: 2_000_000, discount: 20 },
{ quantity: 200, unitPrice: 1_800, totalPrice: 4_000_000, discount: 23 },
{ quantity: 300, unitPrice: 1_700, totalPrice: 6_000_000, discount: 25 },
];
export const PRODUCT_GALLERY_IMAGES = [productImage, productImage, productImage, productImage, productImage] as const;
export const PRODUCT_TABS = [
{ id: "description", label: "توضیحات" },
{ id: "features", label: "ویژگی ها" },
{ id: "template", label: "قالب و مشخصات" },
{ id: "portfolio", label: "نمونه کار ها" },
{ id: "faq", label: "سوالات متدوال" },
] as const;
export type ProductTabId = (typeof PRODUCT_TABS)[number]["id"];
export const PRODUCT_DESCRIPTION_CONTENT = {
title: "جعبه کیبوردی؛ بسته‌بندی حرفه‌ای برای محافظت و معرفی برند",
bullets: [
"تولید در ابعاد استاندارد و سفارشی",
"چاپ اختصاصی یک‌رو یا دورو",
"قابل تولید با مقوای ایندربرد، پشت طوسی، کرافت و گلاسه",
"امکان استفاده از روکش سلفون مات، براق یا مخملی",
"قابلیت اجرای طلاکوب، نقره‌کوب، یووی موضعی و برجسته‌سازی",
"برش و قالب اختصاصی متناسب با محصول",
"مناسب برای انواع محصولات فروشگاهی، آرایشی، غذایی، پوشاک، الکترونیکی و هدایای تبلیغاتی",
],
sections: [
{
title: "بسته‌بندی که ارزش محصول شما را افزایش می‌دهد",
body: "جعبه کیبوردی یکی از پرکاربردترین انواع بسته‌بندی برای محصولات مختلف است. این نوع جعبه علاوه بر محافظت از محصول در برابر آسیب، ظاهری حرفه‌ای و جذاب به آن می‌بخشد. طراحی اختصاصی، چاپ باکیفیت و استفاده از متریال مناسب باعث می‌شود مشتری از همان اولین نگاه، کیفیت برند شما را احساس کند.",
},
{
title: "کاملاً سفارشی، متناسب با هویت برند شما",
body: "جعبه کیبوردی را دقیقاً مطابق نیاز کسب‌وکار خود طراحی کنید. ابعاد، نوع مقوا، روکش، رنگ، چاپ، قالب و خدمات تکمیلی همگی قابل شخصی‌سازی هستند تا بسته‌بندی نهایی کاملاً با هویت برند و نوع محصول شما هماهنگ باشد. نتیجه، بسته‌بندی‌ای است که علاوه بر محافظت از محصول، به ماندگاری نام برند شما در ذهن مشتری کمک می‌کند.",
},
],
};
export const RELATED_PRODUCTS = [
"بروشور",
"کارت ویزیت",
"پوستر",
"کتابچه",
"کاتالوگ",
"جعبه کیبوردی",
] as const;
export function formatPrice(value: number): string {
return value.toLocaleString("en-US");
}