|
1 |
| -import { FC, Fragment, PropsWithChildren, useEffect } from 'react'; |
2 |
| -import { Dialog, Transition } from '@headlessui/react'; |
| 1 | +import { FC, Fragment, useCallback, useState, useEffect } from 'react'; |
| 2 | +import { Dialog, DialogPanel, DialogTitle, DialogBackdrop } from '@headlessui/react'; |
3 | 3 | import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
|
4 |
| -import { CartDrawerItem } from './CartDrawerItem'; |
5 | 4 | import { formatCartSubtotal, formatPrice } from '@libs/util/prices';
|
6 | 5 | import { useCart } from '@app/hooks/useCart';
|
7 | 6 | import { IconButton } from '@app/components/common/buttons/IconButton';
|
8 | 7 | import { ButtonLink } from '@app/components/common/buttons/ButtonLink';
|
9 | 8 | import { Button } from '@app/components/common/buttons/Button';
|
10 |
| -import { useNavigate } from '@remix-run/react'; |
| 9 | +import { useNavigate, useFetchers } from '@remix-run/react'; |
11 | 10 | import { useRegion } from '@app/hooks/useRegion';
|
| 11 | +import { CartDrawerItem } from './CartDrawerItem'; |
| 12 | + |
| 13 | +// Cart Drawer Header Component |
| 14 | +const CartDrawerHeader: FC<{ itemCount: number; onClose: () => void }> = ({ itemCount, onClose }) => ( |
| 15 | + <div className="flex items-start justify-between"> |
| 16 | + <DialogTitle className="text-lg font-bold text-gray-900"> |
| 17 | + My Cart{' '} |
| 18 | + {itemCount > 0 && ( |
| 19 | + <span className="pl-2"> |
| 20 | + ({itemCount} item{itemCount > 1 ? 's' : ''}) |
| 21 | + </span> |
| 22 | + )} |
| 23 | + </DialogTitle> |
| 24 | + <div className="ml-3 flex h-7 items-center"> |
| 25 | + <IconButton icon={XMarkIcon} onClick={onClose} className="-m-2" aria-label="Close panel" /> |
| 26 | + </div> |
| 27 | + </div> |
| 28 | +); |
| 29 | + |
| 30 | +// Cart Drawer Empty Component |
| 31 | +const CartDrawerEmpty: FC = () => <p className="text-center text-sm text-gray-500">Looks like your cart is empty!</p>; |
| 32 | + |
| 33 | +// Cart Drawer Loading Component |
| 34 | +const CartDrawerLoading: FC = () => ( |
| 35 | + <li className="py-6 list-none"> |
| 36 | + <div className="flex animate-pulse space-x-4"> |
| 37 | + <div className="h-24 w-24 rounded-md bg-slate-300" /> |
| 38 | + <div className="flex h-24 w-full flex-1 flex-col space-y-3 py-1"> |
| 39 | + <div className="grid grid-cols-3 gap-4"> |
| 40 | + <div className="col-span-2 h-2 rounded bg-slate-300" /> |
| 41 | + <div className="col-span-1 h-2 rounded bg-slate-300" /> |
| 42 | + </div> |
| 43 | + <div className="h-2 rounded bg-slate-300" /> |
| 44 | + <div className="flex-1" /> |
| 45 | + <div className="grid grid-cols-4 gap-4"> |
| 46 | + <div className="col-span-1 h-2 rounded bg-slate-300" /> |
| 47 | + <div className="col-span-2" /> |
| 48 | + <div className="col-span-1 h-2 rounded bg-slate-300" /> |
| 49 | + </div> |
| 50 | + </div> |
| 51 | + </div> |
| 52 | + </li> |
| 53 | +); |
| 54 | + |
| 55 | +// Cart Drawer Items Component |
| 56 | +const CartDrawerItems: FC<{ |
| 57 | + items: any[]; |
| 58 | + isRemovingItemId?: string; |
| 59 | + currencyCode: string; |
| 60 | +}> = ({ items, isRemovingItemId, currencyCode }) => ( |
| 61 | + <ul className="-my-6 divide-y divide-gray-200 list-none"> |
| 62 | + {items.map((item) => ( |
| 63 | + <CartDrawerItem key={item.id} isRemoving={isRemovingItemId === item.id} item={item} currencyCode={currencyCode} /> |
| 64 | + ))} |
| 65 | + </ul> |
| 66 | +); |
| 67 | + |
| 68 | +// Cart Drawer Content Component |
| 69 | +const CartDrawerContent: FC<{ |
| 70 | + items: any[]; |
| 71 | + isRemovingItemId?: string; |
| 72 | + isAddingItem: boolean; |
| 73 | + showEmptyCartMessage: boolean; |
| 74 | + isRemovingLastItem: boolean; |
| 75 | + currencyCode: string; |
| 76 | +}> = ({ items, isRemovingItemId, isAddingItem, showEmptyCartMessage, isRemovingLastItem, currencyCode }) => ( |
| 77 | + <div className="mt-8"> |
| 78 | + <div className="flow-root"> |
| 79 | + {/* Show empty cart message when cart is empty and not loading */} |
| 80 | + {(showEmptyCartMessage || isRemovingLastItem) && <CartDrawerEmpty />} |
12 | 81 |
|
13 |
| -export const CartDrawer: FC<PropsWithChildren> = () => { |
| 82 | + {/* Show items when there are items in the cart */} |
| 83 | + {items.length > 0 && !isRemovingLastItem && ( |
| 84 | + <CartDrawerItems items={items} isRemovingItemId={isRemovingItemId} currencyCode={currencyCode} /> |
| 85 | + )} |
| 86 | + |
| 87 | + {/* Show loading item when adding items but not when removing the last item */} |
| 88 | + {isAddingItem && !isRemovingLastItem && <CartDrawerLoading />} |
| 89 | + </div> |
| 90 | + </div> |
| 91 | +); |
| 92 | + |
| 93 | +// Cart Drawer Footer Component |
| 94 | +const CartDrawerFooter: FC<{ |
| 95 | + cart: any; |
| 96 | + currencyCode: string; |
| 97 | + itemCount: number; |
| 98 | + isAddingItem: boolean; |
| 99 | + isRemovingLastItem: boolean; |
| 100 | + onCheckout: () => void; |
| 101 | + onClose: () => void; |
| 102 | +}> = ({ cart, currencyCode, itemCount, isAddingItem, isRemovingLastItem, onCheckout, onClose }) => ( |
| 103 | + <div className="border-t border-gray-200 px-4 py-6 sm:px-6"> |
| 104 | + <div className="flex justify-between text-base font-bold text-gray-900"> |
| 105 | + <p>Subtotal</p> |
| 106 | + <p> |
| 107 | + {cart |
| 108 | + ? formatCartSubtotal(cart) |
| 109 | + : formatPrice(0, { |
| 110 | + currency: currencyCode, |
| 111 | + })} |
| 112 | + </p> |
| 113 | + </div> |
| 114 | + <p className="mt-0.5 text-sm text-gray-500">Shipping and taxes calculated at checkout.</p> |
| 115 | + <div className="mt-6"> |
| 116 | + <Button |
| 117 | + variant="primary" |
| 118 | + disabled={itemCount === 0 || isRemovingLastItem} |
| 119 | + onClick={onCheckout} |
| 120 | + className="h-12 w-full !text-base font-bold" |
| 121 | + > |
| 122 | + Checkout |
| 123 | + </Button> |
| 124 | + </div> |
| 125 | + <div className="mt-4 flex justify-center text-center text-sm text-gray-500"> |
| 126 | + <p> |
| 127 | + or{' '} |
| 128 | + <ButtonLink size="sm" onClick={onClose}> |
| 129 | + <div> |
| 130 | + Continue Shopping{` `} |
| 131 | + <span aria-hidden="true">→</span> |
| 132 | + </div> |
| 133 | + </ButtonLink> |
| 134 | + </p> |
| 135 | + </div> |
| 136 | + </div> |
| 137 | +); |
| 138 | + |
| 139 | +export const CartDrawer: FC = () => { |
14 | 140 | const navigate = useNavigate();
|
15 |
| - const { cart, cartDrawerOpen, toggleCartDrawer, isAddingItem, isRemovingItemId } = useCart(); |
| 141 | + const { |
| 142 | + cart, |
| 143 | + cartDrawerOpen, |
| 144 | + toggleCartDrawer, |
| 145 | + isAddingItem, |
| 146 | + isRemovingItemId, |
| 147 | + isRemovingLastItem, |
| 148 | + showEmptyCartMessage, |
| 149 | + } = useCart(); |
16 | 150 | const { region } = useRegion();
|
17 |
| - let lineItems = cart?.items ?? []; |
18 |
| - let lineItemsCount = lineItems.length; |
| 151 | + const allFetchers = useFetchers(); |
| 152 | + |
| 153 | + // Track if any cart-related fetchers are active |
| 154 | + const isCartLoading = allFetchers.some( |
| 155 | + (f) => |
| 156 | + (f.state === 'submitting' || f.state === 'loading') && |
| 157 | + (f.formAction?.includes('/api/cart') || f.formData?.get('action') === 'add-to-cart'), |
| 158 | + ); |
| 159 | + |
| 160 | + // Local state to control the dialog - initialize with cartDrawerOpen |
| 161 | + const [isOpen, setIsOpen] = useState(false); |
| 162 | + |
| 163 | + // Sync our local state with the cart drawer state |
| 164 | + useEffect(() => { |
| 165 | + setIsOpen(cartDrawerOpen === true); |
| 166 | + }, [cartDrawerOpen]); |
| 167 | + |
| 168 | + const lineItems = cart?.items ?? []; |
19 | 169 | const lineItemsTotal = lineItems.reduce((acc, item) => acc + item.quantity, 0);
|
20 |
| - const isRemovingLastItem = lineItems.length === 1 && isRemovingItemId === lineItems[0].id; |
21 | 170 |
|
22 |
| - const handleCheckoutClick = () => { |
| 171 | + const handleCheckoutClick = useCallback(() => { |
23 | 172 | navigate('/checkout');
|
24 |
| - toggleCartDrawer(); |
25 |
| - }; |
| 173 | + toggleCartDrawer(false); |
| 174 | + }, [navigate, toggleCartDrawer]); |
26 | 175 |
|
27 |
| - useEffect(() => { |
28 |
| - if (lineItemsCount < 1 || isRemovingLastItem) toggleCartDrawer(false); |
29 |
| - }, [lineItemsCount]); |
| 176 | + const handleClose = useCallback(() => { |
| 177 | + toggleCartDrawer(false); |
| 178 | + }, [toggleCartDrawer]); |
30 | 179 |
|
31 | 180 | return (
|
32 |
| - <Transition.Root show={!!cartDrawerOpen} as={Fragment}> |
33 |
| - <Dialog as="div" className="relative z-50" onClose={() => toggleCartDrawer(false)}> |
34 |
| - <Transition.Child |
35 |
| - as={Fragment} |
36 |
| - enter="ease-in-out duration-200" |
37 |
| - enterFrom="opacity-0" |
38 |
| - enterTo="opacity-100" |
39 |
| - leave="ease-in-out duration-200" |
40 |
| - leaveFrom="opacity-100" |
41 |
| - leaveTo="opacity-0" |
42 |
| - > |
43 |
| - <div className="fixed inset-0 bg-gray-300 bg-opacity-50 backdrop-blur-sm transition-opacity" /> |
44 |
| - </Transition.Child> |
45 |
| - |
46 |
| - <div className="fixed inset-0 overflow-hidden"> |
47 |
| - <div className="absolute inset-0 overflow-hidden"> |
48 |
| - <div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10"> |
49 |
| - <Transition.Child |
50 |
| - as={Fragment} |
51 |
| - enter="transform transition ease-in-out duration-200" |
52 |
| - enterFrom="translate-x-full" |
53 |
| - enterTo="translate-x-0" |
54 |
| - leave="transform transition ease-in-out duration-200" |
55 |
| - leaveFrom="translate-x-0" |
56 |
| - leaveTo="translate-x-full" |
57 |
| - > |
58 |
| - <Dialog.Panel className="pointer-events-auto w-screen max-w-md"> |
59 |
| - <div className="flex h-full flex-col overflow-y-scroll bg-white shadow-xl"> |
60 |
| - <div className="flex-1 overflow-y-auto px-4 py-6 sm:px-6"> |
61 |
| - <div className="flex items-start justify-between"> |
62 |
| - <Dialog.Title className="text-lg font-bold text-gray-900"> |
63 |
| - My Cart{' '} |
64 |
| - {lineItemsTotal > 0 && ( |
65 |
| - <span className="pl-2"> |
66 |
| - ({lineItemsTotal} item |
67 |
| - {lineItemsTotal > 1 ? 's' : ''}) |
68 |
| - </span> |
69 |
| - )} |
70 |
| - </Dialog.Title> |
71 |
| - <div className="ml-3 flex h-7 items-center"> |
72 |
| - <IconButton |
73 |
| - icon={XMarkIcon} |
74 |
| - onClick={() => toggleCartDrawer(false)} |
75 |
| - className="-m-2" |
76 |
| - aria-label="Close panel" |
77 |
| - /> |
78 |
| - </div> |
79 |
| - </div> |
80 |
| - |
81 |
| - <div className="mt-8"> |
82 |
| - <div className="flow-root"> |
83 |
| - {((!isAddingItem && lineItems.length === 0) || isRemovingLastItem) && ( |
84 |
| - <p className="text-center text-sm text-gray-500">Looks like your cart is empty!</p> |
85 |
| - )} |
86 |
| - |
87 |
| - <ul className="-my-6 divide-y divide-gray-200"> |
88 |
| - {lineItems.map((item) => { |
89 |
| - const isRemoving = isRemovingItemId === item.id; |
90 |
| - return ( |
91 |
| - <CartDrawerItem |
92 |
| - key={item.id} |
93 |
| - isRemoving={isRemoving} |
94 |
| - item={item} |
95 |
| - currencyCode={region.currency_code} |
96 |
| - /> |
97 |
| - ); |
98 |
| - })} |
99 |
| - |
100 |
| - {isAddingItem && ( |
101 |
| - <li className="py-6"> |
102 |
| - <div className="flex animate-pulse space-x-4"> |
103 |
| - <div className="h-24 w-24 rounded-md bg-slate-300" /> |
104 |
| - <div className="flex h-24 w-full flex-1 flex-col space-y-3 py-1"> |
105 |
| - <div className="grid grid-cols-3 gap-4"> |
106 |
| - <div className="col-span-2 h-2 rounded bg-slate-300" /> |
107 |
| - <div className="col-span-1 h-2 rounded bg-slate-300" /> |
108 |
| - </div> |
109 |
| - <div className="h-2 rounded bg-slate-300" /> |
110 |
| - <div className="flex-1" /> |
111 |
| - <div className="grid grid-cols-4 gap-4"> |
112 |
| - <div className="col-span-1 h-2 rounded bg-slate-300" /> |
113 |
| - <div className="col-span-2" /> |
114 |
| - <div className="col-span-1 h-2 rounded bg-slate-300" /> |
115 |
| - </div> |
116 |
| - </div> |
117 |
| - </div> |
118 |
| - </li> |
119 |
| - )} |
120 |
| - </ul> |
121 |
| - </div> |
122 |
| - </div> |
123 |
| - </div> |
124 |
| - |
125 |
| - <div className="border-t border-gray-200 px-4 py-6 sm:px-6"> |
126 |
| - <div className="flex justify-between text-base font-bold text-gray-900"> |
127 |
| - <p>Subtotal</p> |
128 |
| - <p> |
129 |
| - {cart |
130 |
| - ? formatCartSubtotal(cart) |
131 |
| - : formatPrice(0, { |
132 |
| - currency: region.currency_code, |
133 |
| - })} |
134 |
| - </p> |
135 |
| - </div> |
136 |
| - <p className="mt-0.5 text-sm text-gray-500">Shipping and taxes calculated at checkout.</p> |
137 |
| - <div className="mt-6"> |
138 |
| - <Button |
139 |
| - variant="primary" |
140 |
| - disabled={lineItems.length === 0} |
141 |
| - onClick={handleCheckoutClick} |
142 |
| - className="h-12 w-full !text-base font-bold" |
143 |
| - > |
144 |
| - Checkout |
145 |
| - </Button> |
146 |
| - </div> |
147 |
| - <div className="mt-4 flex justify-center text-center text-sm text-gray-500"> |
148 |
| - <p> |
149 |
| - or{' '} |
150 |
| - <ButtonLink size="sm" onClick={() => toggleCartDrawer(false)}> |
151 |
| - <div> |
152 |
| - Continue Shopping{` `} |
153 |
| - <span aria-hidden="true">→</span> |
154 |
| - </div> |
155 |
| - </ButtonLink> |
156 |
| - </p> |
157 |
| - </div> |
158 |
| - </div> |
159 |
| - </div> |
160 |
| - </Dialog.Panel> |
161 |
| - </Transition.Child> |
162 |
| - </div> |
| 181 | + <Dialog open={isOpen} onClose={handleClose} className="relative z-50"> |
| 182 | + {/* Backdrop with transition */} |
| 183 | + <DialogBackdrop |
| 184 | + transition |
| 185 | + className="fixed inset-0 bg-gray-300 bg-opacity-50 backdrop-blur-sm duration-300 ease-out data-[closed]:opacity-0" |
| 186 | + /> |
| 187 | + |
| 188 | + <div className="fixed inset-0 overflow-hidden"> |
| 189 | + <div className="absolute inset-0 overflow-hidden"> |
| 190 | + <div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10"> |
| 191 | + {/* Panel with transition */} |
| 192 | + <DialogPanel |
| 193 | + transition |
| 194 | + className="pointer-events-auto w-screen max-w-md transform duration-500 ease-in-out data-[closed]:translate-x-full" |
| 195 | + > |
| 196 | + <div className="flex h-full flex-col overflow-y-scroll bg-white shadow-xl"> |
| 197 | + {/* Content */} |
| 198 | + <div className="flex-1 overflow-y-auto px-4 py-6 sm:px-6"> |
| 199 | + <CartDrawerHeader itemCount={lineItemsTotal} onClose={handleClose} /> |
| 200 | + |
| 201 | + <CartDrawerContent |
| 202 | + items={lineItems} |
| 203 | + isRemovingItemId={isRemovingItemId} |
| 204 | + isAddingItem={isAddingItem || isCartLoading} |
| 205 | + showEmptyCartMessage={showEmptyCartMessage} |
| 206 | + isRemovingLastItem={isRemovingLastItem} |
| 207 | + currencyCode={region.currency_code} |
| 208 | + /> |
| 209 | + </div> |
| 210 | + |
| 211 | + {/* Footer */} |
| 212 | + <CartDrawerFooter |
| 213 | + cart={cart} |
| 214 | + currencyCode={region.currency_code} |
| 215 | + itemCount={lineItemsTotal} |
| 216 | + isAddingItem={isAddingItem || isCartLoading} |
| 217 | + isRemovingLastItem={isRemovingLastItem} |
| 218 | + onCheckout={handleCheckoutClick} |
| 219 | + onClose={handleClose} |
| 220 | + /> |
| 221 | + </div> |
| 222 | + </DialogPanel> |
163 | 223 | </div>
|
164 | 224 | </div>
|
165 |
| - </Dialog> |
166 |
| - </Transition.Root> |
| 225 | + </div> |
| 226 | + </Dialog> |
167 | 227 | );
|
168 | 228 | };
|
0 commit comments