@aws505/sheetsite 1.0.0
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/README.md +105 -0
- package/dist/components/index.js +1696 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/index.mjs +1630 -0
- package/dist/components/index.mjs.map +1 -0
- package/dist/config/index.js +1840 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/index.mjs +1793 -0
- package/dist/config/index.mjs.map +1 -0
- package/dist/data/index.js +1296 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/index.mjs +1220 -0
- package/dist/data/index.mjs.map +1 -0
- package/dist/index.js +5433 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5285 -0
- package/dist/index.mjs.map +1 -0
- package/dist/seo/index.js +187 -0
- package/dist/seo/index.js.map +1 -0
- package/dist/seo/index.mjs +155 -0
- package/dist/seo/index.mjs.map +1 -0
- package/dist/theme/index.js +552 -0
- package/dist/theme/index.js.map +1 -0
- package/dist/theme/index.mjs +526 -0
- package/dist/theme/index.mjs.map +1 -0
- package/package.json +96 -0
- package/src/components/index.ts +41 -0
- package/src/components/layout/Footer.tsx +234 -0
- package/src/components/layout/Header.tsx +134 -0
- package/src/components/sections/FAQ.tsx +178 -0
- package/src/components/sections/Gallery.tsx +107 -0
- package/src/components/sections/Hero.tsx +202 -0
- package/src/components/sections/Hours.tsx +225 -0
- package/src/components/sections/Services.tsx +216 -0
- package/src/components/sections/Testimonials.tsx +184 -0
- package/src/components/ui/Button.tsx +158 -0
- package/src/components/ui/Card.tsx +162 -0
- package/src/components/ui/Icons.tsx +508 -0
- package/src/config/index.ts +207 -0
- package/src/config/presets/generic.ts +153 -0
- package/src/config/presets/home-kitchen.ts +154 -0
- package/src/config/presets/index.ts +708 -0
- package/src/config/presets/professional.ts +165 -0
- package/src/config/presets/repair.ts +160 -0
- package/src/config/presets/restaurant.ts +162 -0
- package/src/config/presets/salon.ts +178 -0
- package/src/config/presets/tailor.ts +159 -0
- package/src/config/types.ts +314 -0
- package/src/data/csv-parser.ts +154 -0
- package/src/data/defaults.ts +202 -0
- package/src/data/google-drive.ts +148 -0
- package/src/data/index.ts +535 -0
- package/src/data/sheets.ts +709 -0
- package/src/data/types.ts +379 -0
- package/src/seo/index.ts +272 -0
- package/src/theme/colors.ts +351 -0
- package/src/theme/index.ts +249 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SheetSite Component Library
|
|
3
|
+
*
|
|
4
|
+
* Pre-built React components for building small business websites.
|
|
5
|
+
* All components are designed to work with SheetSite data types.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Layout Components
|
|
9
|
+
export { Header } from './layout/Header';
|
|
10
|
+
export type { HeaderProps, NavItem } from './layout/Header';
|
|
11
|
+
|
|
12
|
+
export { Footer } from './layout/Footer';
|
|
13
|
+
export type { FooterProps, FooterLink } from './layout/Footer';
|
|
14
|
+
|
|
15
|
+
// Section Components
|
|
16
|
+
export { Hero } from './sections/Hero';
|
|
17
|
+
export type { HeroProps } from './sections/Hero';
|
|
18
|
+
|
|
19
|
+
export { Services } from './sections/Services';
|
|
20
|
+
export type { ServicesProps } from './sections/Services';
|
|
21
|
+
|
|
22
|
+
export { Testimonials } from './sections/Testimonials';
|
|
23
|
+
export type { TestimonialsProps } from './sections/Testimonials';
|
|
24
|
+
|
|
25
|
+
export { FAQ } from './sections/FAQ';
|
|
26
|
+
export type { FAQProps } from './sections/FAQ';
|
|
27
|
+
|
|
28
|
+
export { Hours, getTodayHours, isCurrentlyOpen } from './sections/Hours';
|
|
29
|
+
export type { HoursProps } from './sections/Hours';
|
|
30
|
+
|
|
31
|
+
export { Gallery } from './sections/Gallery';
|
|
32
|
+
export type { GalleryProps } from './sections/Gallery';
|
|
33
|
+
|
|
34
|
+
// UI Components
|
|
35
|
+
export { Button, ButtonLink } from './ui/Button';
|
|
36
|
+
export type { ButtonProps, ButtonLinkProps } from './ui/Button';
|
|
37
|
+
|
|
38
|
+
export { Card, CardHeader, CardBody, CardFooter, CardImage } from './ui/Card';
|
|
39
|
+
export type { CardProps, CardHeaderProps, CardImageProps } from './ui/Card';
|
|
40
|
+
|
|
41
|
+
export * from './ui/Icons';
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer Component
|
|
3
|
+
*
|
|
4
|
+
* A comprehensive footer with business info, links, and social icons.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import type { BusinessInfo, HoursEntry } from '../../data/types';
|
|
9
|
+
import {
|
|
10
|
+
PhoneIcon,
|
|
11
|
+
MailIcon,
|
|
12
|
+
MapPinIcon,
|
|
13
|
+
ClockIcon,
|
|
14
|
+
FacebookIcon,
|
|
15
|
+
InstagramIcon,
|
|
16
|
+
YelpIcon,
|
|
17
|
+
} from '../ui/Icons';
|
|
18
|
+
|
|
19
|
+
export interface FooterLink {
|
|
20
|
+
label: string;
|
|
21
|
+
href: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FooterProps {
|
|
25
|
+
business: BusinessInfo;
|
|
26
|
+
hours?: HoursEntry[];
|
|
27
|
+
quickLinks?: FooterLink[];
|
|
28
|
+
showHours?: boolean;
|
|
29
|
+
showSocial?: boolean;
|
|
30
|
+
variant?: 'simple' | 'columns' | 'centered';
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const defaultQuickLinks: FooterLink[] = [
|
|
35
|
+
{ label: 'Home', href: '/' },
|
|
36
|
+
{ label: 'Services', href: '/services' },
|
|
37
|
+
{ label: 'About', href: '/about' },
|
|
38
|
+
{ label: 'Contact', href: '/contact' },
|
|
39
|
+
{ label: 'Privacy Policy', href: '/privacy' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const dayNames: Record<string, string> = {
|
|
43
|
+
monday: 'Mon',
|
|
44
|
+
tuesday: 'Tue',
|
|
45
|
+
wednesday: 'Wed',
|
|
46
|
+
thursday: 'Thu',
|
|
47
|
+
friday: 'Fri',
|
|
48
|
+
saturday: 'Sat',
|
|
49
|
+
sunday: 'Sun',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Footer component.
|
|
54
|
+
*/
|
|
55
|
+
export function Footer({
|
|
56
|
+
business,
|
|
57
|
+
hours = [],
|
|
58
|
+
quickLinks = defaultQuickLinks,
|
|
59
|
+
showHours = true,
|
|
60
|
+
showSocial = true,
|
|
61
|
+
variant = 'columns',
|
|
62
|
+
className = '',
|
|
63
|
+
}: FooterProps) {
|
|
64
|
+
const currentYear = new Date().getFullYear();
|
|
65
|
+
|
|
66
|
+
const socialLinks = [
|
|
67
|
+
{ url: business.socialYelp, icon: YelpIcon, label: 'Yelp' },
|
|
68
|
+
{ url: business.socialInstagram, icon: InstagramIcon, label: 'Instagram' },
|
|
69
|
+
{ url: business.socialFacebook, icon: FacebookIcon, label: 'Facebook' },
|
|
70
|
+
].filter((link) => link.url);
|
|
71
|
+
|
|
72
|
+
if (variant === 'simple') {
|
|
73
|
+
return (
|
|
74
|
+
<footer className={`bg-gray-900 text-white py-8 ${className}`}>
|
|
75
|
+
<div className="container mx-auto px-4 text-center">
|
|
76
|
+
<p className="text-lg font-semibold mb-2">{business.name}</p>
|
|
77
|
+
{business.phone && <p className="text-gray-400">{business.phone}</p>}
|
|
78
|
+
<p className="text-gray-500 text-sm mt-4">
|
|
79
|
+
© {currentYear} {business.name}. All rights reserved.
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
</footer>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (variant === 'centered') {
|
|
87
|
+
return (
|
|
88
|
+
<footer className={`bg-gray-900 text-white py-12 ${className}`}>
|
|
89
|
+
<div className="container mx-auto px-4 text-center">
|
|
90
|
+
<p className="text-2xl font-bold mb-4">{business.name}</p>
|
|
91
|
+
{business.tagline && <p className="text-gray-400 mb-6">{business.tagline}</p>}
|
|
92
|
+
|
|
93
|
+
<div className="flex flex-wrap justify-center gap-6 mb-8">
|
|
94
|
+
{quickLinks.map((link) => (
|
|
95
|
+
<a
|
|
96
|
+
key={link.href}
|
|
97
|
+
href={link.href}
|
|
98
|
+
className="text-gray-400 hover:text-white transition-colors"
|
|
99
|
+
>
|
|
100
|
+
{link.label}
|
|
101
|
+
</a>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{showSocial && socialLinks.length > 0 && (
|
|
106
|
+
<div className="flex justify-center gap-4 mb-8">
|
|
107
|
+
{socialLinks.map((link) => (
|
|
108
|
+
<a
|
|
109
|
+
key={link.label}
|
|
110
|
+
href={link.url}
|
|
111
|
+
target="_blank"
|
|
112
|
+
rel="noopener noreferrer"
|
|
113
|
+
className="p-2 bg-gray-800 rounded-full hover:bg-gray-700 transition-colors"
|
|
114
|
+
aria-label={link.label}
|
|
115
|
+
>
|
|
116
|
+
<link.icon size={20} />
|
|
117
|
+
</a>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<p className="text-gray-500 text-sm">
|
|
123
|
+
© {currentYear} {business.name}. All rights reserved.
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
</footer>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Default: columns variant
|
|
131
|
+
return (
|
|
132
|
+
<footer className={`bg-gray-900 text-white py-12 ${className}`}>
|
|
133
|
+
<div className="container mx-auto px-4">
|
|
134
|
+
<div className="grid md:grid-cols-3 gap-8">
|
|
135
|
+
{/* Business Info */}
|
|
136
|
+
<div>
|
|
137
|
+
<h3 className="text-xl font-bold mb-4">{business.name}</h3>
|
|
138
|
+
{business.addressLine1 && (
|
|
139
|
+
<div className="flex items-start text-gray-400 mb-3">
|
|
140
|
+
<MapPinIcon size={18} className="mr-2 mt-1 flex-shrink-0" />
|
|
141
|
+
<div>
|
|
142
|
+
<p>{business.addressLine1}</p>
|
|
143
|
+
{business.addressLine2 && <p>{business.addressLine2}</p>}
|
|
144
|
+
<p>
|
|
145
|
+
{business.city}, {business.state} {business.zip}
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
{business.phone && (
|
|
151
|
+
<a
|
|
152
|
+
href={`tel:${business.phone.replace(/\D/g, '')}`}
|
|
153
|
+
className="flex items-center text-gray-400 hover:text-white mb-3"
|
|
154
|
+
>
|
|
155
|
+
<PhoneIcon size={18} className="mr-2" />
|
|
156
|
+
{business.phone}
|
|
157
|
+
</a>
|
|
158
|
+
)}
|
|
159
|
+
{business.email && (
|
|
160
|
+
<a
|
|
161
|
+
href={`mailto:${business.email}`}
|
|
162
|
+
className="flex items-center text-gray-400 hover:text-white"
|
|
163
|
+
>
|
|
164
|
+
<MailIcon size={18} className="mr-2" />
|
|
165
|
+
{business.email}
|
|
166
|
+
</a>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* Quick Links */}
|
|
171
|
+
<div>
|
|
172
|
+
<h3 className="text-lg font-semibold mb-4">Quick Links</h3>
|
|
173
|
+
<ul className="space-y-2">
|
|
174
|
+
{quickLinks.map((link) => (
|
|
175
|
+
<li key={link.href}>
|
|
176
|
+
<a
|
|
177
|
+
href={link.href}
|
|
178
|
+
className="text-gray-400 hover:text-white transition-colors"
|
|
179
|
+
>
|
|
180
|
+
{link.label}
|
|
181
|
+
</a>
|
|
182
|
+
</li>
|
|
183
|
+
))}
|
|
184
|
+
</ul>
|
|
185
|
+
|
|
186
|
+
{showSocial && socialLinks.length > 0 && (
|
|
187
|
+
<div className="flex gap-3 mt-6">
|
|
188
|
+
{socialLinks.map((link) => (
|
|
189
|
+
<a
|
|
190
|
+
key={link.label}
|
|
191
|
+
href={link.url}
|
|
192
|
+
target="_blank"
|
|
193
|
+
rel="noopener noreferrer"
|
|
194
|
+
className="p-2 bg-gray-800 rounded-full hover:bg-gray-700 transition-colors"
|
|
195
|
+
aria-label={link.label}
|
|
196
|
+
>
|
|
197
|
+
<link.icon size={20} />
|
|
198
|
+
</a>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{/* Hours */}
|
|
205
|
+
{showHours && hours.length > 0 && (
|
|
206
|
+
<div>
|
|
207
|
+
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
|
208
|
+
<ClockIcon size={18} className="mr-2" />
|
|
209
|
+
Hours
|
|
210
|
+
</h3>
|
|
211
|
+
<ul className="space-y-1 text-gray-400">
|
|
212
|
+
{hours.map((entry) => (
|
|
213
|
+
<li key={entry.day} className="flex justify-between">
|
|
214
|
+
<span>{dayNames[entry.day]}</span>
|
|
215
|
+
<span>
|
|
216
|
+
{entry.closed ? 'Closed' : `${entry.open} - ${entry.close}`}
|
|
217
|
+
</span>
|
|
218
|
+
</li>
|
|
219
|
+
))}
|
|
220
|
+
</ul>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Bottom Bar */}
|
|
226
|
+
<div className="mt-12 pt-8 border-t border-gray-800 text-center text-gray-500 text-sm">
|
|
227
|
+
<p>© {currentYear} {business.name}. All rights reserved.</p>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</footer>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default Footer;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header Component
|
|
3
|
+
*
|
|
4
|
+
* A responsive header with navigation and mobile menu.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import React, { useState } from 'react';
|
|
10
|
+
import type { BusinessInfo } from '../../data/types';
|
|
11
|
+
import { MenuIcon, XIcon, PhoneIcon } from '../ui/Icons';
|
|
12
|
+
|
|
13
|
+
export interface NavItem {
|
|
14
|
+
label: string;
|
|
15
|
+
href: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HeaderProps {
|
|
19
|
+
business: BusinessInfo;
|
|
20
|
+
navigation?: NavItem[];
|
|
21
|
+
sticky?: boolean;
|
|
22
|
+
transparent?: boolean;
|
|
23
|
+
showPhone?: boolean;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const defaultNavigation: NavItem[] = [
|
|
28
|
+
{ label: 'Home', href: '/' },
|
|
29
|
+
{ label: 'Services', href: '/services' },
|
|
30
|
+
{ label: 'Gallery', href: '/gallery' },
|
|
31
|
+
{ label: 'About', href: '/about' },
|
|
32
|
+
{ label: 'Contact', href: '/contact' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Header component.
|
|
37
|
+
*/
|
|
38
|
+
export function Header({
|
|
39
|
+
business,
|
|
40
|
+
navigation = defaultNavigation,
|
|
41
|
+
sticky = true,
|
|
42
|
+
transparent = false,
|
|
43
|
+
showPhone = true,
|
|
44
|
+
className = '',
|
|
45
|
+
}: HeaderProps) {
|
|
46
|
+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<header
|
|
50
|
+
className={`
|
|
51
|
+
${sticky ? 'sticky top-0 z-50' : ''}
|
|
52
|
+
${transparent ? 'bg-transparent' : 'bg-white shadow-sm'}
|
|
53
|
+
${className}
|
|
54
|
+
`}
|
|
55
|
+
>
|
|
56
|
+
<nav className="container mx-auto px-4">
|
|
57
|
+
<div className="flex items-center justify-between h-16">
|
|
58
|
+
{/* Logo / Business Name */}
|
|
59
|
+
<a href="/" className="flex items-center">
|
|
60
|
+
{business.logoUrl ? (
|
|
61
|
+
<img
|
|
62
|
+
src={business.logoUrl}
|
|
63
|
+
alt={business.name}
|
|
64
|
+
className="h-10 w-auto"
|
|
65
|
+
/>
|
|
66
|
+
) : (
|
|
67
|
+
<span className="text-xl font-bold text-gray-900">{business.name}</span>
|
|
68
|
+
)}
|
|
69
|
+
</a>
|
|
70
|
+
|
|
71
|
+
{/* Desktop Navigation */}
|
|
72
|
+
<div className="hidden md:flex items-center space-x-8">
|
|
73
|
+
{navigation.map((item) => (
|
|
74
|
+
<a
|
|
75
|
+
key={item.href}
|
|
76
|
+
href={item.href}
|
|
77
|
+
className="text-gray-600 hover:text-primary-600 font-medium transition-colors"
|
|
78
|
+
>
|
|
79
|
+
{item.label}
|
|
80
|
+
</a>
|
|
81
|
+
))}
|
|
82
|
+
|
|
83
|
+
{/* Phone Button */}
|
|
84
|
+
{showPhone && business.phone && (
|
|
85
|
+
<a
|
|
86
|
+
href={`tel:${business.phone.replace(/\D/g, '')}`}
|
|
87
|
+
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
|
88
|
+
>
|
|
89
|
+
<PhoneIcon size={18} className="mr-2" />
|
|
90
|
+
{business.phone}
|
|
91
|
+
</a>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Mobile Menu Button */}
|
|
96
|
+
<button
|
|
97
|
+
className="md:hidden p-2 rounded-lg hover:bg-gray-100"
|
|
98
|
+
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
|
99
|
+
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
|
|
100
|
+
>
|
|
101
|
+
{mobileMenuOpen ? <XIcon size={24} /> : <MenuIcon size={24} />}
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
{/* Mobile Navigation */}
|
|
106
|
+
{mobileMenuOpen && (
|
|
107
|
+
<div className="md:hidden py-4 border-t">
|
|
108
|
+
{navigation.map((item) => (
|
|
109
|
+
<a
|
|
110
|
+
key={item.href}
|
|
111
|
+
href={item.href}
|
|
112
|
+
className="block py-2 text-gray-600 hover:text-primary-600 font-medium"
|
|
113
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
114
|
+
>
|
|
115
|
+
{item.label}
|
|
116
|
+
</a>
|
|
117
|
+
))}
|
|
118
|
+
{showPhone && business.phone && (
|
|
119
|
+
<a
|
|
120
|
+
href={`tel:${business.phone.replace(/\D/g, '')}`}
|
|
121
|
+
className="flex items-center py-2 text-primary-600 font-medium"
|
|
122
|
+
>
|
|
123
|
+
<PhoneIcon size={18} className="mr-2" />
|
|
124
|
+
{business.phone}
|
|
125
|
+
</a>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</nav>
|
|
130
|
+
</header>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default Header;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FAQ Section Component
|
|
3
|
+
*
|
|
4
|
+
* Displays frequently asked questions in an accordion format.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import React, { useState } from 'react';
|
|
10
|
+
import type { FAQItem } from '../../data/types';
|
|
11
|
+
import { ChevronDownIcon } from '../ui/Icons';
|
|
12
|
+
|
|
13
|
+
export interface FAQProps {
|
|
14
|
+
items: FAQItem[];
|
|
15
|
+
title?: string;
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
variant?: 'accordion' | 'cards' | 'simple';
|
|
18
|
+
defaultOpen?: number | number[];
|
|
19
|
+
allowMultiple?: boolean;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* FAQ section component.
|
|
25
|
+
*/
|
|
26
|
+
export function FAQ({
|
|
27
|
+
items,
|
|
28
|
+
title = 'Frequently Asked Questions',
|
|
29
|
+
subtitle,
|
|
30
|
+
variant = 'accordion',
|
|
31
|
+
defaultOpen = 0,
|
|
32
|
+
allowMultiple = false,
|
|
33
|
+
className = '',
|
|
34
|
+
}: FAQProps) {
|
|
35
|
+
const initialOpen = Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen];
|
|
36
|
+
const [openItems, setOpenItems] = useState<number[]>(initialOpen);
|
|
37
|
+
|
|
38
|
+
const toggleItem = (index: number) => {
|
|
39
|
+
if (allowMultiple) {
|
|
40
|
+
setOpenItems((prev) =>
|
|
41
|
+
prev.includes(index)
|
|
42
|
+
? prev.filter((i) => i !== index)
|
|
43
|
+
: [...prev, index]
|
|
44
|
+
);
|
|
45
|
+
} else {
|
|
46
|
+
setOpenItems((prev) =>
|
|
47
|
+
prev.includes(index) ? [] : [index]
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (variant === 'cards') {
|
|
53
|
+
return (
|
|
54
|
+
<section className={`py-16 ${className}`}>
|
|
55
|
+
<div className="container mx-auto px-4">
|
|
56
|
+
<SectionHeader title={title} subtitle={subtitle} />
|
|
57
|
+
<div className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
|
58
|
+
{items.map((item) => (
|
|
59
|
+
<FAQCard key={item.id || item.question} item={item} />
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (variant === 'simple') {
|
|
68
|
+
return (
|
|
69
|
+
<section className={`py-16 ${className}`}>
|
|
70
|
+
<div className="container mx-auto px-4">
|
|
71
|
+
<SectionHeader title={title} subtitle={subtitle} />
|
|
72
|
+
<div className="max-w-3xl mx-auto space-y-8">
|
|
73
|
+
{items.map((item) => (
|
|
74
|
+
<FAQSimple key={item.id || item.question} item={item} />
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Default: accordion variant
|
|
83
|
+
return (
|
|
84
|
+
<section className={`py-16 bg-gray-50 ${className}`}>
|
|
85
|
+
<div className="container mx-auto px-4">
|
|
86
|
+
<SectionHeader title={title} subtitle={subtitle} />
|
|
87
|
+
<div className="max-w-3xl mx-auto">
|
|
88
|
+
{items.map((item, index) => (
|
|
89
|
+
<FAQAccordionItem
|
|
90
|
+
key={item.id || item.question}
|
|
91
|
+
item={item}
|
|
92
|
+
isOpen={openItems.includes(index)}
|
|
93
|
+
onToggle={() => toggleItem(index)}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Section header.
|
|
104
|
+
*/
|
|
105
|
+
function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
|
106
|
+
return (
|
|
107
|
+
<div className="text-center mb-12">
|
|
108
|
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">{title}</h2>
|
|
109
|
+
{subtitle && (
|
|
110
|
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Accordion FAQ item.
|
|
118
|
+
*/
|
|
119
|
+
function FAQAccordionItem({
|
|
120
|
+
item,
|
|
121
|
+
isOpen,
|
|
122
|
+
onToggle,
|
|
123
|
+
}: {
|
|
124
|
+
item: FAQItem;
|
|
125
|
+
isOpen: boolean;
|
|
126
|
+
onToggle: () => void;
|
|
127
|
+
}) {
|
|
128
|
+
return (
|
|
129
|
+
<div className="border-b border-gray-200 last:border-b-0">
|
|
130
|
+
<button
|
|
131
|
+
className="w-full py-4 flex items-center justify-between text-left focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded"
|
|
132
|
+
onClick={onToggle}
|
|
133
|
+
aria-expanded={isOpen}
|
|
134
|
+
>
|
|
135
|
+
<span className="text-lg font-medium text-gray-900 pr-4">{item.question}</span>
|
|
136
|
+
<ChevronDownIcon
|
|
137
|
+
size={20}
|
|
138
|
+
className={`flex-shrink-0 text-gray-500 transition-transform duration-200 ${
|
|
139
|
+
isOpen ? 'rotate-180' : ''
|
|
140
|
+
}`}
|
|
141
|
+
/>
|
|
142
|
+
</button>
|
|
143
|
+
<div
|
|
144
|
+
className={`overflow-hidden transition-all duration-200 ${
|
|
145
|
+
isOpen ? 'max-h-96 pb-4' : 'max-h-0'
|
|
146
|
+
}`}
|
|
147
|
+
>
|
|
148
|
+
<p className="text-gray-600">{item.answer}</p>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Card FAQ item.
|
|
156
|
+
*/
|
|
157
|
+
function FAQCard({ item }: { item: FAQItem }) {
|
|
158
|
+
return (
|
|
159
|
+
<div className="bg-white rounded-lg shadow p-6">
|
|
160
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-2">{item.question}</h3>
|
|
161
|
+
<p className="text-gray-600">{item.answer}</p>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Simple FAQ item.
|
|
168
|
+
*/
|
|
169
|
+
function FAQSimple({ item }: { item: FAQItem }) {
|
|
170
|
+
return (
|
|
171
|
+
<div>
|
|
172
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-2">{item.question}</h3>
|
|
173
|
+
<p className="text-gray-600">{item.answer}</p>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default FAQ;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gallery Section Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a grid of images.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import React, { useState } from 'react';
|
|
10
|
+
import type { GalleryItem } from '../../data/types';
|
|
11
|
+
|
|
12
|
+
export interface GalleryProps {
|
|
13
|
+
items: GalleryItem[];
|
|
14
|
+
title?: string;
|
|
15
|
+
subtitle?: string;
|
|
16
|
+
columns?: 2 | 3 | 4;
|
|
17
|
+
variant?: 'grid' | 'masonry' | 'carousel';
|
|
18
|
+
showCaptions?: boolean;
|
|
19
|
+
limit?: number;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Gallery section component.
|
|
25
|
+
*/
|
|
26
|
+
export function Gallery({
|
|
27
|
+
items,
|
|
28
|
+
title = 'Gallery',
|
|
29
|
+
subtitle,
|
|
30
|
+
columns = 3,
|
|
31
|
+
variant = 'grid',
|
|
32
|
+
showCaptions = true,
|
|
33
|
+
limit,
|
|
34
|
+
className = '',
|
|
35
|
+
}: GalleryProps) {
|
|
36
|
+
const displayedItems = limit ? items.slice(0, limit) : items;
|
|
37
|
+
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
|
|
38
|
+
|
|
39
|
+
const handleImageError = (id: string) => {
|
|
40
|
+
setFailedImages((prev) => new Set(prev).add(id));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const gridCols = {
|
|
44
|
+
2: 'grid-cols-1 sm:grid-cols-2',
|
|
45
|
+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
|
46
|
+
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section className={`py-16 ${className}`}>
|
|
51
|
+
<div className="container mx-auto px-4">
|
|
52
|
+
{/* Header */}
|
|
53
|
+
{(title || subtitle) && (
|
|
54
|
+
<div className="text-center mb-12">
|
|
55
|
+
{title && (
|
|
56
|
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">{title}</h2>
|
|
57
|
+
)}
|
|
58
|
+
{subtitle && (
|
|
59
|
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
{/* Gallery Grid */}
|
|
65
|
+
<div className={`grid gap-4 ${gridCols[columns]}`}>
|
|
66
|
+
{displayedItems.map((item) => {
|
|
67
|
+
const itemId = item.id || item.imageUrl;
|
|
68
|
+
const hasFailed = failedImages.has(itemId);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
key={itemId}
|
|
73
|
+
className="group relative aspect-square overflow-hidden rounded-lg bg-gray-100"
|
|
74
|
+
>
|
|
75
|
+
{hasFailed ? (
|
|
76
|
+
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
|
77
|
+
<span>Image unavailable</span>
|
|
78
|
+
</div>
|
|
79
|
+
) : (
|
|
80
|
+
<>
|
|
81
|
+
<img
|
|
82
|
+
src={item.imageUrl}
|
|
83
|
+
alt={item.alt || ''}
|
|
84
|
+
loading="lazy"
|
|
85
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
86
|
+
onError={() => handleImageError(itemId)}
|
|
87
|
+
/>
|
|
88
|
+
{/* Caption overlay */}
|
|
89
|
+
{showCaptions && item.caption && (
|
|
90
|
+
<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
|
+
<div className="absolute bottom-0 left-0 right-0 p-4">
|
|
92
|
+
<p className="text-white text-sm">{item.caption}</p>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default Gallery;
|