@codesinger0/shared-components 1.0.14 → 1.0.15
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.
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { X, ShoppingCart } from 'lucide-react';
|
|
4
|
+
import SmallButton from '../SmallButton';
|
|
5
|
+
import useScrollLock from '../hooks/useScrollLock';
|
|
6
|
+
|
|
7
|
+
const ItemDetailsModal = ({ item, isOpen, onClose, onAddToCart }) => {
|
|
8
|
+
const [localOpen, setLocalOpen] = useState(Boolean(isOpen));
|
|
9
|
+
const closingRequestedRef = useRef(false);
|
|
10
|
+
|
|
11
|
+
// Sync with external isOpen state
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (isOpen) {
|
|
14
|
+
setLocalOpen(true);
|
|
15
|
+
closingRequestedRef.current = false;
|
|
16
|
+
} else {
|
|
17
|
+
setLocalOpen(false);
|
|
18
|
+
}
|
|
19
|
+
}, [isOpen]);
|
|
20
|
+
|
|
21
|
+
useScrollLock(isOpen);
|
|
22
|
+
|
|
23
|
+
// Handle modal close request
|
|
24
|
+
const requestClose = () => {
|
|
25
|
+
closingRequestedRef.current = true;
|
|
26
|
+
setLocalOpen(false);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Handle exit complete
|
|
30
|
+
const handleExitComplete = () => {
|
|
31
|
+
if (closingRequestedRef.current) {
|
|
32
|
+
onClose();
|
|
33
|
+
closingRequestedRef.current = false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Handle add to cart
|
|
38
|
+
const handleAddToCart = () => {
|
|
39
|
+
onAddToCart(item);
|
|
40
|
+
requestClose();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!item) return null;
|
|
44
|
+
|
|
45
|
+
// Calculate discount info
|
|
46
|
+
const hasDiscount = item.discountPrice && item.discountPrice < item.price;
|
|
47
|
+
const discountPercentage = hasDiscount
|
|
48
|
+
? Math.round(((item.price - item.discountPrice) / item.price) * 100)
|
|
49
|
+
: 0;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={`fixed inset-0 z-50 flex items-center justify-center ${isOpen ? '' : 'pointer-events-none'} supports-[height:100dvh]:h-[100dvh]`}>
|
|
53
|
+
<AnimatePresence initial={false} mode="wait" onExitComplete={handleExitComplete}>
|
|
54
|
+
{localOpen && (
|
|
55
|
+
<>
|
|
56
|
+
{/* Backdrop */}
|
|
57
|
+
<motion.div
|
|
58
|
+
key="backdrop"
|
|
59
|
+
initial={{ opacity: 0 }}
|
|
60
|
+
animate={{ opacity: 1 }}
|
|
61
|
+
exit={{ opacity: 0 }}
|
|
62
|
+
transition={{ duration: 0.18 }}
|
|
63
|
+
className="absolute inset-0 bg-black bg-opacity-30 backdrop-blur-sm pointer-events-auto"
|
|
64
|
+
onClick={requestClose}
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
{/* Modal Panel */}
|
|
69
|
+
<motion.div
|
|
70
|
+
key="modal-panel"
|
|
71
|
+
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
72
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
73
|
+
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
74
|
+
transition={{
|
|
75
|
+
type: "spring",
|
|
76
|
+
stiffness: 300,
|
|
77
|
+
damping: 30
|
|
78
|
+
}}
|
|
79
|
+
className="relative w-full max-w-2xl mx-4 max-h-[90vh] glass-card overflow-hidden pointer-events-auto"
|
|
80
|
+
dir="rtl"
|
|
81
|
+
role="dialog"
|
|
82
|
+
aria-modal="true"
|
|
83
|
+
onClick={(e) => e.stopPropagation()}
|
|
84
|
+
>
|
|
85
|
+
{/* Header */}
|
|
86
|
+
<div className="flex justify-between items-center p-6 border-b border-white/20">
|
|
87
|
+
<h2 className="subtitle font-bold text-right">{item.name || item.label}</h2>
|
|
88
|
+
<button
|
|
89
|
+
onClick={requestClose}
|
|
90
|
+
className="content-text hover:text-primary hover:bg-gray-100 p-2 rounded-md transition-colors duration-200"
|
|
91
|
+
aria-label="סגור"
|
|
92
|
+
>
|
|
93
|
+
<X size={20} />
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Content */}
|
|
98
|
+
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
|
99
|
+
<div className="grid md:grid-cols-2 gap-6">
|
|
100
|
+
{/* Image Section */}
|
|
101
|
+
<div className="relative">
|
|
102
|
+
<div className="relative w-full h-64 md:h-80 overflow-hidden rounded-xl bg-gray-100">
|
|
103
|
+
{item.imageUrl ? (
|
|
104
|
+
<img
|
|
105
|
+
src={item.imageUrl}
|
|
106
|
+
alt={item.name || item.label}
|
|
107
|
+
className="w-full h-full object-cover"
|
|
108
|
+
onError={(e) => {
|
|
109
|
+
e.target.style.display = 'none';
|
|
110
|
+
e.target.nextElementSibling.style.display = 'flex';
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
) : null}
|
|
114
|
+
|
|
115
|
+
{/* Image Fallback */}
|
|
116
|
+
<div
|
|
117
|
+
className="w-full h-full bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center"
|
|
118
|
+
style={{ display: item.imageUrl ? 'none' : 'flex' }}
|
|
119
|
+
>
|
|
120
|
+
<div className="text-center text-gray-400">
|
|
121
|
+
<div className="text-4xl mb-2">🍰</div>
|
|
122
|
+
<div className="text-sm font-medium">תמונה</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* Discount Badge */}
|
|
127
|
+
{hasDiscount && (
|
|
128
|
+
<div className="absolute top-3 left-3 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-bold shadow-lg">
|
|
129
|
+
-{discountPercentage}%
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Details Section */}
|
|
136
|
+
<div className="space-y-4">
|
|
137
|
+
{/* Description */}
|
|
138
|
+
{item.description && (
|
|
139
|
+
<div>
|
|
140
|
+
<h3 className="content-text font-medium mb-2">תיאור</h3>
|
|
141
|
+
<p className="caption-text leading-relaxed">
|
|
142
|
+
{item.description}
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{/* Price Section */}
|
|
148
|
+
<div className="space-y-2">
|
|
149
|
+
<h3 className="content-text font-medium">מחיר</h3>
|
|
150
|
+
{hasDiscount ? (
|
|
151
|
+
<div className="space-y-2">
|
|
152
|
+
<div className="flex items-center gap-3">
|
|
153
|
+
<span className="text-2xl font-bold text-price">
|
|
154
|
+
₪{item.discountPrice}
|
|
155
|
+
</span>
|
|
156
|
+
<span className="text-sm text-gray-400 line-through">
|
|
157
|
+
₪{item.price}
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="text-sm text-price font-medium">
|
|
161
|
+
חיסכון ₪{item.price - item.discountPrice}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
) : (
|
|
165
|
+
<div className="text-2xl font-bold text-price">
|
|
166
|
+
₪{item.price}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Add to Cart Button */}
|
|
172
|
+
<div className="pt-4">
|
|
173
|
+
<SmallButton
|
|
174
|
+
onClick={handleAddToCart}
|
|
175
|
+
className="w-full flex items-center justify-center gap-2"
|
|
176
|
+
>
|
|
177
|
+
<ShoppingCart size={16} />
|
|
178
|
+
הוסף לעגלה
|
|
179
|
+
</SmallButton>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</motion.div>
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
</AnimatePresence>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export default ItemDetailsModal;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/hooks/useScrollLock.js
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
const useScrollLock = (isOpen) => {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
if (isOpen) {
|
|
7
|
+
// Store original styles
|
|
8
|
+
const originalStyle = window.getComputedStyle(document.body);
|
|
9
|
+
const originalOverflow = originalStyle.overflow;
|
|
10
|
+
const originalPosition = originalStyle.position;
|
|
11
|
+
|
|
12
|
+
// Get current scroll position
|
|
13
|
+
const scrollY = window.scrollY;
|
|
14
|
+
|
|
15
|
+
// Apply scroll lock with Mobile Safari fixes
|
|
16
|
+
document.body.style.overflow = 'hidden';
|
|
17
|
+
document.body.style.position = 'fixed';
|
|
18
|
+
document.body.style.top = `-${scrollY}px`;
|
|
19
|
+
document.body.style.left = '0';
|
|
20
|
+
document.body.style.right = '0';
|
|
21
|
+
document.body.style.width = '100%';
|
|
22
|
+
|
|
23
|
+
// Mobile Safari specific: prevent viewport changes
|
|
24
|
+
const viewport = document.querySelector('meta[name="viewport"]');
|
|
25
|
+
const originalViewport = viewport?.content;
|
|
26
|
+
if (viewport) {
|
|
27
|
+
viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Cleanup function
|
|
31
|
+
return () => {
|
|
32
|
+
// Restore original styles
|
|
33
|
+
document.body.style.overflow = originalOverflow;
|
|
34
|
+
document.body.style.position = originalPosition;
|
|
35
|
+
document.body.style.top = '';
|
|
36
|
+
document.body.style.left = '';
|
|
37
|
+
document.body.style.right = '';
|
|
38
|
+
document.body.style.width = '';
|
|
39
|
+
|
|
40
|
+
// Restore scroll position
|
|
41
|
+
window.scrollTo(0, scrollY);
|
|
42
|
+
|
|
43
|
+
// Restore viewport
|
|
44
|
+
if (viewport && originalViewport) {
|
|
45
|
+
viewport.content = originalViewport;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}, [isOpen]);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default useScrollLock;
|
package/dist/index.js
CHANGED
|
@@ -5,5 +5,11 @@ export { default as LargeItemCard } from './components/LargeItemCard';
|
|
|
5
5
|
export { default as SmallItemsGrid } from './components/SmallItemsGrid';
|
|
6
6
|
export { default as SmallItemCard } from './components/SmallItemCard';
|
|
7
7
|
|
|
8
|
+
// Modals
|
|
9
|
+
export { ItemDetailsModal } from './components/modals/ItemDetailsModal'
|
|
10
|
+
|
|
8
11
|
// Context
|
|
9
12
|
export { ItemModalProvider, useItemModal } from './context/ItemModalContext';
|
|
13
|
+
|
|
14
|
+
// Hooks
|
|
15
|
+
export { UseScrollLock } from './hooks/useScrollLock'
|