Files

1017 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDown, Minus, Plus, ShoppingBag, Trash } from "lucide-react";
import { useCallback, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import {
Controller,
FormProvider,
useFieldArray,
useForm,
useFormContext,
} from "react-hook-form";
import z from "zod";
import QuantityInput from "@/components/shadcnblocks/quantity-input";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface ProductPrice {
regular: number;
sale?: number;
currency: string;
}
type CartItem = {
product_id: string;
link: string;
name: string;
image: string;
price: ProductPrice;
quantity: number;
details: {
label: string;
value: string;
}[];
};
interface CartItemProps extends CartItem {
index: number;
onRemoveClick: () => void;
onQuantityChange: (newQty: number) => void;
}
interface CartProps {
cartItems: CartItem[];
form: UseFormReturn<CheckoutFormType>;
}
const formatPrice = (price: number, currency = "IRR") =>
new Intl.NumberFormat("fa-IR", {
style: "currency",
currency,
}).format(price);
const PAYMENT_METHODS = {
creditCard: "creditCard",
paypal: "paypal",
onlineBankTransfer: "onlineBankTransfer",
};
type PaymentMethod = keyof typeof PAYMENT_METHODS;
const CreditCardPayment = z.object({
method: z.literal(PAYMENT_METHODS.creditCard),
cardholderName: z.string(),
cardNumber: z.string(),
expiryDate: z
.string()
.regex(/^(0[1-9]|1[0-2])\/\d{2}$/, "فرمت نامعتبر (MM/YY)")
.refine((value) => {
const [mm, yy] = value.split("/").map(Number);
const now = new Date();
const currentMonth = now.getMonth() + 1;
const currentYear = now.getFullYear() % 100;
if (yy < currentYear) return false;
if (yy === currentYear && mm < currentMonth) return false;
return true;
}, "کارت منقضی شده است"),
cvc: z.string(),
});
const PayPalPayment = z.object({
method: z.literal(PAYMENT_METHODS.paypal),
payPalEmail: z.string(),
});
const BankTransferPayment = z.object({
method: z.literal(PAYMENT_METHODS.onlineBankTransfer),
bankName: z.string(),
accountNumber: z.string(),
});
const PaymentSchema = z.discriminatedUnion("method", [
CreditCardPayment,
PayPalPayment,
BankTransferPayment,
]);
const checkoutFormSchema = z.object({
contactInfo: z.object({
email: z.string(),
subscribe: z.boolean().optional(),
}),
address: z.object({
country: z.string(),
firstName: z.string(),
lastName: z.string(),
address: z.string(),
postalCode: z.string(),
city: z.string(),
phone: z.string(),
}),
shippingMethod: z.string(),
payment: PaymentSchema,
products: z
.object({
product_id: z.string(),
quantity: z.number(),
price: z.number(),
})
.array(),
});
type CheckoutFormType = z.infer<typeof checkoutFormSchema>;
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",
},
quantity: 1,
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",
},
quantity: 1,
details: [
{ label: "رنگ", value: "آبی و سفید" },
{ label: "سایز", value: "L" },
],
},
{
product_id: "product-4",
link: "#",
name: "کیف چرمی زرشکی",
image:
"/block-media/shadcnblocks/block/ecommerce/clothes/maroon-leather-handbag.png",
price: {
regular: 245.0,
currency: "IRR",
},
quantity: 1,
details: [{ label: "رنگ", value: "زرشکی" }],
},
];
interface Checkout3Props {
cartItems?: CartItem[];
className?: string;
}
const Checkout3 = ({ cartItems = CART_ITEMS, className }: Checkout3Props) => {
const defaultProducts = cartItems.map((item) => ({
product_id: item.product_id,
quantity: item.quantity,
price: item.price.sale ?? item.price.regular,
}));
const form = useForm({
resolver: zodResolver(checkoutFormSchema),
defaultValues: {
payment: {
method: PAYMENT_METHODS.creditCard,
},
products: defaultProducts,
},
});
const onSubmit = (data: CheckoutFormType) => {
console.log(data);
};
return (
<section dir="rtl" lang="fa" className={cn("py-16 md:py-32", className)}>
<div className="container max-w-lg">
<div className="mb-8 text-center">
<h1 className="text-xl font-bold tracking-tight md:text-3xl">
نهایی‌سازی خرید
</h1>
</div>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<CollapsibleCart form={form} cartItems={cartItems} />
<div className="space-y-6">
<section>
<h2 className="mb-4 text-lg font-semibold">اطلاعات تماس</h2>
<ContactFields />
</section>
<Separator />
<ShippingDeliveryAccordion />
<Separator />
<section>
<h2 className="mb-4 text-lg font-semibold">پرداخت نهایی</h2>
<PaymentFields />
</section>
</div>
<Button type="submit" size="lg" className="w-full text-base">
ثبت و پرداخت سفارش
</Button>
</form>
</FormProvider>
</div>
</section>
);
};
const ShippingDeliveryAccordion = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<section>
<Accordion
type="single"
collapsible
className="w-full"
value={isOpen ? "shipping-delivery" : undefined}
onValueChange={(value) => setIsOpen(value === "shipping-delivery")}
>
<AccordionItem value="shipping-delivery">
<AccordionTrigger className="py-0 text-lg font-semibold hover:no-underline [&>svg:last-child]:hidden [&[data-state=closed]>svg:nth-of-type(2)]:hidden [&[data-state=open]>svg:nth-of-type(1)]:hidden [&[data-state=open]>svg:nth-of-type(2)]:block">
ارسال و تحویل
<Plus className="pointer-events-none size-4 shrink-0 self-center text-muted-foreground" />
<Minus className="pointer-events-none hidden size-4 shrink-0 self-center text-muted-foreground" />
</AccordionTrigger>
<AccordionContent className="px-1 pb-4">
<div className="space-y-6">
<AddressFields />
<ShippingMethodFields />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</section>
);
};
const CollapsibleCart = ({ cartItems, form }: CartProps) => {
const [isOpen, setIsOpen] = useState(false);
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 itemCount = formItems?.reduce((sum, p) => sum + p.quantity, 0) || 0;
const handleRemove = useCallback(
(index: number) => () => {
remove(index);
},
[remove],
);
const handleQuantityChange = useCallback(
(index: number) => (newQty: number) =>
update(index, { ...fields[index], quantity: newQty }),
[update, fields],
);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="outline"
className="h-auto w-full justify-between py-4"
>
<div className="flex items-center gap-3">
<ShoppingBag className="size-5" />
<span className="font-medium">
{itemCount.toLocaleString("fa-IR")} کالا
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold tabular-nums">
{formatPrice(totalPrice ?? 0, cartItems[0]?.price.currency)}
</span>
<ChevronDown
className={cn(
"size-4 transition-transform",
isOpen && "rotate-180",
)}
/>
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-4 space-y-4 rounded-lg border p-4">
<ul className="space-y-4">
{fields.map((field, index) => {
const cartItem = cartItems.find(
(p) => p.product_id === field.product_id,
);
if (!cartItem) return null;
return (
<li key={field.id}>
<CartItemComponent
{...cartItem}
onRemoveClick={() => handleRemove(index)()}
onQuantityChange={(newQty: number) =>
handleQuantityChange(index)(newQty)
}
index={index}
/>
</li>
);
})}
</ul>
<Separator />
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">جمع سفارش</span>
<span className="tabular-nums">
{formatPrice(totalPrice ?? 0, cartItems[0]?.price.currency)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">هزینه ارسال</span>
<span>پس از انتخاب روش ارسال محاسبه می‌شود</span>
</div>
</div>
<Separator />
<div className="flex justify-between font-medium">
<span>مبلغ قابل پرداخت</span>
<span className="tabular-nums">
{formatPrice(totalPrice ?? 0, cartItems[0]?.price.currency)}
</span>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};
const CartItemComponent = ({
image,
name,
link,
details,
price,
index,
onQuantityChange,
onRemoveClick,
}: CartItemProps) => {
const { regular, currency } = price;
return (
<div className="flex gap-4">
<div className="w-16 shrink-0">
<AspectRatio ratio={1} className="overflow-hidden rounded-md bg-muted">
<img src={image} alt={name} className="size-full object-cover" />
</AspectRatio>
</div>
<div className="min-w-0 flex-1">
<div className="flex justify-between gap-2">
<div>
<a
href={link}
className="line-clamp-1 text-sm font-medium hover:underline"
>
{name}
</a>
<ProductDetails details={details} />
</div>
<Button
size="icon"
variant="ghost"
className="size-7 shrink-0"
onClick={onRemoveClick}
aria-label={`حذف ${name} از سبد`}
>
<Trash className="size-3.5" />
<span className="sr-only">حذف از سبد</span>
</Button>
</div>
<div className="mt-2 flex items-center justify-between">
<QuantityField index={index} onQuantityChange={onQuantityChange} />
<span className="text-sm font-medium tabular-nums">
{formatPrice(regular, currency)}
</span>
</div>
</div>
</div>
);
};
const ProductDetails = ({
details,
}: {
details?: { label: string; value: string }[];
}) => {
if (!details) return null;
return (
<p className="text-xs text-muted-foreground">
{details.map((item, index) => (
<span key={index}>
{item.value}
{index < details.length - 1 && " / "}
</span>
))}
</p>
);
};
const QuantityField = ({
index,
onQuantityChange,
}: {
index: number;
onQuantityChange: (n: number) => void;
}) => {
const { control } = useFormContext();
return (
<Controller
name={`products.${index}.quantity`}
control={control}
render={({ field }) => (
<Field className="w-30">
<QuantityInput
inputProps={field}
onValueChange={(newQty) => {
field.onChange(newQty);
onQuantityChange(newQty);
}}
className="h-8 rounded-none"
/>
</Field>
)}
/>
);
};
const ContactFields = () => {
const form = useFormContext();
return (
<FieldGroup className="gap-4">
<Controller
name="contactInfo.email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-email"
>
ایمیل
</FieldLabel>
<Input
{...field}
id="checkout-email"
type="email"
dir="rtl"
placeholder="example@email.com"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="contactInfo.subscribe"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="checkout-subscribe"
name={field.name}
// @ts-ignore
checked={field.value}
onCheckedChange={field.onChange}
/>
<FieldLabel
htmlFor="checkout-subscribe"
className="text-sm font-normal"
>
دریافت اخبار و پیشنهادها از طریق ایمیل
</FieldLabel>
</Field>
)}
/>
</FieldGroup>
);
};
const AddressFields = () => {
const form = useFormContext();
return (
<FieldGroup className="gap-4">
<Controller
name="address.country"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-country"
>
کشور
</FieldLabel>
<Input
{...field}
id="checkout-country"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Controller
name="address.firstName"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-firstName"
>
نام
</FieldLabel>
<Input
{...field}
id="checkout-firstName"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="address.lastName"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-lastName"
>
نام خانوادگی
</FieldLabel>
<Input
{...field}
id="checkout-lastName"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
</div>
<Controller
name="address.address"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-address"
>
آدرس
</FieldLabel>
<Input
{...field}
id="checkout-address"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<Controller
name="address.postalCode"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-postalCode"
>
کد پستی
</FieldLabel>
<Input
{...field}
id="checkout-postalCode"
dir="rtl"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
name="address.city"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-city"
>
شهر
</FieldLabel>
<Input
{...field}
id="checkout-city"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
</div>
<Controller
name="address.phone"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-phone"
>
تلفن
</FieldLabel>
<Input
{...field}
id="checkout-phone"
type="tel"
dir="rtl"
placeholder="09123456789"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
</FieldGroup>
);
};
const ShippingMethodFields = () => {
const form = useFormContext();
return (
<Controller
name="shippingMethod"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<RadioGroup
name={field.name}
value={field.value}
onValueChange={field.onChange}
className="gap-3"
>
<FieldLabel htmlFor="checkout-shippingMethod-1">
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="rounded-lg border p-4"
>
<RadioGroupItem
value="UPS"
id="checkout-shippingMethod-1"
aria-invalid={fieldState.invalid}
/>
<FieldContent className="flex-1">
<FieldTitle>ارسال سریع</FieldTitle>
<FieldDescription>تحویل در اولین روز کاری</FieldDescription>
</FieldContent>
<p className="text-sm font-medium tabular-nums">
{formatPrice(10, "IRR")}
</p>
</Field>
</FieldLabel>
<FieldLabel htmlFor="checkout-shippingMethod-2">
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="rounded-lg border p-4"
>
<RadioGroupItem
value="Standard"
id="checkout-shippingMethod-2"
aria-invalid={fieldState.invalid}
/>
<FieldContent className="flex-1">
<FieldTitle>ارسال عادی</FieldTitle>
<FieldDescription>تحویل طی ۳ تا ۵ روز کاری</FieldDescription>
</FieldContent>
<p className="text-sm font-medium text-green-600">بدون هزینه</p>
</Field>
</FieldLabel>
</RadioGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
);
};
const PaymentFields = () => {
const form = useFormContext();
const paymentMethod = form.watch("payment.method") as PaymentMethod;
return (
<div className="space-y-6">
<Controller
name="payment.method"
control={form.control}
render={({ field, fieldState }) => (
<Field>
<RadioGroup
name={field.name}
value={field.value}
onValueChange={field.onChange}
className="gap-3"
>
<FieldLabel htmlFor="checkout-payment-method-1">
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="rounded-lg border p-4"
>
<RadioGroupItem
value="creditCard"
id="checkout-payment-method-1"
aria-invalid={fieldState.invalid}
/>
<FieldContent className="flex-1">
<FieldTitle>کارت بانکی</FieldTitle>
</FieldContent>
<img
src="/block-media/shadcnblocks/block/logos/visa-icon.svg"
alt="کارت بانکی"
className="size-5"
/>
</Field>
</FieldLabel>
<FieldLabel htmlFor="checkout-payment-method-2">
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="rounded-lg border p-4"
>
<RadioGroupItem
value="paypal"
id="checkout-payment-method-2"
aria-invalid={fieldState.invalid}
/>
<FieldContent className="flex-1">
<FieldTitle>کیف پول دیجیتال</FieldTitle>
</FieldContent>
<img
src="/block-media/shadcnblocks/block/logos/paypal-icon.svg"
alt="PayPal"
className="size-5"
/>
</Field>
</FieldLabel>
</RadioGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<PaymentFieldsByMethod method={paymentMethod} />
</div>
);
};
const PaymentFieldsByMethod = ({ method }: { method: PaymentMethod }) => {
const form = useFormContext();
if (!method) return null;
switch (method) {
case PAYMENT_METHODS.creditCard:
return (
<div className="space-y-4">
<Controller
name="payment.cardholderName"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-payment-cardholderName"
>
نام روی کارت
</FieldLabel>
<Input
{...field}
id="checkout-payment-cardholderName"
placeholder="نام و نام خانوادگی"
className="h-12"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="payment.cardNumber"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-payment-cardNumber"
>
شماره کارت
</FieldLabel>
<Input
{...field}
id="checkout-payment-cardNumber"
dir="rtl"
className="h-12"
placeholder="1234 5678 9012 3456"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<div className="grid grid-cols-2 gap-4">
<DateInput />
<Controller
name="payment.cvc"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-payment-cvc"
>
کد امنیتی (CVC)
</FieldLabel>
<Input
{...field}
id="checkout-payment-cvc"
dir="rtl"
className="h-12"
placeholder="123"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</div>
</div>
);
case PAYMENT_METHODS.paypal:
return (
<Controller
name="payment.payPalEmail"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-payment-payPalEmail"
>
ایمیل کیف پول دیجیتال
</FieldLabel>
<Input
{...field}
type="email"
dir="rtl"
className="h-12"
placeholder="example@email.com"
id="checkout-payment-payPalEmail"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
);
default:
return null;
}
};
const DateInput = () => {
const form = useFormContext();
return (
<Controller
name="payment.expiryDate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel
className="text-sm font-normal"
htmlFor="checkout-payment-expiryDate"
>
تاریخ انقضا
</FieldLabel>
<Input
{...field}
className="h-12"
onChange={(e) => {
let val = e.target.value;
val = val.replace(/[^0-9/]/g, "");
const prev = field.value ?? "";
const isDeleting = val.length < prev.length;
if (!isDeleting) {
if (val.length === 2 && !val.includes("/")) {
val = val + "/";
}
}
if (val.length > 5) {
val = val.slice(0, 5);
}
field.onChange(val);
}}
pattern="^(0[1-9]|1[0-2])/[0-9]{2}$"
placeholder="MM/YY"
dir="rtl"
id="checkout-payment-expiryDate"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
);
};
export { Checkout3 };