Spaces:
Running
Running
import { useState } from "react"; | |
import { useLocation } from "wouter"; | |
import { useAuth } from "@/hooks/use-auth"; | |
import { useCart } from "@/hooks/use-cart"; | |
import { ordersApi } from "@/lib/api"; | |
import { useForm } from "react-hook-form"; | |
import { zodResolver } from "@hookform/resolvers/zod"; | |
import { z } from "zod"; | |
import Header from "@/components/layout/header"; | |
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
import { Button } from "@/components/ui/button"; | |
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; | |
import { Input } from "@/components/ui/input"; | |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; | |
import { Separator } from "@/components/ui/separator"; | |
import { useToast } from "@/hooks/use-toast"; | |
import { Loader2, CreditCard, Shield, IndianRupee, Smartphone } from "lucide-react"; | |
import { INDIAN_STATES, PAYMENT_METHODS, DEFAULT_COUNTRY } from "@/lib/constants"; | |
const checkoutSchema = z.object({ | |
firstName: z.string().min(1, "First name is required"), | |
lastName: z.string().min(1, "Last name is required"), | |
email: z.string().email("Valid email is required"), | |
phone: z.string().min(10, "Phone number is required"), | |
street: z.string().min(1, "Street address is required"), | |
city: z.string().min(1, "City is required"), | |
state: z.string().min(1, "State is required"), | |
pinCode: z.string().min(6, "PIN code is required").max(6, "PIN code must be 6 digits"), | |
country: z.string().min(1, "Country is required"), | |
paymentMethod: z.enum(["cod", "upi"], { required_error: "Please select a payment method" }), | |
upiId: z.string().optional(), | |
}).refine((data) => { | |
if (data.paymentMethod === "upi" && (!data.upiId || data.upiId.length === 0)) { | |
return false; | |
} | |
return true; | |
}, { | |
message: "UPI ID is required for UPI payment", | |
path: ["upiId"] | |
}); | |
type CheckoutFormData = z.infer<typeof checkoutSchema>; | |
export default function Checkout() { | |
const [, setLocation] = useLocation(); | |
const { user } = useAuth(); | |
const { items, subtotal, tax, total, clearCart } = useCart(); | |
const { toast } = useToast(); | |
const [isLoading, setIsLoading] = useState(false); | |
const [searchQuery, setSearchQuery] = useState(""); | |
const form = useForm<CheckoutFormData>({ | |
resolver: zodResolver(checkoutSchema), | |
defaultValues: { | |
firstName: user?.firstName || "", | |
lastName: user?.lastName || "", | |
email: user?.email || "", | |
phone: user?.phone || "", | |
street: user?.street || "", | |
city: user?.city || "", | |
state: user?.state || "", | |
pinCode: user?.zipCode || "", | |
country: user?.country || DEFAULT_COUNTRY, | |
paymentMethod: "cod" as const, | |
upiId: "", | |
}, | |
}); | |
// Redirect if not authenticated or cart is empty | |
if (!user) { | |
setLocation('/auth'); | |
return null; | |
} | |
if (items.length === 0) { | |
setLocation('/'); | |
return null; | |
} | |
const onSubmit = async (data: CheckoutFormData) => { | |
setIsLoading(true); | |
try { | |
const shippingAddress = `${data.street}, ${data.city}, ${data.state} ${data.pinCode}, ${data.country}`; | |
const orderData = { | |
subtotal: subtotal.toString(), | |
tax: tax.toString(), | |
shipping: "0.00", | |
total: total.toString(), | |
shippingAddress, | |
paymentMethod: data.paymentMethod, | |
items: items.map(item => ({ | |
productId: item.productId, | |
quantity: item.quantity, | |
price: item.product?.price || "0", | |
})), | |
}; | |
await ordersApi.create(orderData); | |
clearCart(); | |
toast({ | |
title: "Order placed successfully!", | |
description: "Thank you for your purchase. You will receive a confirmation email shortly.", | |
}); | |
setLocation('/profile'); | |
} catch (error) { | |
toast({ | |
variant: "destructive", | |
title: "Order failed", | |
description: "There was an error processing your order. Please try again.", | |
}); | |
} finally { | |
setIsLoading(false); | |
} | |
}; | |
return ( | |
<div className="min-h-screen bg-gray-50"> | |
<Header searchQuery={searchQuery} setSearchQuery={setSearchQuery} /> | |
<div className="max-w-7xl mx-auto px-3 sm:px-4 lg:px-6 py-4" data-testid="checkout-page"> | |
<div className="mb-6"> | |
<h1 className="text-3xl font-bold text-gray-900" data-testid="checkout-title"> | |
Checkout | |
</h1> | |
<p className="text-gray-600 mt-2">Complete your purchase</p> | |
</div> | |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
{/* Checkout Form */} | |
<div className="lg:col-span-2"> | |
<Form {...form}> | |
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> | |
{/* User Details Confirmation */} | |
<Card> | |
<CardHeader> | |
<CardTitle>Confirm Your Details</CardTitle> | |
</CardHeader> | |
<CardContent className="space-y-4"> | |
<div className="bg-blue-50 p-4 rounded-lg"> | |
<p className="text-sm text-gray-700 mb-2"> | |
Please confirm that your details below are correct: | |
</p> | |
<div className="grid grid-cols-2 gap-4 text-sm"> | |
<div> | |
<span className="font-medium">Name:</span> {user?.firstName} {user?.lastName} | |
</div> | |
<div> | |
<span className="font-medium">Email:</span> {user?.email} | |
</div> | |
<div> | |
<span className="font-medium">Phone:</span> {user?.phone} | |
</div> | |
<div> | |
<span className="font-medium">Address:</span> {user?.street} | |
</div> | |
<div> | |
<span className="font-medium">City:</span> {user?.city} | |
</div> | |
<div> | |
<span className="font-medium">State:</span> {user?.state} | |
</div> | |
</div> | |
</div> | |
</CardContent> | |
</Card> | |
{/* Shipping Information */} | |
<Card> | |
<CardHeader> | |
<CardTitle>Shipping Information</CardTitle> | |
</CardHeader> | |
<CardContent className="space-y-4"> | |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
<FormField | |
control={form.control} | |
name="firstName" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>First Name</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="first-name-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={form.control} | |
name="lastName" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Last Name</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="last-name-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
</div> | |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
<FormField | |
control={form.control} | |
name="email" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Email Address</FormLabel> | |
<FormControl> | |
<Input {...field} type="email" data-testid="email-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={form.control} | |
name="phone" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Phone Number</FormLabel> | |
<FormControl> | |
<Input {...field} type="tel" data-testid="phone-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
</div> | |
<FormField | |
control={form.control} | |
name="street" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Street Address</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="street-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
<FormField | |
control={form.control} | |
name="city" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>City</FormLabel> | |
<FormControl> | |
<Input {...field} data-testid="city-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={form.control} | |
name="state" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>State</FormLabel> | |
<Select onValueChange={field.onChange} defaultValue={field.value}> | |
<FormControl> | |
<SelectTrigger data-testid="state-select"> | |
<SelectValue placeholder="Select state" /> | |
</SelectTrigger> | |
</FormControl> | |
<SelectContent className="max-h-60 overflow-y-auto"> | |
{INDIAN_STATES.map((state) => ( | |
<SelectItem key={state} value={state}> | |
{state} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</Select> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
<FormField | |
control={form.control} | |
name="pinCode" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>PIN Code</FormLabel> | |
<FormControl> | |
<Input {...field} placeholder="400001" data-testid="pin-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
</div> | |
</CardContent> | |
</Card> | |
{/* Payment Information */} | |
<Card> | |
<CardHeader> | |
<CardTitle className="flex items-center"> | |
<IndianRupee className="mr-2 h-5 w-5" /> | |
Payment Method | |
</CardTitle> | |
</CardHeader> | |
<CardContent className="space-y-4"> | |
<FormField | |
control={form.control} | |
name="paymentMethod" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>Select Payment Method</FormLabel> | |
<Select onValueChange={field.onChange} defaultValue={field.value}> | |
<FormControl> | |
<SelectTrigger data-testid="payment-method-select"> | |
<SelectValue placeholder="Choose payment method" /> | |
</SelectTrigger> | |
</FormControl> | |
<SelectContent> | |
{PAYMENT_METHODS.map((method) => ( | |
<SelectItem key={method.value} value={method.value}> | |
{method.label} | |
</SelectItem> | |
))} | |
</SelectContent> | |
</Select> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
{form.watch("paymentMethod") === "upi" && ( | |
<FormField | |
control={form.control} | |
name="upiId" | |
render={({ field }) => ( | |
<FormItem> | |
<FormLabel>UPI ID</FormLabel> | |
<FormControl> | |
<Input {...field} placeholder="yourname@paytm" data-testid="upi-input" /> | |
</FormControl> | |
<FormMessage /> | |
</FormItem> | |
)} | |
/> | |
)} | |
<div className="flex items-center space-x-2 text-sm text-gray-600 mt-4"> | |
<Smartphone className="h-4 w-4" /> | |
<p> | |
{form.watch("paymentMethod") === "cod" | |
? "Pay with cash when your order is delivered" | |
: "Secure UPI payment gateway" | |
} | |
</p> | |
</div> | |
</CardContent> | |
</Card> | |
<Button | |
type="submit" | |
className="w-full bg-blue-600 hover:bg-blue-700 text-lg py-3" | |
disabled={isLoading} | |
data-testid="place-order-button" | |
> | |
{isLoading && <Loader2 className="mr-2 h-5 w-5 animate-spin" />} | |
<Shield className="mr-2 h-5 w-5" /> | |
Place Order - ₹{total.toFixed(2)} | |
</Button> | |
</form> | |
</Form> | |
</div> | |
{/* Order Summary */} | |
<div className="lg:col-span-1"> | |
<Card className="sticky top-4"> | |
<CardHeader> | |
<CardTitle>Order Summary</CardTitle> | |
</CardHeader> | |
<CardContent> | |
{/* Order Items */} | |
<div className="space-y-3 mb-4" data-testid="order-items"> | |
{items.map((item) => ( | |
<div key={item.id} className="flex items-center space-x-3"> | |
<div className="w-12 h-12 bg-gray-100 rounded overflow-hidden flex-shrink-0"> | |
{item.product?.images && item.product.images.length > 0 ? ( | |
<img | |
src={item.product.images[0]} | |
alt={item.product.title} | |
className="w-full h-full object-cover" | |
data-testid={`order-item-image-${item.id}`} | |
/> | |
) : ( | |
<div className="w-full h-full bg-gray-200 flex items-center justify-center"> | |
<span className="text-xs text-gray-400">No Image</span> | |
</div> | |
)} | |
</div> | |
<div className="flex-1"> | |
<h4 className="text-sm font-medium text-gray-900 line-clamp-1" data-testid={`order-item-title-${item.id}`}> | |
{item.product?.title || 'Unknown Product'} | |
</h4> | |
<p className="text-xs text-gray-500" data-testid={`order-item-seller-${item.id}`}> | |
{item.product?.store?.name || 'Unknown Seller'} | |
</p> | |
<div className="flex justify-between items-center mt-1"> | |
<span className="text-xs text-gray-500" data-testid={`order-item-quantity-${item.id}`}> | |
Qty: {item.quantity} | |
</span> | |
<span className="text-sm font-semibold text-gray-900" data-testid={`order-item-price-${item.id}`}> | |
₹{item.product ? (parseFloat(item.product.price) * item.quantity).toFixed(2) : '0.00'} | |
</span> | |
</div> | |
</div> | |
</div> | |
))} | |
</div> | |
<Separator className="my-4" /> | |
{/* Order Totals */} | |
<div className="space-y-2 mb-4" data-testid="order-totals"> | |
<div className="flex justify-between text-sm"> | |
<span className="text-gray-600">Subtotal:</span> | |
<span className="text-gray-900" data-testid="order-subtotal"> | |
₹{subtotal.toFixed(2)} | |
</span> | |
</div> | |
<div className="flex justify-between text-sm"> | |
<span className="text-gray-600">Shipping:</span> | |
<span className="text-green-600" data-testid="order-shipping">Free</span> | |
</div> | |
<div className="flex justify-between text-sm"> | |
<span className="text-gray-600">Tax:</span> | |
<span className="text-gray-900" data-testid="order-tax"> | |
₹{tax.toFixed(2)} | |
</span> | |
</div> | |
<Separator /> | |
<div className="flex justify-between font-semibold text-lg"> | |
<span className="text-gray-900">Total:</span> | |
<span className="text-gray-900" data-testid="order-total"> | |
₹{total.toFixed(2)} | |
</span> | |
</div> | |
</div> | |
</CardContent> | |
</Card> | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
} | |