@aws505/sheetsite 1.0.2 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws505/sheetsite",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -31,6 +31,15 @@ export type { HoursProps } from './sections/Hours';
31
31
  export { Gallery } from './sections/Gallery';
32
32
  export type { GalleryProps } from './sections/Gallery';
33
33
 
34
+ export { Menu } from './sections/Menu';
35
+ export type { MenuProps } from './sections/Menu';
36
+
37
+ export { TrustBadges } from './sections/TrustBadges';
38
+ export type { TrustBadgesProps, TrustBadge } from './sections/TrustBadges';
39
+
40
+ export { BeforeAfter } from './sections/BeforeAfter';
41
+ export type { BeforeAfterProps, BeforeAfterItem } from './sections/BeforeAfter';
42
+
34
43
  // UI Components
35
44
  export { Button, ButtonLink } from './ui/Button';
36
45
  export type { ButtonProps, ButtonLinkProps } from './ui/Button';
@@ -39,3 +48,6 @@ export { Card, CardHeader, CardBody, CardFooter, CardImage } from './ui/Card';
39
48
  export type { CardProps, CardHeaderProps, CardImageProps } from './ui/Card';
40
49
 
41
50
  export * from './ui/Icons';
51
+
52
+ export { AnimatedSection, StaggerContainer } from './ui/AnimatedSection';
53
+ export type { AnimatedSectionProps, StaggerContainerProps } from './ui/AnimatedSection';
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Before/After Gallery Section Component
3
+ *
4
+ * Displays before and after images with an interactive slider.
5
+ * Perfect for tailors, home services, beauty services, etc.
6
+ */
7
+
8
+ 'use client';
9
+
10
+ import React, { useState, useRef, useCallback } from 'react';
11
+
12
+ export interface BeforeAfterItem {
13
+ id?: string;
14
+ beforeImageUrl: string;
15
+ afterImageUrl: string;
16
+ beforeAlt?: string;
17
+ afterAlt?: string;
18
+ title?: string;
19
+ description?: string;
20
+ category?: string;
21
+ featured?: boolean;
22
+ sortOrder?: number;
23
+ }
24
+
25
+ export interface BeforeAfterProps {
26
+ items: BeforeAfterItem[];
27
+ title?: string;
28
+ subtitle?: string;
29
+ columns?: 1 | 2 | 3;
30
+ variant?: 'slider' | 'side-by-side' | 'stacked';
31
+ showCategories?: boolean;
32
+ className?: string;
33
+ id?: string;
34
+ }
35
+
36
+ /**
37
+ * Before/After gallery section component.
38
+ */
39
+ export function BeforeAfter({
40
+ items,
41
+ title = 'Our Work',
42
+ subtitle = 'See the transformation',
43
+ columns = 2,
44
+ variant = 'slider',
45
+ showCategories = true,
46
+ className = '',
47
+ id = 'portfolio',
48
+ }: BeforeAfterProps) {
49
+ // Get unique categories
50
+ const categories = showCategories
51
+ ? [...new Set(items.filter(item => item.category).map(item => item.category!))]
52
+ : [];
53
+
54
+ const [activeCategory, setActiveCategory] = useState<string | null>(null);
55
+
56
+ // Filter items by category
57
+ const displayedItems = activeCategory
58
+ ? items.filter(item => item.category === activeCategory)
59
+ : items;
60
+
61
+ // Sort by sortOrder
62
+ const sortedItems = [...displayedItems].sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
63
+
64
+ const gridCols = {
65
+ 1: 'max-w-2xl mx-auto',
66
+ 2: 'md:grid-cols-2',
67
+ 3: 'md:grid-cols-2 lg:grid-cols-3',
68
+ };
69
+
70
+ return (
71
+ <section id={id} className={`py-16 scroll-mt-20 ${className}`}>
72
+ <div className="container mx-auto px-4">
73
+ {/* Header */}
74
+ <div className="text-center mb-12">
75
+ <h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">{title}</h2>
76
+ {subtitle && (
77
+ <p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>
78
+ )}
79
+ </div>
80
+
81
+ {/* Category Tabs */}
82
+ {showCategories && categories.length > 1 && (
83
+ <div className="flex flex-wrap justify-center gap-2 mb-8">
84
+ <button
85
+ onClick={() => setActiveCategory(null)}
86
+ className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
87
+ activeCategory === null
88
+ ? 'bg-primary-600 text-white'
89
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
90
+ }`}
91
+ >
92
+ All
93
+ </button>
94
+ {categories.map((category) => (
95
+ <button
96
+ key={category}
97
+ onClick={() => setActiveCategory(category)}
98
+ className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
99
+ activeCategory === category
100
+ ? 'bg-primary-600 text-white'
101
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
102
+ }`}
103
+ >
104
+ {category}
105
+ </button>
106
+ ))}
107
+ </div>
108
+ )}
109
+
110
+ {/* Items Grid */}
111
+ <div className={`grid gap-8 ${gridCols[columns]}`}>
112
+ {sortedItems.map((item) => (
113
+ <BeforeAfterCard
114
+ key={item.id || `${item.beforeImageUrl}-${item.afterImageUrl}`}
115
+ item={item}
116
+ variant={variant}
117
+ />
118
+ ))}
119
+ </div>
120
+ </div>
121
+ </section>
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Before/After card component.
127
+ */
128
+ function BeforeAfterCard({
129
+ item,
130
+ variant,
131
+ }: {
132
+ item: BeforeAfterItem;
133
+ variant: BeforeAfterProps['variant'];
134
+ }) {
135
+ if (variant === 'side-by-side') {
136
+ return <SideBySideCard item={item} />;
137
+ }
138
+
139
+ if (variant === 'stacked') {
140
+ return <StackedCard item={item} />;
141
+ }
142
+
143
+ // Default: slider variant
144
+ return <SliderCard item={item} />;
145
+ }
146
+
147
+ /**
148
+ * Slider card with draggable comparison.
149
+ */
150
+ function SliderCard({ item }: { item: BeforeAfterItem }) {
151
+ const [sliderPosition, setSliderPosition] = useState(50);
152
+ const containerRef = useRef<HTMLDivElement>(null);
153
+ const isDragging = useRef(false);
154
+
155
+ const handleMove = useCallback((clientX: number) => {
156
+ if (!containerRef.current) return;
157
+ const rect = containerRef.current.getBoundingClientRect();
158
+ const x = clientX - rect.left;
159
+ const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
160
+ setSliderPosition(percentage);
161
+ }, []);
162
+
163
+ const handleMouseDown = () => {
164
+ isDragging.current = true;
165
+ };
166
+
167
+ const handleMouseUp = () => {
168
+ isDragging.current = false;
169
+ };
170
+
171
+ const handleMouseMove = (e: React.MouseEvent) => {
172
+ if (isDragging.current) {
173
+ handleMove(e.clientX);
174
+ }
175
+ };
176
+
177
+ const handleTouchMove = (e: React.TouchEvent) => {
178
+ handleMove(e.touches[0].clientX);
179
+ };
180
+
181
+ return (
182
+ <div className="bg-white rounded-lg shadow-md overflow-hidden">
183
+ <div
184
+ ref={containerRef}
185
+ className="relative aspect-[4/3] cursor-ew-resize select-none overflow-hidden"
186
+ onMouseDown={handleMouseDown}
187
+ onMouseUp={handleMouseUp}
188
+ onMouseLeave={handleMouseUp}
189
+ onMouseMove={handleMouseMove}
190
+ onTouchMove={handleTouchMove}
191
+ >
192
+ {/* After image (full) */}
193
+ <img
194
+ src={item.afterImageUrl}
195
+ alt={item.afterAlt || 'After'}
196
+ className="absolute inset-0 w-full h-full object-cover"
197
+ />
198
+
199
+ {/* Before image (clipped) */}
200
+ <div
201
+ className="absolute inset-0 overflow-hidden"
202
+ style={{ width: `${sliderPosition}%` }}
203
+ >
204
+ <img
205
+ src={item.beforeImageUrl}
206
+ alt={item.beforeAlt || 'Before'}
207
+ className="absolute inset-0 w-full h-full object-cover"
208
+ style={{ width: containerRef.current?.offsetWidth || '100%' }}
209
+ />
210
+ </div>
211
+
212
+ {/* Slider handle */}
213
+ <div
214
+ className="absolute top-0 bottom-0 w-1 bg-white shadow-lg cursor-ew-resize"
215
+ style={{ left: `${sliderPosition}%`, transform: 'translateX(-50%)' }}
216
+ >
217
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white rounded-full shadow-lg flex items-center justify-center">
218
+ <svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
219
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
220
+ </svg>
221
+ </div>
222
+ </div>
223
+
224
+ {/* Labels */}
225
+ <div className="absolute bottom-4 left-4 px-2 py-1 bg-black/60 text-white text-xs rounded">
226
+ Before
227
+ </div>
228
+ <div className="absolute bottom-4 right-4 px-2 py-1 bg-black/60 text-white text-xs rounded">
229
+ After
230
+ </div>
231
+ </div>
232
+
233
+ {/* Content */}
234
+ {(item.title || item.description) && (
235
+ <div className="p-4">
236
+ {item.title && (
237
+ <h3 className="text-lg font-semibold text-gray-900 mb-1">{item.title}</h3>
238
+ )}
239
+ {item.description && (
240
+ <p className="text-gray-600 text-sm">{item.description}</p>
241
+ )}
242
+ </div>
243
+ )}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Side by side card.
250
+ */
251
+ function SideBySideCard({ item }: { item: BeforeAfterItem }) {
252
+ return (
253
+ <div className="bg-white rounded-lg shadow-md overflow-hidden">
254
+ <div className="grid grid-cols-2">
255
+ <div className="relative aspect-square">
256
+ <img
257
+ src={item.beforeImageUrl}
258
+ alt={item.beforeAlt || 'Before'}
259
+ className="w-full h-full object-cover"
260
+ />
261
+ <span className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
262
+ Before
263
+ </span>
264
+ </div>
265
+ <div className="relative aspect-square">
266
+ <img
267
+ src={item.afterImageUrl}
268
+ alt={item.afterAlt || 'After'}
269
+ className="w-full h-full object-cover"
270
+ />
271
+ <span className="absolute bottom-2 right-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
272
+ After
273
+ </span>
274
+ </div>
275
+ </div>
276
+
277
+ {/* Content */}
278
+ {(item.title || item.description) && (
279
+ <div className="p-4">
280
+ {item.title && (
281
+ <h3 className="text-lg font-semibold text-gray-900 mb-1">{item.title}</h3>
282
+ )}
283
+ {item.description && (
284
+ <p className="text-gray-600 text-sm">{item.description}</p>
285
+ )}
286
+ </div>
287
+ )}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Stacked card with hover reveal.
294
+ */
295
+ function StackedCard({ item }: { item: BeforeAfterItem }) {
296
+ const [showAfter, setShowAfter] = useState(false);
297
+
298
+ return (
299
+ <div className="bg-white rounded-lg shadow-md overflow-hidden">
300
+ <div
301
+ className="relative aspect-[4/3] cursor-pointer"
302
+ onMouseEnter={() => setShowAfter(true)}
303
+ onMouseLeave={() => setShowAfter(false)}
304
+ onClick={() => setShowAfter(!showAfter)}
305
+ >
306
+ {/* Before image */}
307
+ <img
308
+ src={item.beforeImageUrl}
309
+ alt={item.beforeAlt || 'Before'}
310
+ className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
311
+ showAfter ? 'opacity-0' : 'opacity-100'
312
+ }`}
313
+ />
314
+
315
+ {/* After image */}
316
+ <img
317
+ src={item.afterImageUrl}
318
+ alt={item.afterAlt || 'After'}
319
+ className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
320
+ showAfter ? 'opacity-100' : 'opacity-0'
321
+ }`}
322
+ />
323
+
324
+ {/* Label */}
325
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 bg-black/60 text-white text-sm rounded">
326
+ {showAfter ? 'After' : 'Before'} (hover to toggle)
327
+ </div>
328
+ </div>
329
+
330
+ {/* Content */}
331
+ {(item.title || item.description) && (
332
+ <div className="p-4">
333
+ {item.title && (
334
+ <h3 className="text-lg font-semibold text-gray-900 mb-1">{item.title}</h3>
335
+ )}
336
+ {item.description && (
337
+ <p className="text-gray-600 text-sm">{item.description}</p>
338
+ )}
339
+ </div>
340
+ )}
341
+ </div>
342
+ );
343
+ }
344
+
345
+ export default BeforeAfter;
@@ -53,7 +53,7 @@ export function FAQ({
53
53
 
54
54
  if (variant === 'cards') {
55
55
  return (
56
- <section id={id} className={`py-16 ${className}`}>
56
+ <section id={id} className={`py-16 scroll-mt-20 ${className}`}>
57
57
  <div className="container mx-auto px-4">
58
58
  <SectionHeader title={title} subtitle={subtitle} />
59
59
  <div className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
@@ -68,7 +68,7 @@ export function FAQ({
68
68
 
69
69
  if (variant === 'simple') {
70
70
  return (
71
- <section id={id} className={`py-16 ${className}`}>
71
+ <section id={id} className={`py-16 scroll-mt-20 ${className}`}>
72
72
  <div className="container mx-auto px-4">
73
73
  <SectionHeader title={title} subtitle={subtitle} />
74
74
  <div className="max-w-3xl mx-auto space-y-8">
@@ -83,7 +83,7 @@ export function FAQ({
83
83
 
84
84
  // Default: accordion variant
85
85
  return (
86
- <section id={id} className={`py-16 bg-gray-50 ${className}`}>
86
+ <section id={id} className={`py-16 scroll-mt-20 bg-gray-50 ${className}`}>
87
87
  <div className="container mx-auto px-4">
88
88
  <SectionHeader title={title} subtitle={subtitle} />
89
89
  <div className="max-w-3xl mx-auto">
@@ -16,8 +16,10 @@ export interface GalleryProps {
16
16
  columns?: 2 | 3 | 4;
17
17
  variant?: 'grid' | 'masonry' | 'carousel';
18
18
  showCaptions?: boolean;
19
+ enableLightbox?: boolean;
19
20
  limit?: number;
20
21
  className?: string;
22
+ id?: string;
21
23
  }
22
24
 
23
25
  /**
@@ -30,11 +32,14 @@ export function Gallery({
30
32
  columns = 3,
31
33
  variant = 'grid',
32
34
  showCaptions = true,
35
+ enableLightbox = true,
33
36
  limit,
34
37
  className = '',
38
+ id = 'gallery',
35
39
  }: GalleryProps) {
36
40
  const displayedItems = limit ? items.slice(0, limit) : items;
37
41
  const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
42
+ const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
38
43
 
39
44
  const handleImageError = (id: string) => {
40
45
  setFailedImages((prev) => new Set(prev).add(id));
@@ -46,8 +51,28 @@ export function Gallery({
46
51
  4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
47
52
  };
48
53
 
54
+ const openLightbox = (index: number) => {
55
+ if (enableLightbox) {
56
+ setLightboxIndex(index);
57
+ }
58
+ };
59
+
60
+ const closeLightbox = () => setLightboxIndex(null);
61
+
62
+ const goToPrevious = () => {
63
+ if (lightboxIndex !== null) {
64
+ setLightboxIndex(lightboxIndex === 0 ? displayedItems.length - 1 : lightboxIndex - 1);
65
+ }
66
+ };
67
+
68
+ const goToNext = () => {
69
+ if (lightboxIndex !== null) {
70
+ setLightboxIndex(lightboxIndex === displayedItems.length - 1 ? 0 : lightboxIndex + 1);
71
+ }
72
+ };
73
+
49
74
  return (
50
- <section className={`py-16 ${className}`}>
75
+ <section id={id} className={`py-16 scroll-mt-20 ${className}`}>
51
76
  <div className="container mx-auto px-4">
52
77
  {/* Header */}
53
78
  {(title || subtitle) && (
@@ -63,14 +88,18 @@ export function Gallery({
63
88
 
64
89
  {/* Gallery Grid */}
65
90
  <div className={`grid gap-4 ${gridCols[columns]}`}>
66
- {displayedItems.map((item) => {
91
+ {displayedItems.map((item, index) => {
67
92
  const itemId = item.id || item.imageUrl;
68
93
  const hasFailed = failedImages.has(itemId);
69
94
 
70
95
  return (
71
96
  <div
72
97
  key={itemId}
73
- className="group relative aspect-square overflow-hidden rounded-lg bg-gray-100"
98
+ className={`group relative aspect-square overflow-hidden rounded-lg bg-gray-100 ${enableLightbox ? 'cursor-pointer' : ''}`}
99
+ onClick={() => openLightbox(index)}
100
+ role={enableLightbox ? 'button' : undefined}
101
+ tabIndex={enableLightbox ? 0 : undefined}
102
+ onKeyDown={enableLightbox ? (e) => e.key === 'Enter' && openLightbox(index) : undefined}
74
103
  >
75
104
  {hasFailed ? (
76
105
  <div className="absolute inset-0 flex items-center justify-center text-gray-400">
@@ -85,8 +114,16 @@ export function Gallery({
85
114
  className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
86
115
  onError={() => handleImageError(itemId)}
87
116
  />
117
+ {/* Hover overlay with zoom icon */}
118
+ {enableLightbox && (
119
+ <div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
120
+ <svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
121
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
122
+ </svg>
123
+ </div>
124
+ )}
88
125
  {/* Caption overlay */}
89
- {showCaptions && item.caption && (
126
+ {showCaptions && item.caption && !enableLightbox && (
90
127
  <div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
91
128
  <div className="absolute bottom-0 left-0 right-0 p-4">
92
129
  <p className="text-white text-sm">{item.caption}</p>
@@ -100,6 +137,69 @@ export function Gallery({
100
137
  })}
101
138
  </div>
102
139
  </div>
140
+
141
+ {/* Lightbox Modal */}
142
+ {enableLightbox && lightboxIndex !== null && (
143
+ <div
144
+ className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
145
+ onClick={closeLightbox}
146
+ >
147
+ {/* Close button */}
148
+ <button
149
+ onClick={closeLightbox}
150
+ className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors z-10"
151
+ aria-label="Close lightbox"
152
+ >
153
+ <svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
154
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
155
+ </svg>
156
+ </button>
157
+
158
+ {/* Previous button */}
159
+ <button
160
+ onClick={(e) => { e.stopPropagation(); goToPrevious(); }}
161
+ className="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 transition-colors z-10"
162
+ aria-label="Previous image"
163
+ >
164
+ <svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
165
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
166
+ </svg>
167
+ </button>
168
+
169
+ {/* Next button */}
170
+ <button
171
+ onClick={(e) => { e.stopPropagation(); goToNext(); }}
172
+ className="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:text-gray-300 transition-colors z-10"
173
+ aria-label="Next image"
174
+ >
175
+ <svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
176
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
177
+ </svg>
178
+ </button>
179
+
180
+ {/* Image container */}
181
+ <div
182
+ className="max-w-5xl max-h-[85vh] mx-4"
183
+ onClick={(e) => e.stopPropagation()}
184
+ >
185
+ <img
186
+ src={displayedItems[lightboxIndex].imageUrl}
187
+ alt={displayedItems[lightboxIndex].alt || ''}
188
+ className="max-w-full max-h-[85vh] object-contain"
189
+ />
190
+ {showCaptions && displayedItems[lightboxIndex].caption && (
191
+ <p className="text-white text-center mt-4 text-lg">
192
+ {displayedItems[lightboxIndex].caption}
193
+ </p>
194
+ )}
195
+ </div>
196
+
197
+ {/* Image counter */}
198
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-sm">
199
+ {lightboxIndex + 1} / {displayedItems.length}
200
+ </div>
201
+ </div>
202
+ )}
103
203
  </section>
104
204
  );
105
205
  }