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

912 lines
23 KiB
TypeScript

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckCircle, Info } from "lucide-react";
import { useCallback, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import z from "zod";
import QuantityInput from "@/components/shadcnblocks/quantity-input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty";
import { Field, FieldError } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const formatPrice = (price: number, currency = "IRR") =>
new Intl.NumberFormat("fa-IR", {
style: "currency",
currency,
}).format(price);
type UsefulLink = {
text: string;
link: string;
};
interface ProductPrice {
regular: number;
sale?: number;
currency: string;
}
type EmptyCartData = {
title: string;
description: string;
cta: {
link: string;
text: string;
};
};
type Option = {
id: string;
label: string;
value: string;
color?: string;
};
interface OptionsProps extends Partial<Variant> {
currentValue?: string;
}
interface Variant {
label: string;
id: string;
name: string;
options?: Option[];
type: "select" | "radio";
}
type EmptyCartProps = EmptyCartData;
type CartItem = {
product_id: string;
link: string;
name: string;
image: string;
price: ProductPrice;
details?: {
label: string;
value: string;
}[];
variants?: Partial<Record<"color" | "size", Variant>>;
sizeValue?: string;
colorValue?: string;
};
interface FullCartProps {
cartItems: CartItem[];
formItems: CartFormType["products"];
form: UseFormReturn<CartFormType>;
usefulLinks: UsefulLink[];
}
interface CartItemProps extends CartItem {
index: number;
form: ReturnType<typeof useForm<CartFormType>>;
onRemoveClick: () => void;
onQuantityChange: (newQty: number) => void;
}
interface UsefulLinksListProps {
links: UsefulLink[];
}
interface EditProductProps {
index: number;
variants?: Partial<Record<"color" | "size", Variant>>;
onCancel: () => void;
currentVariant: {
color?: string;
size?: string;
};
}
const EMPTY_CART_DATA = {
title: "سبد خرید شما خالی است",
description:
"سبد خرید شما خالی است؛ همه محصولات ما را ببینید و اولین خرید خود را ثبت کنید.",
cta: {
link: "#",
text: "مشاهده محصولات",
},
};
const CART_ITEMS: CartItem[] = [
{
product_id: "product-1",
link: "#",
name: "کفش ورزشی بژ مینیمال",
image:
"/block-media/shadcnblocks/block/ecommerce/clothes/Minimalist-Beige-Sneakers-2.png",
price: {
regular: 149.0,
currency: "IRR",
sale: 120.0,
},
details: [
{
label: "سایز",
value: "EU 36",
},
{
label: "رنگ",
value: "بژ",
},
],
sizeValue: "EU_36",
colorValue: "product-1-beige",
variants: {
size: {
label: "سایز",
name: "size",
id: "size",
type: "select",
options: [
{
label: "EU 36",
id: "product-1-EU_36",
value: "EU_36",
},
{
label: "EU 36.5",
id: "product-1-EU_36.5",
value: "EU_36.5",
},
{
label: "EU 37",
id: "product-1-EU_37",
value: "EU_37",
},
{
label: "EU 37.5",
id: "product-1-EU_37.5",
value: "EU_37.5",
},
{
label: "EU 39",
id: "product-1-EU_39",
value: "EU_39",
},
{
label: "EU 39.5",
id: "product-1-EU_39.5",
value: "EU_39.5",
},
{
label: "EU 41",
id: "product-1-EU_41",
value: "EU_41",
},
],
},
color: {
label: "رنگ",
name: "color",
id: "color",
type: "radio",
options: [
{
label: "مشکی",
id: "product-1-black",
value: "product-1-black",
color: "#000000",
},
{
label: "آبی",
id: "product-1-blue",
value: "product-1-blue",
color: "#1581BF",
},
{
label: "هلویی",
id: "product-1-peach",
value: "product-1-peach",
color: "#FF5555",
},
{
label: "بژ",
id: "product-1-beige",
value: "product-1-beige",
color: "#D7C097",
},
],
},
},
},
{
product_id: "product-2",
link: "#",
name: "بلوز آبی گلدوزی",
image:
"/block-media/shadcnblocks/block/ecommerce/clothes/Woman-in-Embroidered-Blue-Top-2.png",
price: {
regular: 140.0,
currency: "IRR",
},
details: [
{
label: "سایز",
value: "S",
},
{
label: "رنگ",
value: "آبی",
},
],
sizeValue: "S",
colorValue: "product-2-blue",
variants: {
size: {
label: "سایز",
name: "size",
id: "size",
type: "select",
options: [
{
label: "S",
id: "product-2-S",
value: "S",
},
{
label: "M",
id: "product-2-M",
value: "M",
},
{
label: "XL",
id: "product-XL",
value: "XL",
},
],
},
color: {
label: "رنگ",
name: "color",
id: "color",
type: "radio",
options: [
{
label: "مشکی",
id: "product-2-black",
value: "product-2-black",
color: "#000000",
},
{
label: "آبی",
id: "product-2-blue",
value: "product-2-blue",
color: "#1581BF",
},
{
label: "هلویی",
id: "product-2-peach",
value: "product-2-peach",
color: "#FF5555",
},
],
},
},
},
{
product_id: "product-3",
link: "#",
name: "کلاه فدورا کلاسیک",
image:
"/block-media/shadcnblocks/block/ecommerce/accessories/Classic-Fedora-Hat-2.png",
price: {
regular: 84.0,
currency: "IRR",
},
details: [
{
label: "رنگ",
value: "بژ",
},
],
colorValue: "product-1-beige",
variants: {
color: {
label: "رنگ",
name: "color",
id: "color",
type: "radio",
options: [
{
label: "مشکی",
id: "product-3-black",
value: "product-3-black",
color: "#000000",
},
{
label: "آبی",
id: "product-3-blue",
value: "product-3-blue",
color: "#1581BF",
},
{
label: "بژ",
id: "product-1-beige",
value: "product-1-beige",
color: "#D7C097",
},
],
},
},
},
];
const USEFUL_LINKS: UsefulLink[] = [
{
text: "بازگشت و تعویض",
link: "#",
},
{
text: "ارسال به منزل",
link: "#",
},
{
text: "گارانتی",
link: "#",
},
];
const cartFormSchema = z.object({
products: z
.object({
product_id: z.string(),
quantity: z.number(),
price: z.number(),
color: z.string(),
size: z.string(),
})
.array(),
});
type CartFormType = z.infer<typeof cartFormSchema>;
const ShoppingCart15 = ({
emptyCartData = EMPTY_CART_DATA,
cartItems = CART_ITEMS,
usefulLinks = USEFUL_LINKS,
}) => {
const { title, description, cta } = emptyCartData;
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 formItems = form.watch("products");
const isEmpty = formItems.length === 0;
return (
<section className="py-32">
<div className="container">
{isEmpty ? (
<EmptyCart title={title} description={description} cta={cta} />
) : (
<FullCart
usefulLinks={usefulLinks}
form={form}
formItems={formItems}
cartItems={cartItems}
/>
)}
</div>
</section>
);
};
const EmptyCart = ({ title, description, cta }: EmptyCartProps) => {
return (
<Empty className="gap-8">
<EmptyHeader className="gap-3">
<EmptyTitle className="text-xl">{title}</EmptyTitle>
<EmptyDescription className="text-lg text-balance">
{description}
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="gap-3.5">
<Button asChild size="lg">
<a href={cta.link}>{cta.text}</a>
</Button>
</EmptyContent>
</Empty>
);
};
const FullCart = ({
cartItems,
form,
formItems,
usefulLinks,
}: FullCartProps) => {
const itemsCount = formItems && formItems.length ? formItems.length : 0;
const totalPrice = formItems?.reduce(
(sum, p) => sum + p.price * p.quantity,
0,
);
const onSubmit = (data: CartFormType) => {
console.log(data);
};
const { fields, remove, update } = useFieldArray({
control: form.control,
name: "products",
});
const handleQuantityChange = useCallback(
(index: number) => (newQty: number) =>
update(index, { ...fields[index], quantity: newQty }),
[update, fields],
);
const handleRemove = useCallback(
(index: number) => () => {
remove(index);
},
[remove],
);
return (
<div className="space-y-4">
<div className="space-y-1 xl:px-16">
<h1 className="text-xl font-semibold">سبد خرید</h1>
<p className="font-medium text-muted-foreground">
{itemsCount.toLocaleString("fa-IR")} کالا
</p>
</div>
<div className="grid grid-cols-1 gap-10 lg:grid-cols-3">
<div className="col-span-2">
<div className="xl:px-16">
<div className="flex flex-col gap-6">
<Alert className="bg-muted">
<Info className="stroke-muted-foreground" />
<AlertDescription className="text-muted-foreground">
محصولات پس از تکمیل خرید برای شما رزرو می‌شوند.
</AlertDescription>
</Alert>
<div>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-5">
{fields.map((field, index) => (
<CartItem
key={field.id}
index={index}
form={form}
onRemoveClick={() => handleRemove(index)()}
onQuantityChange={(newQty: number) =>
handleQuantityChange(index)(newQty)
}
{...(cartItems.find(
(p) => p.product_id === field.product_id,
) as CartItem)}
/>
))}
</div>
</form>
</div>
</div>
</div>
</div>
<div className="col-span-1">
<div className="space-y-5 xl:px-16">
<PromoCode />
<div className="">
<dl className="flex items-center justify-between gap-2 pb-4 not-first:pt-4 not-last:border-b">
<dt className="text-sm">جمع جزء</dt>
<dd className="text-sm">
{formatPrice(totalPrice ?? 0, cartItems[0].price.currency)}
</dd>
</dl>
<dl className="flex items-center justify-between gap-2 pb-4 not-first:pt-4 not-last:border-b">
<dt className="text-sm">هزینه ارسال</dt>
<dd className="text-sm">
{formatPrice(30.0, cartItems[0].price.currency)}
</dd>
</dl>
<dl className="flex items-center justify-between gap-2 pb-4 not-first:pt-4 not-last:border-b">
<dt className="text-sm">مبلغ قابل پرداخت</dt>
<dd className="text-xl font-bold">
{formatPrice(totalPrice ?? 0, cartItems[0].price.currency)}
</dd>
</dl>
</div>
<div className="max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:mb-0">
<div className="max-lg:space-y-3 max-lg:border-t max-lg:bg-background max-lg:px-6 max-lg:py-4">
<div className="flex items-center justify-between gap-2 lg:hidden">
<p>
جمع جزء {itemsCount.toLocaleString("fa-IR")} کالا
</p>
<span className="text-base font-bold">
{formatPrice(totalPrice ?? 0, cartItems[0].price.currency)}
</span>
</div>
<Button size="lg" className="w-full">
ادامه خرید
</Button>
</div>
</div>
<UsefulLinksList links={usefulLinks} />
</div>
</div>
</div>
</div>
);
};
const CartItem = ({
image,
name,
details,
index,
form,
onQuantityChange,
onRemoveClick,
price,
link,
variants,
sizeValue,
colorValue,
}: CartItemProps) => {
const [openEdit, setOpenEdit] = useState(false);
const { regular, currency, sale } = price;
return (
<div>
<Card className="group relative border-none bg-transparent p-0 shadow-none">
<CardContent className="flex h-full gap-6 p-0 max-sm:flex-col">
<div className="shrink-0 grow-0 basis-53">
<AspectRatio ratio={1} className="overflow-hidden rounded-sm">
<img
src={image}
alt={name}
className="block size-full object-cover object-center"
/>
</AspectRatio>
</div>
<div className="flex flex-1 flex-col items-start justify-between gap-3 pt-4 md:pt-6">
<div className="flex w-full justify-between gap-2 max-sm:flex-col">
<div className="flex-1">
<div className="space-y-2">
<CardTitle className="text-sm leading-snug font-medium transition-colors duration-300 hover:text-primary">
<a href={link}>{name}</a>
</CardTitle>
<ProductDetails details={details} />
</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 text-lg font-semibold">
{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>
<QuantityField
form={form}
index={index}
onQuantityChange={onQuantityChange}
/>
<CardFooter className="gap-4 p-0">
<Button
variant="link"
className="h-fit p-0 text-xs text-muted-foreground underline"
onClick={onRemoveClick}
aria-label={`حذف ${name} از سبد`}
>
حذف
</Button>
<Button
variant="link"
type="button"
onClick={() => {
setOpenEdit((prev) => !prev);
}}
className="h-fit p-0 text-xs text-muted-foreground underline"
aria-label={`ویرایش ${name}`}
>
ویرایش
</Button>
</CardFooter>
</div>
</CardContent>
</Card>
<Collapsible open={openEdit}>
<CollapsibleContent>
<EditProduct
onCancel={() => setOpenEdit(false)}
variants={variants}
index={index}
currentVariant={{
size: sizeValue,
color: colorValue,
}}
/>
</CollapsibleContent>
</Collapsible>
</div>
);
};
const promoCodeFormSchema = z.object({
promoCode: z.string().trim().min(3),
});
type promoCodeFormType = z.infer<typeof promoCodeFormSchema>;
const PromoCode = () => {
const [showCodeForm, setShowCodeForm] = useState(false);
const promoForm = useForm<promoCodeFormType>({
resolver: zodResolver(promoCodeFormSchema),
defaultValues: {
promoCode: "",
},
});
const onSubmit = (data: promoCodeFormType) => {
console.log(data);
};
const onCancel = () => {
setShowCodeForm(false);
};
return (
<Collapsible open={showCodeForm} onOpenChange={setShowCodeForm}>
<CollapsibleTrigger asChild>
<Button variant="link" className="h-fit p-0 text-primary underline">
افزودن کد تخفیف
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 overflow-hidden data-[state=closed]:animate-slide-up data-[state=open]:animate-slide-down">
<form onSubmit={promoForm.handleSubmit(onSubmit)}>
<Controller
name="promoCode"
control={promoForm.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
id="promo-code"
placeholder="کد تخفیف"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<div className="flex items-center gap-4 pt-3">
<Button
onClick={onCancel}
type="button"
variant="outline"
className="flex-1"
>
انصراف
</Button>
<Button className="flex-1">افزودن</Button>
</div>
</form>
</CollapsibleContent>
</Collapsible>
);
};
const UsefulLinksList = ({ links }: UsefulLinksListProps) => {
if (!links) return;
return (
<ul className="space-y-3 py-5">
{links.map(({ link, text }) => (
<li key={crypto.randomUUID()}>
<Button
asChild
variant="link"
className="h-fit !p-0 text-muted-foreground underline"
>
<a href={link}>
<CheckCircle />
{text}
</a>
</Button>
</li>
))}
</ul>
);
};
const ProductDetails = ({
details,
}: {
details?: {
label: string;
value: string;
}[];
}) => {
if (!details) return;
return (
<ul className="flex flex-col gap-0.5">
{details?.map((item, index) => (
<li key={`product-details-${index}`}>
<dl className="text-xs text-muted-foreground">
<dt className="inline">{item.label}: </dt>
<dd className="inline">{item.value}</dd>
</dl>
</li>
))}
</ul>
);
};
const QuantityField = ({
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 EditProduct = ({
variants,
onCancel,
currentVariant,
}: EditProductProps) => {
const [variant, setVariant] = useState({
size: currentVariant.size,
color: currentVariant.color,
});
if (!variants) return;
return (
<div className="py-4">
<div className="space-y-6 border-y px-4 py-6">
<div className="flex w-full gap-6 max-sm:flex-col">
<ColorOptions currentValue={variant.color} {...variants.color} />
<SizeOptions currentValue={variant.size} {...variants.size} />
</div>
<div className="flex gap-3 max-sm:flex-col">
<Button
onClick={onCancel}
type="button"
variant="outline"
className="flex-1"
>
انصراف
</Button>
<Button type="button" className="flex-1">
ذخیره
</Button>
</div>
</div>
</div>
);
};
const ColorOptions = ({ options, label, currentValue, name }: OptionsProps) => {
if (!options) return;
return (
<div className="space-y-3">
<Label className="text-sm font-bold">{label}</Label>
<RadioGroup name={name} className="flex flex-wrap gap-3">
{options.map((option) => (
<Label
key={option.id}
className="block size-9 cursor-pointer has-checked:ring-2"
style={{
backgroundColor: option.color,
}}
>
<RadioGroupItem
className="sr-only"
value={option.value}
id={option.id}
defaultChecked={currentValue === option.value}
/>
</Label>
))}
</RadioGroup>
</div>
);
};
const SizeOptions = ({ options, label, currentValue, name }: OptionsProps) => {
if (!options) return;
const currentSizeLabel = options?.filter(
(item) => item.value === currentValue,
)[0]?.label;
return (
<div className="space-y-3">
<Label className="text-sm font-bold">{label}</Label>
<Select name={name}>
<SelectTrigger className="w-60">
<SelectValue placeholder={currentSizeLabel} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.id} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
export { ShoppingCart15 };