Files
reui-blocks/blocks/shopping-cart16.tsx
T

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 };