@codesinger0/shared-components 1.0.44 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/cart/CartItem.jsx +101 -0
- package/dist/components/cart/FloatingCartButton.jsx +44 -0
- package/dist/components/cart/ShoppingCartModal.jsx +226 -0
- package/dist/context/CartContext.jsx +165 -0
- package/dist/hooks/useFirestoreCollection.js +243 -0
- package/dist/index.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { X, Plus, Minus } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
const CartItem = ({ item, onUpdateQuantity, onRemoveItem }) => {
|
|
6
|
+
const handleQuantityChange = (newQuantity) => {
|
|
7
|
+
if (newQuantity <= 0) {
|
|
8
|
+
onRemoveItem(item.id);
|
|
9
|
+
} else {
|
|
10
|
+
onUpdateQuantity(item.id, newQuantity);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const itemTotal = (item.price * item.quantity).toFixed(2);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<motion.div
|
|
18
|
+
layout
|
|
19
|
+
initial={{ opacity: 0, scale: 0.8 }}
|
|
20
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
21
|
+
exit={{ opacity: 0, scale: 0.8 }}
|
|
22
|
+
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
|
23
|
+
className="glass-card p-4 mb-3"
|
|
24
|
+
dir="rtl"
|
|
25
|
+
>
|
|
26
|
+
<div className="flex items-start gap-3">
|
|
27
|
+
{/* Product Image */}
|
|
28
|
+
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-100 flex-shrink-0">
|
|
29
|
+
{item.imageUrl ? (
|
|
30
|
+
<img
|
|
31
|
+
src={item.imageUrl}
|
|
32
|
+
alt={item.name}
|
|
33
|
+
className="w-full h-full object-cover"
|
|
34
|
+
onError={(e) => {
|
|
35
|
+
e.target.style.display = 'none';
|
|
36
|
+
e.target.nextElementSibling.style.display = 'flex';
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
) : null}
|
|
40
|
+
<div
|
|
41
|
+
className="w-full h-full flex items-center justify-center content-text text-xs"
|
|
42
|
+
style={{ display: item.imageUrl ? 'none' : 'flex' }}
|
|
43
|
+
>
|
|
44
|
+
🍰
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Product Info */}
|
|
49
|
+
<div className="flex-1">
|
|
50
|
+
<div className="flex justify-between items-start mb-2">
|
|
51
|
+
<div>
|
|
52
|
+
<h3 className="content-text font-medium line-clamp-2">{item.name}</h3>
|
|
53
|
+
<p className="text-sm text-price">₪{item.price} ליחידה</p>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Remove Button */}
|
|
57
|
+
<button
|
|
58
|
+
onClick={() => onRemoveItem(item.id)}
|
|
59
|
+
className="text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors duration-200"
|
|
60
|
+
title="הסר מהעגלה"
|
|
61
|
+
>
|
|
62
|
+
<X size={16} />
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Quantity Controls and Total */}
|
|
67
|
+
<div className="flex justify-between items-center">
|
|
68
|
+
{/* Quantity Controls */}
|
|
69
|
+
<div className="flex items-center gap-2">
|
|
70
|
+
<button
|
|
71
|
+
onClick={() => handleQuantityChange(item.quantity - 1)}
|
|
72
|
+
disabled={item.quantity <= 1}
|
|
73
|
+
className="bg-gray-200 hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed w-7 h-7 rounded-md flex items-center justify-center transition-colors duration-200"
|
|
74
|
+
>
|
|
75
|
+
<Minus size={14} />
|
|
76
|
+
</button>
|
|
77
|
+
|
|
78
|
+
<span className="w-8 text-center content-text font-medium">
|
|
79
|
+
{item.quantity}
|
|
80
|
+
</span>
|
|
81
|
+
|
|
82
|
+
<button
|
|
83
|
+
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
84
|
+
className="bg-gray-200 hover:bg-gray-300 w-7 h-7 rounded-md flex items-center justify-center transition-colors duration-200"
|
|
85
|
+
>
|
|
86
|
+
<Plus size={14} />
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Item Total */}
|
|
91
|
+
<div className="content-text font-bold text-price">
|
|
92
|
+
₪{itemTotal}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</motion.div>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default CartItem;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { ShoppingCart } from 'lucide-react';
|
|
4
|
+
import { useCart } from '../../context/CartContext';
|
|
5
|
+
|
|
6
|
+
const FloatingCartButton = () => {
|
|
7
|
+
const { totalItems, toggleCart } = useCart();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="fixed bottom-6 right-6 z-30">
|
|
11
|
+
<motion.button
|
|
12
|
+
onClick={toggleCart}
|
|
13
|
+
className="relative bg-orange-500 text-white p-4 rounded-full shadow-lg hover:brightness-90 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50"
|
|
14
|
+
style={{
|
|
15
|
+
transform: 'translate3d(0, 0, 0)',
|
|
16
|
+
WebkitTransform: 'translate3d(0, 0, 0)',
|
|
17
|
+
WebkitBackfaceVisibility: 'hidden'
|
|
18
|
+
}} whileHover={{ scale: 1.05 }}
|
|
19
|
+
whileTap={{ scale: 0.95 }}
|
|
20
|
+
initial={{ scale: 0 }}
|
|
21
|
+
animate={{ scale: 1 }}
|
|
22
|
+
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
|
23
|
+
>
|
|
24
|
+
<ShoppingCart size={26} />
|
|
25
|
+
{/* Badge */}
|
|
26
|
+
<AnimatePresence>
|
|
27
|
+
{totalItems > 0 && (
|
|
28
|
+
<motion.div
|
|
29
|
+
initial={{ scale: 0, opacity: 0 }}
|
|
30
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
31
|
+
exit={{ scale: 0, opacity: 0 }}
|
|
32
|
+
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
|
33
|
+
className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full min-w-[1.5rem] h-6 flex items-center justify-center px-1"
|
|
34
|
+
>
|
|
35
|
+
{totalItems > 99 ? '99+' : totalItems}
|
|
36
|
+
</motion.div>
|
|
37
|
+
)}
|
|
38
|
+
</AnimatePresence>
|
|
39
|
+
</motion.button>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default FloatingCartButton;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { X, ShoppingBag } from 'lucide-react';
|
|
4
|
+
import { useCart } from '../../context/CartContext';
|
|
5
|
+
import CartItem from './CartItem';
|
|
6
|
+
import useScrollLock from '../../hooks/useScrollLock'
|
|
7
|
+
|
|
8
|
+
const ShoppingCartModal = ({ CheckoutComponent }) => {
|
|
9
|
+
const {
|
|
10
|
+
cartItems,
|
|
11
|
+
totalItems,
|
|
12
|
+
totalPrice,
|
|
13
|
+
isCartOpen, // context state
|
|
14
|
+
closeCart, // context function that flips isCartOpen -> false
|
|
15
|
+
updateQuantity,
|
|
16
|
+
removeFromCart
|
|
17
|
+
} = useCart();
|
|
18
|
+
|
|
19
|
+
// local visible state that controls AnimatePresence mounting
|
|
20
|
+
const [localOpen, setLocalOpen] = useState(Boolean(isCartOpen));
|
|
21
|
+
const [showOrderForm, setShowOrderForm] = useState(false);
|
|
22
|
+
|
|
23
|
+
// track whether the close was requested from inside this component
|
|
24
|
+
const closingRequestedRef = useRef(false);
|
|
25
|
+
|
|
26
|
+
// when context opens the cart, immediately open locally so it animates in
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (isCartOpen) {
|
|
29
|
+
setLocalOpen(true);
|
|
30
|
+
setShowOrderForm(false); // Reset to cart view when opening
|
|
31
|
+
closingRequestedRef.current = false;
|
|
32
|
+
} else {
|
|
33
|
+
setLocalOpen(false);
|
|
34
|
+
setShowOrderForm(false); // Reset when closing
|
|
35
|
+
}
|
|
36
|
+
}, [isCartOpen]);
|
|
37
|
+
|
|
38
|
+
useScrollLock(isCartOpen);
|
|
39
|
+
|
|
40
|
+
// called by close buttons / backdrop clicks inside this component
|
|
41
|
+
const requestClose = () => {
|
|
42
|
+
// mark we requested a close so we call context close only after exit
|
|
43
|
+
closingRequestedRef.current = true;
|
|
44
|
+
// trigger AnimatePresence exit by setting localOpen -> false
|
|
45
|
+
setLocalOpen(false);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleBackToCart = (closeCart = false) => {
|
|
49
|
+
setShowOrderForm(false);
|
|
50
|
+
if (closeCart === true) {
|
|
51
|
+
requestClose()
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// onExitComplete runs after all children finish exiting
|
|
56
|
+
const handleExitComplete = () => {
|
|
57
|
+
if (closingRequestedRef.current) {
|
|
58
|
+
closeCart();
|
|
59
|
+
closingRequestedRef.current = false;
|
|
60
|
+
}
|
|
61
|
+
setShowOrderForm(false); // Reset form view when modal closes
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
// wrapper ignores clicks when closed so it doesn't block the app
|
|
66
|
+
<div className={`fixed inset-0 z-50 flex ${isCartOpen ? '' : 'pointer-events-none'} supports-[height:100dvh]:h-[100dvh]`}>
|
|
67
|
+
<AnimatePresence initial={false} mode="wait" onExitComplete={handleExitComplete}>
|
|
68
|
+
{localOpen && (
|
|
69
|
+
<>
|
|
70
|
+
{/* Backdrop */}
|
|
71
|
+
<motion.div
|
|
72
|
+
key="backdrop"
|
|
73
|
+
initial={{ opacity: 0 }}
|
|
74
|
+
animate={{ opacity: 1 }}
|
|
75
|
+
exit={{ opacity: 0 }}
|
|
76
|
+
transition={{ duration: 0.18 }}
|
|
77
|
+
className="absolute inset-0 bg-black bg-opacity-30 backdrop-blur-sm pointer-events-auto"
|
|
78
|
+
onClick={!showOrderForm ? requestClose : undefined}
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
/>
|
|
81
|
+
|
|
82
|
+
{/* Cart Panel */}
|
|
83
|
+
<motion.aside
|
|
84
|
+
key="cart-panel"
|
|
85
|
+
initial={{ x: '-100%' }}
|
|
86
|
+
animate={{ x: 0 }}
|
|
87
|
+
exit={{ x: 0 }}
|
|
88
|
+
transition={{
|
|
89
|
+
type: "tween",
|
|
90
|
+
duration: 0.1,
|
|
91
|
+
}}
|
|
92
|
+
className={`relative ${showOrderForm ? 'w-full max-w-4xl' : 'w-full max-w-md'} h-full glass-card rounded-none overflow-hidden pointer-events-auto transition-all duration-300`}
|
|
93
|
+
dir="rtl"
|
|
94
|
+
role="dialog"
|
|
95
|
+
aria-modal="true"
|
|
96
|
+
onClick={(e) => e.stopPropagation()}
|
|
97
|
+
>
|
|
98
|
+
{showOrderForm ? (
|
|
99
|
+
// Order Form View
|
|
100
|
+
<div className="h-full overflow-y-auto">
|
|
101
|
+
{CheckoutComponent ? (
|
|
102
|
+
<CheckoutComponent
|
|
103
|
+
onBack={handleBackToCart}
|
|
104
|
+
cartItems={cartItems}
|
|
105
|
+
totalPrice={totalPrice}
|
|
106
|
+
/>
|
|
107
|
+
) : (
|
|
108
|
+
<div className="p-6">
|
|
109
|
+
<p className="content-text">No checkout component provided</p>
|
|
110
|
+
</div>
|
|
111
|
+
)} </div>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="flex flex-col h-full">
|
|
114
|
+
{/* Header */}
|
|
115
|
+
<div className="flex justify-between items-center p-6 border-b border-white/20">
|
|
116
|
+
<div className="flex items-center gap-3">
|
|
117
|
+
<ShoppingBag className="w-6 h-6 text-primary" />
|
|
118
|
+
<h2 className="subtitle font-bold">עגלת קניות</h2>
|
|
119
|
+
{totalItems > 0 && (
|
|
120
|
+
<motion.div
|
|
121
|
+
initial={{ scale: 0 }}
|
|
122
|
+
animate={{ scale: 1 }}
|
|
123
|
+
transition={{ type: 'spring', stiffness: 600, damping: 20 }}
|
|
124
|
+
className="bg-primary text-white text-xs font-bold rounded-full min-w-[1.5rem] h-6 flex items-center justify-center px-2"
|
|
125
|
+
>
|
|
126
|
+
{totalItems}
|
|
127
|
+
</motion.div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
<button
|
|
131
|
+
onClick={requestClose} // use local requestClose
|
|
132
|
+
className="content-text hover:text-primary hover:bg-gray-100 p-2 rounded-md transition-colors duration-200"
|
|
133
|
+
aria-label="Close cart"
|
|
134
|
+
>
|
|
135
|
+
<X size={20} />
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Cart Items */}
|
|
140
|
+
<div className="flex-1 overflow-y-auto p-6">
|
|
141
|
+
<AnimatePresence>
|
|
142
|
+
{cartItems.length === 0 ? (
|
|
143
|
+
<motion.div
|
|
144
|
+
key="empty"
|
|
145
|
+
initial={{ opacity: 0, y: 20 }}
|
|
146
|
+
animate={{ opacity: 1, y: 0 }}
|
|
147
|
+
exit={{ opacity: 0, y: 10 }}
|
|
148
|
+
className="text-center py-12"
|
|
149
|
+
>
|
|
150
|
+
<ShoppingBag className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
|
151
|
+
<h3 className="subtitle font-medium text-gray-500 mb-2">
|
|
152
|
+
העגלה ריקה
|
|
153
|
+
</h3>
|
|
154
|
+
<p className="content-text text-gray-400">
|
|
155
|
+
הוסף מוצרים כדי להתחיל לקנות
|
|
156
|
+
</p>
|
|
157
|
+
</motion.div>
|
|
158
|
+
) : (
|
|
159
|
+
<motion.div
|
|
160
|
+
key="items"
|
|
161
|
+
initial={{ opacity: 0 }}
|
|
162
|
+
animate={{ opacity: 1 }}
|
|
163
|
+
exit={{ opacity: 0 }}
|
|
164
|
+
className="space-y-3"
|
|
165
|
+
>
|
|
166
|
+
{cartItems.map((item) => (
|
|
167
|
+
<CartItem
|
|
168
|
+
key={item.id}
|
|
169
|
+
item={item}
|
|
170
|
+
onUpdateQuantity={updateQuantity}
|
|
171
|
+
onRemoveItem={removeFromCart}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
</motion.div>
|
|
175
|
+
)}
|
|
176
|
+
</AnimatePresence>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{/* Footer */}
|
|
180
|
+
<motion.div
|
|
181
|
+
initial={{ opacity: 0, y: 20 }}
|
|
182
|
+
animate={{ opacity: 1, y: 0 }}
|
|
183
|
+
exit={{ opacity: 0, y: 10 }}
|
|
184
|
+
transition={{ duration: 0.18 }}
|
|
185
|
+
className="p-6 border-t border-white/20 space-y-4"
|
|
186
|
+
>
|
|
187
|
+
{cartItems.length > 0 && (
|
|
188
|
+
<>
|
|
189
|
+
{/* Total */}
|
|
190
|
+
<div className="flex justify-between items-center">
|
|
191
|
+
<span className="subtitle font-bold">סה״כ:</span>
|
|
192
|
+
<span className="text-xl font-bold text-price">
|
|
193
|
+
₪{totalPrice.toFixed(2)}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Checkout Button */}
|
|
198
|
+
<button
|
|
199
|
+
onClick={() => setShowOrderForm(true)}
|
|
200
|
+
className="w-full btn-primary text-center py-3 rounded-lg font-medium transition-all duration-200 hover:brightness-90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50"
|
|
201
|
+
>
|
|
202
|
+
המשך לתשלום
|
|
203
|
+
</button>
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
</>
|
|
207
|
+
)}
|
|
208
|
+
{/* Continue Shopping */}
|
|
209
|
+
<button
|
|
210
|
+
onClick={requestClose}
|
|
211
|
+
className="w-full text-center py-2 content-text hover:text-primary transition-colors duration-200"
|
|
212
|
+
>
|
|
213
|
+
המשך קנייה
|
|
214
|
+
</button>
|
|
215
|
+
</motion.div>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</motion.aside>
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
</AnimatePresence>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export default ShoppingCartModal;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useEffect } from 'react';
|
|
2
|
+
import { useToast } from '../components/ToastProvider';
|
|
3
|
+
|
|
4
|
+
const CART_STORAGE_KEY = 'digishop_shopping_cart';
|
|
5
|
+
|
|
6
|
+
const CartContext = createContext();
|
|
7
|
+
|
|
8
|
+
export const useCart = () => {
|
|
9
|
+
const context = useContext(CartContext);
|
|
10
|
+
if (!context) {
|
|
11
|
+
throw new Error('useCart must be used within a CartProvider');
|
|
12
|
+
}
|
|
13
|
+
return context;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const CartProvider = ({ children, user = null }) => {
|
|
17
|
+
const [cartItems, setCartItems] = useState([]);
|
|
18
|
+
const [isCartOpen, setIsCartOpen] = useState(false);
|
|
19
|
+
const [previousUser, setPreviousUser] = useState(null);
|
|
20
|
+
|
|
21
|
+
const addToast = useToast();
|
|
22
|
+
|
|
23
|
+
const totalItems = cartItems.reduce((sum, item) => sum + item.quantity, 0);
|
|
24
|
+
const totalPrice = cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// If we had a user before and now we don't = logout happened
|
|
28
|
+
if (previousUser && !user) {
|
|
29
|
+
clearCart(); // Auto-clear cart on logout
|
|
30
|
+
}
|
|
31
|
+
setPreviousUser(user);
|
|
32
|
+
}, [user, previousUser]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const savedCart = loadCartFromStorage();
|
|
36
|
+
if (savedCart.length > 0) {
|
|
37
|
+
setCartItems(savedCart);
|
|
38
|
+
}
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
saveCartToStorage(cartItems);
|
|
43
|
+
}, [cartItems]);
|
|
44
|
+
|
|
45
|
+
const saveCartToStorage = (cartItems) => {
|
|
46
|
+
try {
|
|
47
|
+
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartItems));
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Failed to save cart to localStorage:', error);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const loadCartFromStorage = () => {
|
|
54
|
+
try {
|
|
55
|
+
const stored = localStorage.getItem(CART_STORAGE_KEY);
|
|
56
|
+
return stored ? JSON.parse(stored) : [];
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Failed to load cart from localStorage:', error);
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const clearCartFromStorage = () => {
|
|
64
|
+
try {
|
|
65
|
+
localStorage.removeItem(CART_STORAGE_KEY);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Failed to clear cart from localStorage:', error);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const addToCart = (product) => {
|
|
72
|
+
setCartItems(prevItems => {
|
|
73
|
+
const existingItem = prevItems.find(item => item.id === product.id);
|
|
74
|
+
|
|
75
|
+
if (existingItem) {
|
|
76
|
+
return prevItems.map(item =>
|
|
77
|
+
item.id === product.id
|
|
78
|
+
? { ...item, quantity: item.quantity + 1 }
|
|
79
|
+
: item
|
|
80
|
+
);
|
|
81
|
+
} else {
|
|
82
|
+
return [...prevItems, {
|
|
83
|
+
id: product.id,
|
|
84
|
+
name: product.name || product.title,
|
|
85
|
+
price: product.discountPrice || product.price,
|
|
86
|
+
quantity: 1,
|
|
87
|
+
imageUrl: product.imageUrl || ''
|
|
88
|
+
}];
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
addToast("המוצר נוסף לעגלה", "success");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const removeFromCart = (itemId) => {
|
|
95
|
+
setCartItems(prevItems => prevItems.filter(item => item.id !== itemId));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Update item quantity
|
|
99
|
+
const updateQuantity = (itemId, quantity) => {
|
|
100
|
+
if (quantity <= 0) {
|
|
101
|
+
removeFromCart(itemId);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setCartItems(prevItems =>
|
|
106
|
+
prevItems.map(item =>
|
|
107
|
+
item.id === itemId
|
|
108
|
+
? { ...item, quantity }
|
|
109
|
+
: item
|
|
110
|
+
)
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const clearCart = () => {
|
|
115
|
+
setCartItems([]);
|
|
116
|
+
clearCartFromStorage();
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const clearCartOnLogout = () => {
|
|
120
|
+
setCartItems([]);
|
|
121
|
+
clearCartFromStorage();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Toggle cart visibility
|
|
125
|
+
const toggleCart = () => {
|
|
126
|
+
setIsCartOpen(prev => !prev);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const openCart = () => setIsCartOpen(true);
|
|
130
|
+
const closeCart = () => setIsCartOpen(false);
|
|
131
|
+
|
|
132
|
+
// Checkout handler
|
|
133
|
+
const handleCheckout = () => {
|
|
134
|
+
// TODO: Implement checkout logic
|
|
135
|
+
console.log('Proceeding to checkout with items:', cartItems);
|
|
136
|
+
console.log('Total:', totalPrice);
|
|
137
|
+
// For now, just close cart
|
|
138
|
+
closeCart();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const value = {
|
|
142
|
+
// State
|
|
143
|
+
cartItems,
|
|
144
|
+
totalItems,
|
|
145
|
+
totalPrice,
|
|
146
|
+
isCartOpen,
|
|
147
|
+
|
|
148
|
+
// Actions
|
|
149
|
+
addToCart,
|
|
150
|
+
removeFromCart,
|
|
151
|
+
updateQuantity,
|
|
152
|
+
clearCart,
|
|
153
|
+
clearCartOnLogout,
|
|
154
|
+
toggleCart,
|
|
155
|
+
openCart,
|
|
156
|
+
closeCart,
|
|
157
|
+
handleCheckout
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<CartContext.Provider value={value}>
|
|
162
|
+
{children}
|
|
163
|
+
</CartContext.Provider>
|
|
164
|
+
);
|
|
165
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
collection,
|
|
4
|
+
doc,
|
|
5
|
+
getDocs,
|
|
6
|
+
getDoc,
|
|
7
|
+
addDoc,
|
|
8
|
+
setDoc,
|
|
9
|
+
updateDoc,
|
|
10
|
+
deleteDoc,
|
|
11
|
+
orderBy,
|
|
12
|
+
query,
|
|
13
|
+
serverTimestamp
|
|
14
|
+
} from 'firebase/firestore';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generic Firestore Collection Hook
|
|
18
|
+
*
|
|
19
|
+
* @param {string} collectionName - Name of the Firestore collection
|
|
20
|
+
* @param {Function} ModelClass - Constructor function for the model class
|
|
21
|
+
* @param {Object} options - Configuration options
|
|
22
|
+
* @param {string} options.defaultSortField - Default field to sort by (default: 'createdAt')
|
|
23
|
+
* @param {string} options.defaultSortOrder - Default sort order ('asc' or 'desc', default: 'desc')
|
|
24
|
+
* @param {Object} options.errorMessages - Custom error messages for operations
|
|
25
|
+
* @returns {Object} Hook state and methods
|
|
26
|
+
*/
|
|
27
|
+
export const useFirestoreCollection = (
|
|
28
|
+
collectionName,
|
|
29
|
+
ModelClass,
|
|
30
|
+
options = {},
|
|
31
|
+
db
|
|
32
|
+
) => {
|
|
33
|
+
const {
|
|
34
|
+
defaultSortField = 'createdAt',
|
|
35
|
+
defaultSortOrder = 'desc',
|
|
36
|
+
errorMessages = {}
|
|
37
|
+
} = options;
|
|
38
|
+
|
|
39
|
+
// Default error messages with option to override
|
|
40
|
+
const defaultErrorMessages = {
|
|
41
|
+
get: 'קבלת נתונים',
|
|
42
|
+
getById: 'קבלת פריט',
|
|
43
|
+
create: 'יצירת פריט',
|
|
44
|
+
update: 'עדכון פריט',
|
|
45
|
+
delete: 'מחיקת פריט',
|
|
46
|
+
notFound: 'פריט לא נמצא',
|
|
47
|
+
...errorMessages
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const [items, setItems] = useState([]);
|
|
51
|
+
const [loading, setLoading] = useState(false);
|
|
52
|
+
const [error, setError] = useState(null);
|
|
53
|
+
|
|
54
|
+
// Helper function to clear error
|
|
55
|
+
const clearError = () => setError(null);
|
|
56
|
+
|
|
57
|
+
// Helper function to handle errors
|
|
58
|
+
const handleError = (error, operation) => {
|
|
59
|
+
console.error(`Error in ${operation}:`, error);
|
|
60
|
+
setError(`שגיאה ב${defaultErrorMessages[operation] || operation}: ${error.message}`);
|
|
61
|
+
setLoading(false);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Get all items
|
|
65
|
+
const getItems = async (sortField = defaultSortField, sortOrder = defaultSortOrder) => {
|
|
66
|
+
setLoading(true);
|
|
67
|
+
clearError();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const q = query(
|
|
71
|
+
collection(db, collectionName),
|
|
72
|
+
orderBy(sortField, sortOrder)
|
|
73
|
+
);
|
|
74
|
+
const querySnapshot = await getDocs(q);
|
|
75
|
+
|
|
76
|
+
const itemsData = [];
|
|
77
|
+
querySnapshot.forEach((doc) => {
|
|
78
|
+
itemsData.push(new ModelClass({
|
|
79
|
+
...doc.data(),
|
|
80
|
+
id: doc.id
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
setItems(itemsData);
|
|
85
|
+
setLoading(false);
|
|
86
|
+
return itemsData;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
handleError(error, 'get');
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Get single item by ID
|
|
94
|
+
const getItem = async (itemId) => {
|
|
95
|
+
setLoading(true);
|
|
96
|
+
clearError();
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const docRef = doc(db, collectionName, itemId);
|
|
100
|
+
const docSnap = await getDoc(docRef);
|
|
101
|
+
|
|
102
|
+
if (docSnap.exists()) {
|
|
103
|
+
const itemData = new ModelClass({
|
|
104
|
+
...docSnap.data(),
|
|
105
|
+
id: docSnap.id
|
|
106
|
+
});
|
|
107
|
+
setLoading(false);
|
|
108
|
+
return itemData;
|
|
109
|
+
} else {
|
|
110
|
+
const errorMsg = defaultErrorMessages.notFound;
|
|
111
|
+
setError(errorMsg);
|
|
112
|
+
setLoading(false);
|
|
113
|
+
throw new Error(errorMsg);
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
handleError(error, 'getById');
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Create new item
|
|
122
|
+
const createItem = async (itemData, customId = null) => {
|
|
123
|
+
setLoading(true);
|
|
124
|
+
clearError();
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// Create Model instance and validate
|
|
128
|
+
const item = new ModelClass(itemData);
|
|
129
|
+
const validation = item.validate();
|
|
130
|
+
|
|
131
|
+
if (!validation.isValid) {
|
|
132
|
+
throw new Error(validation.errors.join(', '));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Prepare item data with timestamps
|
|
136
|
+
const firestoreData = {
|
|
137
|
+
...item.toFirestore(),
|
|
138
|
+
createdAt: serverTimestamp(),
|
|
139
|
+
updatedAt: serverTimestamp()
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
let docRef;
|
|
143
|
+
if (customId) {
|
|
144
|
+
// Use custom ID
|
|
145
|
+
docRef = doc(db, collectionName, customId);
|
|
146
|
+
await setDoc(docRef, firestoreData);
|
|
147
|
+
} else {
|
|
148
|
+
// Auto-generate ID
|
|
149
|
+
docRef = await addDoc(collection(db, collectionName), firestoreData);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Return Model instance
|
|
153
|
+
const newItem = new ModelClass({
|
|
154
|
+
id: docRef.id,
|
|
155
|
+
...item.toFirestore(),
|
|
156
|
+
createdAt: firestoreData.createdAt,
|
|
157
|
+
updatedAt: firestoreData.updatedAt
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
setLoading(false);
|
|
161
|
+
return newItem;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
handleError(error, 'create');
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const updateItem = async (itemId, updates) => {
|
|
169
|
+
setLoading(true);
|
|
170
|
+
clearError();
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
// Get existing document first
|
|
174
|
+
const docRef = doc(db, collectionName, itemId);
|
|
175
|
+
const existingDoc = await getDoc(docRef);
|
|
176
|
+
|
|
177
|
+
if (!existingDoc.exists()) {
|
|
178
|
+
throw new Error('Document not found');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Merge existing data with updates
|
|
182
|
+
const existingData = existingDoc.data();
|
|
183
|
+
const mergedData = { ...existingData, ...updates, id: itemId };
|
|
184
|
+
|
|
185
|
+
// Create Model instance and validate
|
|
186
|
+
const item = new ModelClass(mergedData);
|
|
187
|
+
const validation = item.validate();
|
|
188
|
+
|
|
189
|
+
if (!validation.isValid) {
|
|
190
|
+
throw new Error(validation.errors.join(', '));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Prepare updates with timestamp
|
|
194
|
+
const updateData = {
|
|
195
|
+
...item.toFirestore(),
|
|
196
|
+
updatedAt: serverTimestamp()
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
await updateDoc(docRef, updateData);
|
|
200
|
+
setLoading(false);
|
|
201
|
+
|
|
202
|
+
// Return Model instance
|
|
203
|
+
return new ModelClass({ id: itemId, ...updateData });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
handleError(error, 'update');
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Delete item
|
|
211
|
+
const deleteItem = async (itemId) => {
|
|
212
|
+
setLoading(true);
|
|
213
|
+
clearError();
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const docRef = doc(db, collectionName, itemId);
|
|
217
|
+
await deleteDoc(docRef);
|
|
218
|
+
setLoading(false);
|
|
219
|
+
|
|
220
|
+
return true;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
handleError(error, 'delete');
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
// State
|
|
229
|
+
items,
|
|
230
|
+
loading,
|
|
231
|
+
error,
|
|
232
|
+
|
|
233
|
+
// Actions
|
|
234
|
+
getItems,
|
|
235
|
+
getItem,
|
|
236
|
+
createItem,
|
|
237
|
+
updateItem,
|
|
238
|
+
deleteItem,
|
|
239
|
+
|
|
240
|
+
// Utility
|
|
241
|
+
clearError
|
|
242
|
+
};
|
|
243
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,8 @@ export { default as SmallItemCard } from './components/SmallItemCard';
|
|
|
7
7
|
export { default as Hero } from './components/Hero'
|
|
8
8
|
export { default as QAAccordion } from './components/QAAccordion'
|
|
9
9
|
export { default as AdvantagesList } from './components/AdvantagesList'
|
|
10
|
+
export { default as ShoppingCartModal } from './components/cart/ShoppingCartModal'
|
|
11
|
+
export { default as FloatingCartButton } from './components/cart/FloatingCartButton'
|
|
10
12
|
|
|
11
13
|
// Modals
|
|
12
14
|
export { default as ItemDetailsModal } from './components/modals/ItemDetailsModal'
|
|
@@ -14,6 +16,8 @@ export { default as ItemDetailsModal } from './components/modals/ItemDetailsModa
|
|
|
14
16
|
// Context
|
|
15
17
|
export { ItemModalProvider, useItemModal } from './context/ItemModalContext';
|
|
16
18
|
export { ToastProvider, useToast } from './components/ToastProvider'
|
|
19
|
+
export { CartProvider, useCart } from './context/CartContext'
|
|
17
20
|
|
|
18
21
|
// Hooks
|
|
19
|
-
export { default as useScrollLock } from './hooks/useScrollLock'
|
|
22
|
+
export { default as useScrollLock } from './hooks/useScrollLock'
|
|
23
|
+
export { useFirestoreCollection } from './hooks/useFirestoreCollection'
|