@codesinger0/shared-components 1.0.50 → 1.0.52
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,333 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const ContactUs = ({
|
|
4
|
+
title = "צרו קשר",
|
|
5
|
+
subtitle = "נשמח לשמוע מכם ולענות על כל שאלה",
|
|
6
|
+
agreeToTermsText = `אני מסכים/ה לתנאי השימוש ומדיניות הפרטיות של האתר,
|
|
7
|
+
ומאשר/ת קבלת עדכונים שיווקיים באימייל
|
|
8
|
+
(ניתן לבטל בכל עת).`,
|
|
9
|
+
submitContactMessage = () => { },
|
|
10
|
+
businessInfo = {},
|
|
11
|
+
className = ""
|
|
12
|
+
}) => {
|
|
13
|
+
const [formData, setFormData] = useState({
|
|
14
|
+
name: '',
|
|
15
|
+
phone: '',
|
|
16
|
+
email: '',
|
|
17
|
+
message: '',
|
|
18
|
+
agreeToTerms: false
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const [errors, setErrors] = useState({});
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
const [submitted, setSubmitted] = useState(false);
|
|
24
|
+
const { submitContactMessage } = useContactMessage();
|
|
25
|
+
|
|
26
|
+
// Handle input changes
|
|
27
|
+
const handleInputChange = (e) => {
|
|
28
|
+
const { name, value, type, checked } = e.target;
|
|
29
|
+
setFormData(prev => ({
|
|
30
|
+
...prev,
|
|
31
|
+
[name]: type === 'checkbox' ? checked : value
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// Clear error for this field when user starts typing
|
|
35
|
+
if (errors[name]) {
|
|
36
|
+
setErrors(prev => ({ ...prev, [name]: '' }));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Validation functions
|
|
41
|
+
const validateEmail = (email) => {
|
|
42
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
43
|
+
return emailRegex.test(email);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const validatePhone = (phone) => {
|
|
47
|
+
const phoneRegex = /^[0-9\-\+\(\)\s]{10,}$/;
|
|
48
|
+
return phoneRegex.test(phone.replace(/\s/g, ''));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Form validation
|
|
52
|
+
const validateForm = () => {
|
|
53
|
+
const newErrors = {};
|
|
54
|
+
|
|
55
|
+
if (!formData.name.trim()) {
|
|
56
|
+
newErrors.name = 'שם מלא הוא שדה חובה';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!formData.phone.trim()) {
|
|
60
|
+
newErrors.phone = 'מספר טלפון הוא שדה חובה';
|
|
61
|
+
} else if (!validatePhone(formData.phone)) {
|
|
62
|
+
newErrors.phone = 'מספר טלפון לא תקין';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!formData.email.trim()) {
|
|
66
|
+
newErrors.email = 'כתובת אימייל היא שדה חובה';
|
|
67
|
+
} else if (!validateEmail(formData.email)) {
|
|
68
|
+
newErrors.email = 'כתובת אימייל לא תקינה';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (formData.message && formData.message.trim().length > 500) {
|
|
72
|
+
newErrors.message = 'ההודעה לא יכולה להיות ארוכה מ-500 תווים';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!formData.agreeToTerms) {
|
|
76
|
+
newErrors.agreeToTerms = 'יש לאשר את תנאי השימוש והפרטיות';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setErrors(newErrors);
|
|
80
|
+
return Object.keys(newErrors).length === 0;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleSubmit = async () => {
|
|
84
|
+
if (!validateForm()) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setLoading(true);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const result = await submitContactMessage({
|
|
92
|
+
name: formData.name,
|
|
93
|
+
email: formData.email,
|
|
94
|
+
phone: formData.phone,
|
|
95
|
+
message: formData.message || `צרו קשר מהאתר - שם: ${formData.name}, טלפון: ${formData.phone}, אימייל: ${formData.email}. ${formData.agreeToTerms ? 'הסכים לתנאי השימוש.' : ''}`
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (result) {
|
|
99
|
+
console.log('Contact message submitted successfully:', result);
|
|
100
|
+
setSubmitted(true);
|
|
101
|
+
// Reset form
|
|
102
|
+
setFormData({
|
|
103
|
+
name: '',
|
|
104
|
+
phone: '',
|
|
105
|
+
email: '',
|
|
106
|
+
message: '',
|
|
107
|
+
agreeToTerms: false
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
setErrors({ submit: 'אירעה שגיאה בשליחת הטופס. אנא נסו שוב.' });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error('Submission error:', error);
|
|
115
|
+
setErrors({ submit: 'אירעה שגיאה בשליחת הטופס. אנא נסו שוב.' });
|
|
116
|
+
} finally {
|
|
117
|
+
setLoading(false);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
// Success message component
|
|
123
|
+
if (submitted) {
|
|
124
|
+
return (
|
|
125
|
+
<section className={`py-16 px-4 bg-main ${className}`} dir="rtl">
|
|
126
|
+
<div className="max-w-4xl mx-auto">
|
|
127
|
+
<div className="glass-card p-8 text-center">
|
|
128
|
+
<h3 className="title mb-4">תודה רבה!</h3>
|
|
129
|
+
<p className="subtitle mb-6">
|
|
130
|
+
הטופס נשלח בהצלחה. נחזור אליכם בהקדם האפשרי.
|
|
131
|
+
</p>
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => setSubmitted(false)}
|
|
134
|
+
className="btn-secondary"
|
|
135
|
+
>
|
|
136
|
+
שלח הודעה נוספת
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</section>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<section className={`py-16 px-4 bg-main ${className}`} dir="rtl">
|
|
146
|
+
<div className="max-w-4xl mx-auto">
|
|
147
|
+
{/* Header */}
|
|
148
|
+
<div className="text-center mb-12">
|
|
149
|
+
<h2 className="title mb-4">{title}</h2>
|
|
150
|
+
<p className="subtitle text-gray-600">{subtitle}</p>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Contact Form */}
|
|
154
|
+
<div className="glass-card p-8">
|
|
155
|
+
<div className="space-y-4">
|
|
156
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
157
|
+
{/* Name Field */}
|
|
158
|
+
<div>
|
|
159
|
+
<label htmlFor="name" className="block subtitle font-semibold mb-2">
|
|
160
|
+
שם מלא *
|
|
161
|
+
</label>
|
|
162
|
+
<input
|
|
163
|
+
type="text"
|
|
164
|
+
id="name"
|
|
165
|
+
name="name"
|
|
166
|
+
value={formData.name}
|
|
167
|
+
onChange={handleInputChange}
|
|
168
|
+
className={`w-full p-4 rounded-lg border-2 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-20 ${errors.name
|
|
169
|
+
? 'border-red-500 bg-red-50'
|
|
170
|
+
: 'border-gray-300 hover:border-gray-400 focus:border-primary'
|
|
171
|
+
}`}
|
|
172
|
+
placeholder="הכניסו את שמכם המלא"
|
|
173
|
+
dir="rtl"
|
|
174
|
+
/>
|
|
175
|
+
{errors.name && (
|
|
176
|
+
<p className="text-red-500 text-sm mt-2">{errors.name}</p>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Phone Field */}
|
|
181
|
+
<div>
|
|
182
|
+
<label htmlFor="phone" className="block subtitle font-semibold mb-2">
|
|
183
|
+
מספר טלפון *
|
|
184
|
+
</label>
|
|
185
|
+
<input
|
|
186
|
+
type="tel"
|
|
187
|
+
id="phone"
|
|
188
|
+
name="phone"
|
|
189
|
+
value={formData.phone}
|
|
190
|
+
onChange={handleInputChange}
|
|
191
|
+
className={`w-full p-4 rounded-lg border-2 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-20 ${errors.phone
|
|
192
|
+
? 'border-red-500 bg-red-50'
|
|
193
|
+
: 'border-gray-300 hover:border-gray-400 focus:border-primary'
|
|
194
|
+
}`}
|
|
195
|
+
placeholder="050-1234567"
|
|
196
|
+
dir="ltr"
|
|
197
|
+
/>
|
|
198
|
+
{errors.phone && (
|
|
199
|
+
<p className="text-red-500 text-sm mt-2">{errors.phone}</p>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Email Field */}
|
|
205
|
+
<div>
|
|
206
|
+
<label htmlFor="email" className="block subtitle font-semibold mb-2">
|
|
207
|
+
כתובת אימייל *
|
|
208
|
+
</label>
|
|
209
|
+
<input
|
|
210
|
+
type="email"
|
|
211
|
+
id="email"
|
|
212
|
+
name="email"
|
|
213
|
+
value={formData.email}
|
|
214
|
+
onChange={handleInputChange}
|
|
215
|
+
className={`w-full p-4 rounded-lg border-2 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-20 ${errors.email
|
|
216
|
+
? 'border-red-500 bg-red-50'
|
|
217
|
+
: 'border-gray-300 hover:border-gray-400 focus:border-primary'
|
|
218
|
+
}`}
|
|
219
|
+
placeholder="example@email.com"
|
|
220
|
+
dir="ltr"
|
|
221
|
+
/>
|
|
222
|
+
{errors.email && (
|
|
223
|
+
<p className="text-red-500 text-sm mt-2">{errors.email}</p>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Message Field */}
|
|
228
|
+
<div>
|
|
229
|
+
<label htmlFor="message" className="block subtitle font-semibold mb-2">
|
|
230
|
+
הודעה (אופציונלי)
|
|
231
|
+
</label>
|
|
232
|
+
<textarea
|
|
233
|
+
id="message"
|
|
234
|
+
name="message"
|
|
235
|
+
value={formData.message}
|
|
236
|
+
onChange={handleInputChange}
|
|
237
|
+
rows={4}
|
|
238
|
+
maxLength={500}
|
|
239
|
+
className={`w-full p-4 rounded-lg border-2 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-20 resize-vertical ${errors.message
|
|
240
|
+
? 'border-red-500 bg-red-50'
|
|
241
|
+
: 'border-gray-300 hover:border-gray-400 focus:border-primary'
|
|
242
|
+
}`}
|
|
243
|
+
placeholder="ספרו לנו מה אתם מחפשים, איך אנחנו יכולים לעזור לכם, או כל דבר אחר שתרצו לשתף איתנו..."
|
|
244
|
+
dir="rtl"
|
|
245
|
+
/>
|
|
246
|
+
<div className="flex justify-between items-center mt-1">
|
|
247
|
+
{errors.message && (
|
|
248
|
+
<p className="text-red-500 text-sm">{errors.message}</p>
|
|
249
|
+
)}
|
|
250
|
+
<span className="text-xs text-gray-500 mr-auto">
|
|
251
|
+
{formData.message.length}/500 תווים
|
|
252
|
+
</span>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
{/* Checkbox Agreement */}
|
|
258
|
+
<div className="px-4 rounded-lg">
|
|
259
|
+
<label className="flex items-start space-x-3 space-x-reverse cursor-pointer">
|
|
260
|
+
<input
|
|
261
|
+
type="checkbox"
|
|
262
|
+
name="agreeToTerms"
|
|
263
|
+
checked={formData.agreeToTerms}
|
|
264
|
+
onChange={handleInputChange}
|
|
265
|
+
className="mt-1 w-5 h-5 text-primary border-2 border-gray-300 rounded focus:ring-primary focus:ring-2 focus:ring-opacity-20"
|
|
266
|
+
/>
|
|
267
|
+
<div className="flex-1">
|
|
268
|
+
<span className="content-text leading-relaxed">
|
|
269
|
+
{agreeToTermsText}
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
</label>
|
|
273
|
+
{errors.agreeToTerms && (
|
|
274
|
+
<p className="text-red-500 text-sm mt-2">{errors.agreeToTerms}</p>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Submit Error */}
|
|
279
|
+
{errors.submit && (
|
|
280
|
+
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
|
281
|
+
{errors.submit}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* Submit Button */}
|
|
286
|
+
<div className="text-center pt-4">
|
|
287
|
+
<button
|
|
288
|
+
onClick={handleSubmit}
|
|
289
|
+
disabled={loading}
|
|
290
|
+
className={`btn-primary text-lg px-8 py-4 inline-flex items-center gap-3 transition-all duration-200 ${loading
|
|
291
|
+
? 'opacity-50 cursor-not-allowed'
|
|
292
|
+
: 'hover:brightness-90 hover:scale-105'
|
|
293
|
+
}`}
|
|
294
|
+
>
|
|
295
|
+
{loading ? (
|
|
296
|
+
<>
|
|
297
|
+
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
298
|
+
שולח...
|
|
299
|
+
</>
|
|
300
|
+
) : (
|
|
301
|
+
<>
|
|
302
|
+
שלח הודעה
|
|
303
|
+
<span className="text-xl">→</span>
|
|
304
|
+
</>
|
|
305
|
+
)}
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
{/* Contact Info */}
|
|
311
|
+
<div className="mt-12 pt-8 border-t border-gray-200">
|
|
312
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-center">
|
|
313
|
+
<div>
|
|
314
|
+
<h4 className="subtitle font-semibold mb-1">טלפון</h4>
|
|
315
|
+
<p className="content-text">{businessInfo.phone}</p>
|
|
316
|
+
</div>
|
|
317
|
+
<div>
|
|
318
|
+
<h4 className="subtitle font-semibold mb-1">אימייל</h4>
|
|
319
|
+
<p className="content-text">{businessInfo.email}</p>
|
|
320
|
+
</div>
|
|
321
|
+
<div>
|
|
322
|
+
<h4 className="subtitle font-semibold mb-1">שעות פעילות</h4>
|
|
323
|
+
<p className="content-text">{businessInfo.hours}</p>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</section>
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export default ContactUs;
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
4
|
+
import useScrollLock from '../hooks/useScrollLock'
|
|
5
|
+
|
|
6
|
+
const MasonryImageList = ({ images = [], cols = 3, onImageClick = () => { } }) => {
|
|
7
|
+
// cols is the desktop column count; mobile will be forced to 2 via CSS
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<div
|
|
11
|
+
className="masonry-columns h-full overflow-y-auto"
|
|
12
|
+
// set CSS variable so the component can control desktop column count
|
|
13
|
+
style={{ ['--masonry-cols']: cols }}
|
|
14
|
+
>
|
|
15
|
+
{images.map((img, i) => (
|
|
16
|
+
<button
|
|
17
|
+
key={i}
|
|
18
|
+
className="masonry-item w-full block mb-4 p-0 border-0 bg-transparent text-left"
|
|
19
|
+
onClick={() => onImageClick(i)}
|
|
20
|
+
aria-label={`open image ${i + 1}`}
|
|
21
|
+
>
|
|
22
|
+
<img
|
|
23
|
+
src={img.src || img}
|
|
24
|
+
alt={img.alt || `image-${i + 1}`}
|
|
25
|
+
loading="lazy"
|
|
26
|
+
className="masonry-img w-full h-auto rounded-lg block"
|
|
27
|
+
style={{ display: 'block' }}
|
|
28
|
+
/>
|
|
29
|
+
</button>
|
|
30
|
+
))}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<style jsx>{`
|
|
34
|
+
/* Container uses CSS columns to create the masonry stacking */
|
|
35
|
+
.masonry-columns {
|
|
36
|
+
column-gap: 0.75rem;
|
|
37
|
+
/* default: mobile 2 columns */
|
|
38
|
+
column-count: 2;
|
|
39
|
+
padding-right: 0.25rem; /* small padding so rtl scroll doesn't cut content */
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Desktop: use the --masonry-cols variable */
|
|
43
|
+
@media (min-width: 1024px) {
|
|
44
|
+
.masonry-columns {
|
|
45
|
+
column-count: var(--masonry-cols);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Make each item avoid breaking across columns and have bottom margin */
|
|
50
|
+
.masonry-item {
|
|
51
|
+
/* avoid splitting an item between columns */
|
|
52
|
+
break-inside: avoid;
|
|
53
|
+
-webkit-column-break-inside: avoid;
|
|
54
|
+
page-break-inside: avoid;
|
|
55
|
+
margin-bottom: 0.75rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Image style */
|
|
59
|
+
.masonry-img {
|
|
60
|
+
width: 100%;
|
|
61
|
+
height: auto;
|
|
62
|
+
display: block;
|
|
63
|
+
object-fit: cover;
|
|
64
|
+
/* give images a subtle shadow & transition if desired */
|
|
65
|
+
transition: transform 0.2s ease, box-shadow 0.15s ease;
|
|
66
|
+
}
|
|
67
|
+
.masonry-item:hover .masonry-img {
|
|
68
|
+
transform: translateY(-2px) scale(1.01);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Optional: nicer scrollbar for the image column */
|
|
72
|
+
.masonry-columns::-webkit-scrollbar {
|
|
73
|
+
width: 10px;
|
|
74
|
+
}
|
|
75
|
+
.masonry-columns::-webkit-scrollbar-thumb {
|
|
76
|
+
border-radius: 9999px;
|
|
77
|
+
background: rgba(0, 0, 0, 0.12);
|
|
78
|
+
}
|
|
79
|
+
`}</style>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Image lightbox modal component
|
|
85
|
+
const ImageLightbox = ({
|
|
86
|
+
images,
|
|
87
|
+
currentIndex,
|
|
88
|
+
isOpen,
|
|
89
|
+
onClose,
|
|
90
|
+
onNavigate
|
|
91
|
+
}) => {
|
|
92
|
+
const currentImage = images[currentIndex];
|
|
93
|
+
|
|
94
|
+
useScrollLock(isOpen);
|
|
95
|
+
|
|
96
|
+
if (!currentImage) return null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<AnimatePresence>
|
|
100
|
+
{isOpen && (
|
|
101
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center supports-[height:100dvh]:h-[100dvh]">
|
|
102
|
+
{/* Backdrop */}
|
|
103
|
+
<motion.div
|
|
104
|
+
key="backdrop"
|
|
105
|
+
initial={{ opacity: 0 }}
|
|
106
|
+
animate={{ opacity: 1 }}
|
|
107
|
+
exit={{ opacity: 0 }}
|
|
108
|
+
transition={{ duration: 0.2 }}
|
|
109
|
+
className="absolute inset-0 bg-black bg-opacity-90"
|
|
110
|
+
onClick={onClose}
|
|
111
|
+
/>
|
|
112
|
+
|
|
113
|
+
{/* Image Container */}
|
|
114
|
+
<motion.div
|
|
115
|
+
key="image-container"
|
|
116
|
+
initial={{ opacity: 0, scale: 0.8 }}
|
|
117
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
118
|
+
exit={{ opacity: 0, scale: 0.8 }}
|
|
119
|
+
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
|
120
|
+
className="relative max-w-[90vw] max-h-[90vh] z-10"
|
|
121
|
+
onClick={(e) => e.stopPropagation()}
|
|
122
|
+
>
|
|
123
|
+
{/* Main Image */}
|
|
124
|
+
<img
|
|
125
|
+
src={currentImage.src}
|
|
126
|
+
alt={currentImage.alt || currentImage.title || 'Gallery image'}
|
|
127
|
+
className="w-screen h-screen object-contain rounded-lg shadow-2xl"
|
|
128
|
+
/>
|
|
129
|
+
|
|
130
|
+
{/* Close Button */}
|
|
131
|
+
<button
|
|
132
|
+
onClick={onClose}
|
|
133
|
+
className="absolute top-4 right-4 bg-black bg-opacity-50 hover:bg-opacity-70 text-white p-2 rounded-full transition-all duration-200"
|
|
134
|
+
aria-label="Close lightbox"
|
|
135
|
+
>
|
|
136
|
+
<X size={24} />
|
|
137
|
+
</button>
|
|
138
|
+
|
|
139
|
+
{/* Navigation Arrows */}
|
|
140
|
+
{images.length > 1 && (
|
|
141
|
+
<>
|
|
142
|
+
{currentIndex > 0 && (
|
|
143
|
+
<button
|
|
144
|
+
onClick={() => onNavigate(currentIndex - 1)}
|
|
145
|
+
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-black bg-opacity-50 hover:bg-opacity-70 text-white p-3 rounded-full transition-all duration-200"
|
|
146
|
+
aria-label="Previous image"
|
|
147
|
+
>
|
|
148
|
+
<ChevronLeft size={24} />
|
|
149
|
+
</button>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{currentIndex < images.length - 1 && (
|
|
153
|
+
<button
|
|
154
|
+
onClick={() => onNavigate(currentIndex + 1)}
|
|
155
|
+
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-black bg-opacity-50 hover:bg-opacity-70 text-white p-3 rounded-full transition-all duration-200"
|
|
156
|
+
aria-label="Next image"
|
|
157
|
+
>
|
|
158
|
+
<ChevronRight size={24} />
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{/* Image Counter */}
|
|
165
|
+
{images.length > 1 && (
|
|
166
|
+
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-sm">
|
|
167
|
+
{currentIndex + 1} / {images.length}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* Image Title */}
|
|
172
|
+
{currentImage.title && (
|
|
173
|
+
<div className="absolute bottom-4 right-4 bg-black bg-opacity-50 text-white px-3 py-2 rounded-lg max-w-xs">
|
|
174
|
+
<div className="text-sm font-medium" dir="rtl">
|
|
175
|
+
{currentImage.title}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</motion.div>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</AnimatePresence>
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const MasonryItemCard = ({
|
|
187
|
+
images = [],
|
|
188
|
+
title,
|
|
189
|
+
subtitle,
|
|
190
|
+
description,
|
|
191
|
+
reverse = false,
|
|
192
|
+
textCols = 1,
|
|
193
|
+
imagesCols = 1,
|
|
194
|
+
mobileHeight = '60vh', // used for small screens
|
|
195
|
+
enableLightbox = true,
|
|
196
|
+
onImageClick,
|
|
197
|
+
className = '',
|
|
198
|
+
cols = 3, // default for desktop masonry (we want 3 per row on desktop)
|
|
199
|
+
...props
|
|
200
|
+
}) => {
|
|
201
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
202
|
+
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
|
203
|
+
|
|
204
|
+
const totalCols = textCols + imagesCols;
|
|
205
|
+
// CSS variables to be applied to grid wrapper:
|
|
206
|
+
const textPct = `${(textCols / totalCols) * 100}%`;
|
|
207
|
+
const imagesPct = `${(imagesCols / totalCols) * 100}%`;
|
|
208
|
+
|
|
209
|
+
const handleImageClick = useCallback(
|
|
210
|
+
(index) => {
|
|
211
|
+
setCurrentImageIndex(index);
|
|
212
|
+
|
|
213
|
+
if (onImageClick) onImageClick(images[index], index);
|
|
214
|
+
|
|
215
|
+
if (enableLightbox) setLightboxOpen(true);
|
|
216
|
+
},
|
|
217
|
+
[images, onImageClick, enableLightbox]
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const handleLightboxClose = useCallback(() => setLightboxOpen(false), []);
|
|
221
|
+
const handleLightboxNavigate = useCallback((index) => setCurrentImageIndex(index), []);
|
|
222
|
+
|
|
223
|
+
if (!images || images.length === 0) {
|
|
224
|
+
return (
|
|
225
|
+
<section className={`py-16 px-8 ${className}`} {...props}>
|
|
226
|
+
<div className="glass-card relative min-h-[48vh]">
|
|
227
|
+
<div className="max-w-7xl mx-auto px-6 pt-16">
|
|
228
|
+
<div className="text-center" dir="rtl">
|
|
229
|
+
<h2 className="title mb-4">{title}</h2>
|
|
230
|
+
<h3 className="subtitle mb-6">{subtitle}</h3>
|
|
231
|
+
<p className="content-text">אין תמונות להצגה</p>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</section>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<>
|
|
241
|
+
<section className={`relative w-full overflow-visible py-16 px-4 ${className}`} {...props}>
|
|
242
|
+
{/* NOTE: --text-frac / --image-frac are set inline so CSS can use them at lg and up */}
|
|
243
|
+
<div
|
|
244
|
+
className="glass-card relative min-h-[48vh] flex flex-col"
|
|
245
|
+
style={{ '--text-frac': textPct, '--image-frac': imagesPct }}
|
|
246
|
+
>
|
|
247
|
+
{/* top padding/content wrapper */}
|
|
248
|
+
<div className="max-w-7xl mx-auto px-6 pt-16 w-full flex-1">
|
|
249
|
+
{/* layout-grid will switch to two-column fractions on lg+ */}
|
|
250
|
+
<div className="layout-grid w-full h-full" dir="rtl">
|
|
251
|
+
{/* Text Content */}
|
|
252
|
+
<div
|
|
253
|
+
className={`text-column ${reverse ? 'lg:order-2' : 'lg:order-1'} px-2`}
|
|
254
|
+
>
|
|
255
|
+
<h2 className="title mb-6">{title}</h2>
|
|
256
|
+
<h3 className="subtitle mb-6">{subtitle}</h3>
|
|
257
|
+
<p className="content-text max-w-md mx-auto lg:mx-0">{description}</p>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* Desktop Masonry Images (visible on lg+) */}
|
|
261
|
+
<div
|
|
262
|
+
className={`image-column hidden lg:block ${reverse ? 'lg:order-1' : 'lg:order-2'} px-2 mb-8`}
|
|
263
|
+
>
|
|
264
|
+
{/* image-column fills available height and scrolls if needed */}
|
|
265
|
+
<MasonryImageList
|
|
266
|
+
images={images}
|
|
267
|
+
onImageClick={handleImageClick}
|
|
268
|
+
cols={cols /* desktop: 3 by default from caller prop */}
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
{/* Mobile Masonry Images (visible on mobile) */}
|
|
275
|
+
<div
|
|
276
|
+
className="block lg:hidden mt-8 w-full px-4"
|
|
277
|
+
style={{ height: mobileHeight }}
|
|
278
|
+
>
|
|
279
|
+
<MasonryImageList
|
|
280
|
+
images={images}
|
|
281
|
+
onImageClick={handleImageClick}
|
|
282
|
+
cols={2} // mobile: 2 per row
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</section>
|
|
287
|
+
|
|
288
|
+
{/* Lightbox */}
|
|
289
|
+
{enableLightbox && (
|
|
290
|
+
<ImageLightbox
|
|
291
|
+
images={images}
|
|
292
|
+
currentIndex={currentImageIndex}
|
|
293
|
+
isOpen={lightboxOpen}
|
|
294
|
+
onClose={handleLightboxClose}
|
|
295
|
+
onNavigate={handleLightboxNavigate}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{/* Component styles */}
|
|
300
|
+
<style jsx>{`
|
|
301
|
+
/* The overall grid - single column by default; switches to two fractional columns at lg+ */
|
|
302
|
+
.layout-grid {
|
|
303
|
+
display: grid;
|
|
304
|
+
grid-template-columns: 1fr;
|
|
305
|
+
gap: 3rem;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* At large screens, use the fractional widths computed in inline styles */
|
|
309
|
+
@media (min-width: 1024px) {
|
|
310
|
+
.layout-grid {
|
|
311
|
+
/* use CSS variables set from JS: --text-frac and --image-frac */
|
|
312
|
+
grid-template-columns: var(--text-frac) var(--image-frac);
|
|
313
|
+
align-items: start;
|
|
314
|
+
gap: 3rem;
|
|
315
|
+
height: 100%;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.text-column {
|
|
319
|
+
/* ensure text column doesn't overflow the parent */
|
|
320
|
+
height: 100%;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.image-column {
|
|
324
|
+
/* make image column take full height of the parent wrapper and be scrollable */
|
|
325
|
+
height: 100%;
|
|
326
|
+
max-height: 100%;
|
|
327
|
+
overflow-y: auto;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* Keep mobile images area scrollable if images exceed mobileHeight (we set that inline) */
|
|
332
|
+
@media (max-width: 1023px) {
|
|
333
|
+
.layout-grid {
|
|
334
|
+
gap: 1.5rem;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* Optional: small visual tweak to hide scrollbar in WebKit (if you want) */
|
|
339
|
+
.image-column::-webkit-scrollbar {
|
|
340
|
+
width: 8px;
|
|
341
|
+
}
|
|
342
|
+
.image-column::-webkit-scrollbar-thumb {
|
|
343
|
+
border-radius: 9999px;
|
|
344
|
+
background: rgba(0, 0, 0, 0.2);
|
|
345
|
+
}
|
|
346
|
+
`}</style>
|
|
347
|
+
</>
|
|
348
|
+
);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
export default MasonryItemCard;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Glass Card - Glassmorphism Effect */
|
|
2
|
+
.glass-card {
|
|
3
|
+
@apply rounded-xl backdrop-blur-md;
|
|
4
|
+
background-color: var(--card-bg);
|
|
5
|
+
background: color-mix(in srgb, var(--card-bg) 75%, transparent);
|
|
6
|
+
backdrop-filter: blur(10px);
|
|
7
|
+
box-shadow: 0 8px 32px 0 color-mix(in srgb, var(--primary) 20%, transparent);
|
|
8
|
+
border: 1px solid color-mix(in srgb, var(--card-bg) 30%, transparent);
|
|
9
|
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.glass-button {
|
|
13
|
+
background: rgba(255, 107, 53, 0.2);
|
|
14
|
+
backdrop-filter: blur(10px);
|
|
15
|
+
border: 1px solid rgba(255, 107, 53, 0.3);
|
|
16
|
+
transition: all 0.3s ease;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.glass-button:hover {
|
|
20
|
+
background: rgba(255, 107, 53, 0.3);
|
|
21
|
+
transform: translateY(-2px);
|
|
22
|
+
box-shadow: 0 12px 24px rgba(255, 107, 53, 0.2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.glass-card:hover {
|
|
26
|
+
background: color-mix(in srgb, var(--card-bg) 55%, transparent);
|
|
27
|
+
box-shadow: 0 12px 40px 0 color-mix(in srgb, var(--primary) 30%, transparent);
|
|
28
|
+
transform: translateY(-2px);
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codesinger0/shared-components",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.52",
|
|
4
4
|
"description": "Shared React components for customer projects",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
7
|
-
"dist"
|
|
7
|
+
"dist",
|
|
8
|
+
"dist/styles"
|
|
8
9
|
],
|
|
9
10
|
"scripts": {
|
|
10
11
|
"build": "rm -fr dist; cp -r src dist"
|