269 lines
9.1 KiB
TypeScript
269 lines
9.1 KiB
TypeScript
"use client"
|
||
|
||
import { useState } from "react"
|
||
import {
|
||
Minus,
|
||
Plus,
|
||
ShoppingCart,
|
||
Trash2,
|
||
AlertCircle,
|
||
CheckCircle,
|
||
Loader2,
|
||
} from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
SheetFooter,
|
||
} from "@/components/ui/sheet"
|
||
import { Separator } from "@/components/ui/separator"
|
||
import { useCart } from "@/lib/cart/context"
|
||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||
import Image from "next/image"
|
||
|
||
interface CartProps {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
}
|
||
|
||
export function Cart({ isOpen, onClose }: CartProps) {
|
||
const { items, removeItem, updateQuantity, getTotal, clearCart } = useCart()
|
||
const [isCheckingOut, setIsCheckingOut] = useState(false)
|
||
const [checkoutError, setCheckoutError] = useState<string | null>(null)
|
||
const [isRemoving, setIsRemoving] = useState<string | null>(null)
|
||
|
||
const handleCheckout = async () => {
|
||
if (items.length === 0) return
|
||
|
||
setIsCheckingOut(true)
|
||
setCheckoutError(null)
|
||
|
||
try {
|
||
const response = await fetch("/api/stripe/checkout", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
items: items.map((item) => ({
|
||
priceId: item.priceId,
|
||
quantity: item.quantity,
|
||
})),
|
||
}),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "Failed to create checkout session")
|
||
}
|
||
|
||
if (data.url) {
|
||
// Redirect to Stripe Checkout
|
||
window.location.href = data.url
|
||
} else {
|
||
throw new Error("No checkout URL returned from server")
|
||
}
|
||
} catch (error) {
|
||
console.error("Error creating checkout session:", error)
|
||
const errorMessage =
|
||
error instanceof Error
|
||
? error.message
|
||
: "Failed to start checkout. Please try again."
|
||
setCheckoutError(errorMessage)
|
||
|
||
// Auto-hide error after 5 seconds
|
||
setTimeout(() => setCheckoutError(null), 5000)
|
||
} finally {
|
||
setIsCheckingOut(false)
|
||
}
|
||
}
|
||
|
||
const handleRemoveItem = async (itemId: string) => {
|
||
setIsRemoving(itemId)
|
||
try {
|
||
// Simulate API call delay for better UX
|
||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||
removeItem(itemId)
|
||
} catch (error) {
|
||
console.error("Error removing item:", error)
|
||
} finally {
|
||
setIsRemoving(null)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Sheet open={isOpen} onOpenChange={onClose}>
|
||
<SheetContent side="right" className="w-full max-w-md flex flex-col p-0">
|
||
<SheetHeader className="p-4 border-b border-border">
|
||
<SheetTitle>Shopping Cart</SheetTitle>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||
<span className="text-sm text-muted-foreground">
|
||
{items.length} {items.length === 1 ? "item" : "items"} in cart
|
||
</span>
|
||
</div>
|
||
</SheetHeader>
|
||
|
||
{/* Error Alert */}
|
||
{checkoutError && (
|
||
<div className="p-4 border-b border-border">
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>{checkoutError}</AlertDescription>
|
||
</Alert>
|
||
</div>
|
||
)}
|
||
|
||
{/* Cart Items */}
|
||
<div className="flex-1 overflow-y-auto p-4">
|
||
{items.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||
<ShoppingCart className="h-16 w-16 text-muted-foreground mb-4" />
|
||
<h3 className="text-lg font-medium mb-2">Your cart is empty</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
Add some products from our catalog to get started
|
||
</p>
|
||
<Button
|
||
className="mt-4"
|
||
onClick={() => {
|
||
onClose()
|
||
// Navigate to products page
|
||
window.location.href = "/products"
|
||
}}
|
||
>
|
||
Browse Products
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{items.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
className="flex gap-4 p-4 border border-border rounded-lg hover:border-secondary/50 transition-colors"
|
||
>
|
||
<div className="relative w-20 h-20 flex-shrink-0 overflow-hidden rounded-md bg-muted">
|
||
<Image
|
||
src={item.image}
|
||
alt={item.name}
|
||
fill
|
||
className="object-cover transition-transform duration-300 hover:scale-105"
|
||
sizes="80px"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-medium truncate mb-1">{item.name}</h3>
|
||
<p className="text-sm text-muted-foreground mb-2">
|
||
${item.price.toFixed(2)} × {item.quantity}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={() =>
|
||
updateQuantity(item.id, item.quantity - 1)
|
||
}
|
||
disabled={item.quantity <= 1}
|
||
title="Decrease quantity"
|
||
>
|
||
<Minus className="h-3 w-3" />
|
||
</Button>
|
||
<span className="w-8 text-center font-medium">
|
||
{item.quantity}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={() =>
|
||
updateQuantity(item.id, item.quantity + 1)
|
||
}
|
||
title="Increase quantity"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 ml-auto text-destructive hover:text-destructive/80"
|
||
onClick={() => handleRemoveItem(item.id)}
|
||
disabled={isRemoving === item.id}
|
||
title="Remove item"
|
||
>
|
||
{isRemoving === item.id ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col justify-end items-end">
|
||
<div className="text-lg font-semibold">
|
||
${(item.price * item.quantity).toFixed(2)}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
${item.price.toFixed(2)} each
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
{items.length > 0 && (
|
||
<SheetFooter className="flex-col gap-4 p-4 border-t border-border">
|
||
<div className="flex justify-between items-end">
|
||
<div>
|
||
<div className="text-sm text-muted-foreground">Subtotal</div>
|
||
<div className="flex items-baseline gap-1">
|
||
<span className="text-2xl font-bold">
|
||
${getTotal().toFixed(2)}
|
||
</span>
|
||
<span className="text-sm text-muted-foreground">USD</span>
|
||
</div>
|
||
</div>
|
||
<div className="text-right text-sm text-muted-foreground">
|
||
{items.reduce((total, item) => total + item.quantity, 0)} items
|
||
</div>
|
||
</div>
|
||
<Separator />
|
||
<Button
|
||
onClick={handleCheckout}
|
||
disabled={isCheckingOut}
|
||
variant="brand"
|
||
size="lg"
|
||
className="w-full h-12"
|
||
>
|
||
{isCheckingOut ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
Processing...
|
||
</>
|
||
) : (
|
||
<>
|
||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||
Checkout - ${getTotal().toFixed(2)}
|
||
</>
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={clearCart}
|
||
className="w-full"
|
||
size="sm"
|
||
disabled={isCheckingOut}
|
||
>
|
||
Clear Cart
|
||
</Button>
|
||
</SheetFooter>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
)
|
||
}
|