648 lines
18 KiB
TypeScript
648 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import clsx from "clsx";
|
|
import { ChevronUp, ShoppingBag, TicketPercent, X } from "lucide-react";
|
|
import { Fragment, memo, useCallback, useState } from "react";
|
|
import { useFieldArray, useForm } from "react-hook-form";
|
|
import { Controller } from "react-hook-form";
|
|
import z from "zod";
|
|
|
|
import QuantityInput from "@/components/shadcnblocks/quantity-input";
|
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import {
|
|
Carousel,
|
|
CarouselContent,
|
|
CarouselItem,
|
|
} from "@/components/ui/carousel";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
import { Field } from "@/components/ui/field";
|
|
import {
|
|
Sheet,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
|
|
const formatPrice = (price: number, currency = "IRR") =>
|
|
new Intl.NumberFormat("fa-IR", {
|
|
style: "currency",
|
|
currency,
|
|
}).format(price);
|
|
|
|
interface ProductPrice {
|
|
regular: number;
|
|
sale?: number;
|
|
currency: string;
|
|
}
|
|
|
|
type CartItem = {
|
|
product_id: string;
|
|
link: string;
|
|
name: string;
|
|
image: string;
|
|
price: ProductPrice;
|
|
details: {
|
|
label: string;
|
|
value: string;
|
|
}[];
|
|
};
|
|
|
|
type SuggestedProduct = {
|
|
name: string;
|
|
image: string;
|
|
price: ProductPrice;
|
|
link: string;
|
|
};
|
|
|
|
interface ShoppingCart16Props {
|
|
cartItems: CartItem[];
|
|
suggestedProducts: SuggestedProduct[];
|
|
}
|
|
|
|
interface CartItemProps extends CartItem {
|
|
index: number;
|
|
form: ReturnType<typeof useForm<CartFormType>>;
|
|
onRemoveClick: () => void;
|
|
onQuantityChange: (newQty: number) => void;
|
|
}
|
|
|
|
interface suggestedProductsProps {
|
|
products: SuggestedProduct[];
|
|
}
|
|
|
|
type ProductCardProp = SuggestedProduct;
|
|
|
|
interface CartItemActionsProps {
|
|
index: number;
|
|
form: ReturnType<typeof useForm<CartFormType>>;
|
|
onRemoveClick: () => void;
|
|
onQuantityChange: (newQty: number) => void;
|
|
}
|
|
|
|
const CART_ITEMS: CartItem[] = [
|
|
{
|
|
product_id: "product-1",
|
|
link: "#",
|
|
name: "کفش اسپرت زرشکی",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/stylish-maroon-sneaker.png",
|
|
price: {
|
|
regular: 354.0,
|
|
currency: "IRR",
|
|
},
|
|
details: [
|
|
{
|
|
label: "رنگ",
|
|
value: "قرمز",
|
|
},
|
|
{
|
|
label: "سایز",
|
|
value: "36",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
product_id: "product-2",
|
|
link: "#",
|
|
name: "سوئیشرت دو رنگ با لوگوی گلدوزی",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/bicolor-crewneck-sweatshirt-with-embroidered-logo.png",
|
|
price: {
|
|
regular: 499.0,
|
|
currency: "IRR",
|
|
},
|
|
details: [
|
|
{
|
|
label: "رنگ",
|
|
value: "آبی و سفید",
|
|
},
|
|
{
|
|
label: "سایز",
|
|
value: "L",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
product_id: "product-3",
|
|
link: "#",
|
|
name: "هودی مشکی",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/black-hoodie-against-light-background.png",
|
|
price: {
|
|
regular: 84.0,
|
|
currency: "IRR",
|
|
},
|
|
details: [
|
|
{
|
|
label: "رنگ",
|
|
value: "مشکی",
|
|
},
|
|
{
|
|
label: "سایز",
|
|
value: "XL",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
product_id: "product-4",
|
|
link: "#",
|
|
name: "کیف چرمی زرشکی",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/maroon-leather-handbag.png",
|
|
price: {
|
|
regular: 245.0,
|
|
currency: "IRR",
|
|
},
|
|
details: [
|
|
{
|
|
label: "رنگ",
|
|
value: "زرشکی",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
product_id: "product-5",
|
|
link: "#",
|
|
name: "کلاه فدورا کلاسیک",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/accessories/Classic-Fedora-Hat-1.png",
|
|
price: {
|
|
regular: 499.0,
|
|
currency: "IRR",
|
|
},
|
|
details: [
|
|
{
|
|
label: "رنگ",
|
|
value: "بژ",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const SUGGESTED_PRODUCTS: SuggestedProduct[] = [
|
|
{
|
|
name: "تیشرت سفید",
|
|
link: "#",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Tattooed-Streetwear-Look-2.png",
|
|
price: {
|
|
regular: 88.0,
|
|
currency: "IRR",
|
|
},
|
|
},
|
|
{
|
|
name: "هودی کلاسیک شهری",
|
|
link: "#",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Man-in-White-Hoodie-2.png",
|
|
price: {
|
|
regular: 499.0,
|
|
currency: "IRR",
|
|
},
|
|
},
|
|
{
|
|
name: "هودی استریتلاین",
|
|
link: "#",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Blue-Hoodie-2.png",
|
|
price: {
|
|
regular: 499.0,
|
|
currency: "IRR",
|
|
},
|
|
},
|
|
{
|
|
name: "هودی خیابانی هریتیج",
|
|
link: "#",
|
|
image:
|
|
"/block-media/shadcnblocks/block/ecommerce/clothes/Young-Man-in-Maroon-Hoodie-2.png",
|
|
price: {
|
|
regular: 499.0,
|
|
currency: "IRR",
|
|
},
|
|
},
|
|
];
|
|
|
|
const cartFormSchema = z.object({
|
|
products: z
|
|
.object({
|
|
product_id: z.string(),
|
|
quantity: z.number(),
|
|
price: z.number(),
|
|
})
|
|
.array(),
|
|
});
|
|
|
|
type CartFormType = z.infer<typeof cartFormSchema>;
|
|
|
|
const ShoppingCart16 = ({
|
|
cartItems = CART_ITEMS,
|
|
suggestedProducts = SUGGESTED_PRODUCTS,
|
|
}: ShoppingCart16Props) => {
|
|
const defaultProducts = cartItems.map((item) => ({
|
|
product_id: item.product_id,
|
|
quantity: 1,
|
|
price: item.price.sale ?? item.price.regular,
|
|
}));
|
|
|
|
const form = useForm<CartFormType>({
|
|
resolver: zodResolver(cartFormSchema),
|
|
defaultValues: {
|
|
products: defaultProducts,
|
|
},
|
|
});
|
|
|
|
const { fields, remove, update } = useFieldArray({
|
|
control: form.control,
|
|
name: "products",
|
|
});
|
|
|
|
const formItems = form.watch("products");
|
|
|
|
const totalPrice = formItems?.reduce(
|
|
(sum, p) => sum + p.price * p.quantity,
|
|
0,
|
|
);
|
|
|
|
const itemsCount = formItems && formItems.length ? formItems.length : 0;
|
|
|
|
const onSubmit = (data: CartFormType) => {
|
|
console.log(data);
|
|
};
|
|
|
|
const handleRemove = useCallback(
|
|
(index: number) => () => {
|
|
remove(index);
|
|
},
|
|
[remove],
|
|
);
|
|
|
|
const handleQuantityChange = useCallback(
|
|
(index: number) => (newQty: number) =>
|
|
update(index, { ...fields[index], quantity: newQty }),
|
|
[update, fields],
|
|
);
|
|
|
|
return (
|
|
<Sheet defaultOpen>
|
|
<SheetContent
|
|
aria-describedby={undefined}
|
|
className="w-[90dvw] gap-0 sm:max-w-120 lg:max-w-150 [&>button]:hidden"
|
|
>
|
|
{itemsCount === 0 && (
|
|
<div className="absolute end-5 top-5">
|
|
<SheetClose asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-lg"
|
|
className="rounded-full"
|
|
aria-label="بستن"
|
|
>
|
|
<X className="size-6.5" />
|
|
</Button>
|
|
</SheetClose>
|
|
</div>
|
|
)}
|
|
{itemsCount > 0 && (
|
|
<Fragment>
|
|
<SheetHeader className="flex flex-row items-center justify-between gap-4 bg-black px-3 py-3 md:px-5 lg:px-7.5">
|
|
<SheetTitle className="text-lg font-medium text-white">
|
|
سبد خرید ({itemsCount.toLocaleString("fa-IR")})
|
|
</SheetTitle>
|
|
<SheetClose asChild>
|
|
<Button
|
|
variant="secondary"
|
|
size="icon-sm"
|
|
className="rounded-full"
|
|
aria-label="بستن"
|
|
>
|
|
<X className="size-4.5" />
|
|
</Button>
|
|
</SheetClose>
|
|
</SheetHeader>
|
|
<form className="contents" onSubmit={form.handleSubmit(onSubmit)}>
|
|
<div className="h-full overflow-auto px-3 py-4 md:px-5 lg:px-7.5">
|
|
<ul className="space-y-5">
|
|
{fields.map((field, index) => {
|
|
return (
|
|
<li key={field.id}>
|
|
<CartItem
|
|
{...(cartItems.find(
|
|
(p) => p.product_id === field.product_id,
|
|
) as CartItem)}
|
|
form={form}
|
|
onRemoveClick={() => handleRemove(index)()}
|
|
onQuantityChange={(newQty: number) =>
|
|
handleQuantityChange(index)(newQty)
|
|
}
|
|
index={index}
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
<SuggestedProducts products={suggestedProducts} />
|
|
<SheetFooter className="mt-0 block border-t px-3 py-2.5 md:px-5 lg:px-7.5">
|
|
<div className="space-y-3.5">
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<TicketPercent className="size-4.5" />
|
|
<p className="text-xs leading-normal font-medium">
|
|
FIRST10 ({formatPrice(20)} تخفیف)
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap justify-between gap-2">
|
|
<div className="text-sm font-medium">هزینه ارسال</div>
|
|
<div className="text-sm font-medium">
|
|
خرید بالای{" "}
|
|
<b>{formatPrice(150, cartItems[0]?.price.currency)}</b>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap justify-between gap-2">
|
|
<h3 className="text-base leading-none font-medium">
|
|
مبلغ قابل پرداخت
|
|
</h3>
|
|
<div className="text-lg leading-none font-medium">
|
|
{formatPrice(
|
|
totalPrice ?? 0,
|
|
cartItems[0]?.price.currency,
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2.5">
|
|
<Button
|
|
size="lg"
|
|
variant="outline"
|
|
className="flex-1 rounded-full"
|
|
type="submit"
|
|
>
|
|
ادامه خرید
|
|
</Button>
|
|
<Button
|
|
size="lg"
|
|
className="flex-1 rounded-full"
|
|
type="button"
|
|
>
|
|
مشاهده سبد خرید
|
|
</Button>
|
|
</div>
|
|
<small className="leading-normal font-medium text-muted-foreground">
|
|
مالیات، تخفیف و هزینه ارسال در صفحه پرداخت محاسبه میشود
|
|
</small>
|
|
</div>
|
|
</SheetFooter>
|
|
</form>
|
|
</Fragment>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
};
|
|
|
|
const CartItem = ({
|
|
image,
|
|
name,
|
|
link,
|
|
details,
|
|
price,
|
|
index,
|
|
form,
|
|
onRemoveClick,
|
|
onQuantityChange,
|
|
}: CartItemProps) => {
|
|
const { regular, sale, currency } = price;
|
|
|
|
console.log(index);
|
|
return (
|
|
<div className="flex gap-4">
|
|
<div className="basis-21 lg:basis-27.5">
|
|
<a href={link}>
|
|
<AspectRatio
|
|
ratio={0.833353128}
|
|
className="overflow-hidden rounded-md"
|
|
>
|
|
<img
|
|
src={image}
|
|
alt={name}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</AspectRatio>
|
|
</a>
|
|
</div>
|
|
<div className="max-sm:space-y-2.5 sm:contents">
|
|
<div className="flex-1 space-y-2">
|
|
<h4 className="text-sm font-medium transition-colors hover:text-foreground/80 lg:text-base">
|
|
<a href={link}>{name}</a>
|
|
</h4>
|
|
<ul className="flex flex-wrap gap-3">
|
|
{details.map((item, index) => (
|
|
<li key={`product-details-${index}`}>
|
|
<dl className="text-sm font-medium">
|
|
<dt className="inline">{item.label}: </dt>
|
|
<dd className="inline text-muted-foreground">{item.value}</dd>
|
|
</dl>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="flex flex-wrap items-center gap-x-1 pt-1.5 text-base font-medium">
|
|
{sale != null && (
|
|
<span className="leading-tight text-rose-600">
|
|
{formatPrice(sale, currency)}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={
|
|
sale != null
|
|
? "leading-tight text-muted-foreground line-through"
|
|
: "leading-tight"
|
|
}
|
|
>
|
|
{formatPrice(regular, currency)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<CartItemActions
|
|
form={form}
|
|
onRemoveClick={onRemoveClick}
|
|
index={index}
|
|
onQuantityChange={onQuantityChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CartItemActions = memo(
|
|
({ onRemoveClick, index, form, onQuantityChange }: CartItemActionsProps) => {
|
|
return (
|
|
<div className="flex flex-col justify-between gap-4 sm:items-end">
|
|
<Button
|
|
variant="link"
|
|
type="button"
|
|
className="h-fit w-fit px-0 py-0 text-muted-foreground hover:underline"
|
|
onClick={onRemoveClick}
|
|
aria-label={`حذف از سبد`}
|
|
>
|
|
حذف
|
|
</Button>
|
|
<div>
|
|
<QuantityField
|
|
form={form}
|
|
index={index}
|
|
onQuantityChange={onQuantityChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
(prev, next) =>
|
|
prev.onRemoveClick !== next.onRemoveClick &&
|
|
prev.onQuantityChange !== next.onQuantityChange,
|
|
);
|
|
|
|
const QuantityField = memo(
|
|
({
|
|
form,
|
|
index,
|
|
onQuantityChange,
|
|
}: {
|
|
form: ReturnType<typeof useForm<CartFormType>>;
|
|
index: number;
|
|
onQuantityChange: (n: number) => void;
|
|
}) => {
|
|
return (
|
|
<Controller
|
|
control={form.control}
|
|
name={`products.${index}.quantity`}
|
|
render={({ field }) => (
|
|
<Field className="gap-3">
|
|
<div className="w-full max-w-28">
|
|
<QuantityInput
|
|
inputProps={field}
|
|
onValueChange={(newQty) => {
|
|
field.onChange(newQty);
|
|
onQuantityChange(newQty);
|
|
}}
|
|
/>
|
|
</div>
|
|
</Field>
|
|
)}
|
|
/>
|
|
);
|
|
},
|
|
);
|
|
|
|
const ProductCard = ({ image, name, price, link }: ProductCardProp) => {
|
|
const { regular, sale, currency } = price;
|
|
|
|
return (
|
|
<Card className="h-full p-0">
|
|
<CardContent className="flex gap-2.5 p-2.5">
|
|
<div className="basis-20">
|
|
<a href={link}>
|
|
<AspectRatio ratio={0.8} className="overflow-hidden rounded-xl">
|
|
<img
|
|
src={image}
|
|
alt={name}
|
|
className="block size-full object-cover object-center"
|
|
/>
|
|
</AspectRatio>
|
|
</a>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="space-y-1">
|
|
<a
|
|
href={link}
|
|
className="inline-block text-sm leading-snug font-medium transition-colors duration-300 hover:text-rose-600 md:text-base"
|
|
>
|
|
{name}
|
|
</a>
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-x-1 text-sm font-medium sm:text-base">
|
|
{sale != null && (
|
|
<span className="leading-tight text-rose-600">
|
|
{formatPrice(sale, currency)}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={
|
|
sale != null
|
|
? "leading-tight text-muted-foreground line-through"
|
|
: "leading-tight"
|
|
}
|
|
>
|
|
{formatPrice(regular, currency)}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
size="icon"
|
|
className="rounded-full"
|
|
aria-label={`افزودن ${name} به سبد`}
|
|
>
|
|
<ShoppingBag className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
const SuggestedProducts = ({ products }: suggestedProductsProps) => {
|
|
const [open, setOpen] = useState(true);
|
|
|
|
if (!products) return;
|
|
|
|
return (
|
|
<Collapsible open={open} onOpenChange={setOpen} className="border-t">
|
|
<CollapsibleTrigger className="flex w-full justify-between px-3 pt-4 pb-3 md:px-5 lg:px-7.5">
|
|
<h3 className="text-sm leading-relaxed font-medium">
|
|
پیشنهاد برای شما
|
|
</h3>
|
|
<ChevronUp
|
|
className={clsx(
|
|
"size-4 stroke-muted-foreground transition-transform duration-300 rtl:rotate-180",
|
|
open ? "rotate-0" : "rotate-180",
|
|
)}
|
|
/>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="px-3 pb-4 md:px-5 lg:px-7.5">
|
|
<div className="-mx-3 md:-mx-5 lg:-mx-7.5">
|
|
<div className="overflow-hidden px-7.5">
|
|
<Carousel
|
|
opts={{
|
|
align: "start",
|
|
}}
|
|
className="[&>div]:overflow-visible"
|
|
>
|
|
<CarouselContent>
|
|
{products.map((product, index) => (
|
|
<CarouselItem
|
|
className="basis-[75dvw] sm:basis-95"
|
|
key={`product-img-${index}`}
|
|
>
|
|
<ProductCard {...product} />
|
|
</CarouselItem>
|
|
))}
|
|
</CarouselContent>
|
|
</Carousel>
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
};
|
|
|
|
export { ShoppingCart16 };
|