@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,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hero Section Component
|
|
3
|
+
*
|
|
4
|
+
* A prominent hero section for the top of pages.
|
|
5
|
+
* Supports various layouts and customization options.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import type { BusinessInfo } from '../../data/types';
|
|
12
|
+
import { PhoneIcon, MapPinIcon, ClockIcon } from '../ui/Icons';
|
|
13
|
+
|
|
14
|
+
export interface HeroProps {
|
|
15
|
+
business: BusinessInfo;
|
|
16
|
+
variant?: 'centered' | 'left' | 'split';
|
|
17
|
+
showOpenStatus?: boolean;
|
|
18
|
+
isOpen?: boolean;
|
|
19
|
+
todayHours?: string;
|
|
20
|
+
backgroundImage?: string;
|
|
21
|
+
overlay?: boolean;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hero section component.
|
|
27
|
+
*/
|
|
28
|
+
export function Hero({
|
|
29
|
+
business,
|
|
30
|
+
variant = 'centered',
|
|
31
|
+
showOpenStatus = true,
|
|
32
|
+
isOpen,
|
|
33
|
+
todayHours,
|
|
34
|
+
backgroundImage,
|
|
35
|
+
overlay = true,
|
|
36
|
+
className = '',
|
|
37
|
+
}: HeroProps) {
|
|
38
|
+
const bgStyle = backgroundImage
|
|
39
|
+
? { backgroundImage: `url(${backgroundImage})` }
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
const handleCallClick = () => {
|
|
43
|
+
if (business.phone) {
|
|
44
|
+
window.location.href = `tel:${business.phone.replace(/\D/g, '')}`;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleDirectionsClick = () => {
|
|
49
|
+
if (business.googleMapsUrl) {
|
|
50
|
+
window.open(business.googleMapsUrl, '_blank');
|
|
51
|
+
} else {
|
|
52
|
+
const address = [
|
|
53
|
+
business.addressLine1,
|
|
54
|
+
business.city,
|
|
55
|
+
business.state,
|
|
56
|
+
business.zip,
|
|
57
|
+
]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join(', ');
|
|
60
|
+
window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`, '_blank');
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handlePrimaryClick = () => {
|
|
65
|
+
if (business.primaryCtaUrl) {
|
|
66
|
+
if (business.primaryCtaUrl.startsWith('http')) {
|
|
67
|
+
window.open(business.primaryCtaUrl, '_blank');
|
|
68
|
+
} else if (business.primaryCtaUrl.startsWith('mailto:')) {
|
|
69
|
+
window.location.href = business.primaryCtaUrl;
|
|
70
|
+
} else {
|
|
71
|
+
window.location.href = business.primaryCtaUrl;
|
|
72
|
+
}
|
|
73
|
+
} else if (business.bookingUrl) {
|
|
74
|
+
window.open(business.bookingUrl, '_blank');
|
|
75
|
+
} else if (business.email) {
|
|
76
|
+
window.location.href = `mailto:${business.email}`;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<section
|
|
82
|
+
className={`
|
|
83
|
+
relative min-h-[500px] flex items-center
|
|
84
|
+
${backgroundImage ? 'bg-cover bg-center' : 'bg-gradient-to-br from-primary-600 to-primary-800'}
|
|
85
|
+
${className}
|
|
86
|
+
`}
|
|
87
|
+
style={bgStyle}
|
|
88
|
+
>
|
|
89
|
+
{/* Overlay */}
|
|
90
|
+
{overlay && backgroundImage && (
|
|
91
|
+
<div className="absolute inset-0 bg-black/50" />
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{/* Content */}
|
|
95
|
+
<div className="relative z-10 container mx-auto px-4 py-16">
|
|
96
|
+
<div
|
|
97
|
+
className={`
|
|
98
|
+
${variant === 'centered' ? 'text-center max-w-3xl mx-auto' : ''}
|
|
99
|
+
${variant === 'left' ? 'max-w-2xl' : ''}
|
|
100
|
+
${variant === 'split' ? 'grid md:grid-cols-2 gap-8 items-center' : ''}
|
|
101
|
+
`}
|
|
102
|
+
>
|
|
103
|
+
<div>
|
|
104
|
+
{/* Open Status */}
|
|
105
|
+
{showOpenStatus && isOpen !== undefined && (
|
|
106
|
+
<div className="mb-4">
|
|
107
|
+
<span
|
|
108
|
+
className={`
|
|
109
|
+
inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
|
110
|
+
${isOpen ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}
|
|
111
|
+
`}
|
|
112
|
+
>
|
|
113
|
+
<span
|
|
114
|
+
className={`w-2 h-2 rounded-full mr-2 ${isOpen ? 'bg-green-500' : 'bg-red-500'}`}
|
|
115
|
+
/>
|
|
116
|
+
{isOpen ? 'Open Now' : 'Closed'}
|
|
117
|
+
{todayHours && <span className="ml-2 opacity-75">· {todayHours}</span>}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{/* Business Name */}
|
|
123
|
+
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold text-white mb-4">
|
|
124
|
+
{business.name}
|
|
125
|
+
</h1>
|
|
126
|
+
|
|
127
|
+
{/* Tagline */}
|
|
128
|
+
{business.tagline && (
|
|
129
|
+
<p className="text-xl md:text-2xl text-white/90 mb-6">
|
|
130
|
+
{business.tagline}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Description */}
|
|
135
|
+
{business.aboutShort && (
|
|
136
|
+
<p className="text-lg text-white/80 mb-8 max-w-xl">
|
|
137
|
+
{business.aboutShort}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{/* Trust Signals */}
|
|
142
|
+
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
|
143
|
+
{['Quality Work', 'Fair Prices', 'Fast Service'].map((signal) => (
|
|
144
|
+
<div key={signal} className="flex items-center text-white/90">
|
|
145
|
+
<svg className="w-5 h-5 text-accent-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|
146
|
+
<path
|
|
147
|
+
fillRule="evenodd"
|
|
148
|
+
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
149
|
+
clipRule="evenodd"
|
|
150
|
+
/>
|
|
151
|
+
</svg>
|
|
152
|
+
<span className="text-sm font-medium">{signal}</span>
|
|
153
|
+
</div>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* CTA Buttons */}
|
|
158
|
+
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
|
159
|
+
{business.phone && (
|
|
160
|
+
<button
|
|
161
|
+
onClick={handleCallClick}
|
|
162
|
+
className="inline-flex items-center justify-center px-6 py-3 bg-white text-primary-700 font-semibold rounded-lg hover:bg-gray-100 transition-colors"
|
|
163
|
+
>
|
|
164
|
+
<PhoneIcon size={20} className="mr-2" />
|
|
165
|
+
Call Now
|
|
166
|
+
</button>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{(business.addressLine1 || business.googleMapsUrl) && (
|
|
170
|
+
<button
|
|
171
|
+
onClick={handleDirectionsClick}
|
|
172
|
+
className="inline-flex items-center justify-center px-6 py-3 border-2 border-white text-white font-semibold rounded-lg hover:bg-white/10 transition-colors"
|
|
173
|
+
>
|
|
174
|
+
<MapPinIcon size={20} className="mr-2" />
|
|
175
|
+
Get Directions
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{(business.primaryCtaUrl || business.bookingUrl || business.email) && (
|
|
180
|
+
<button
|
|
181
|
+
onClick={handlePrimaryClick}
|
|
182
|
+
className="inline-flex items-center justify-center px-6 py-3 bg-accent-500 text-white font-semibold rounded-lg hover:bg-accent-600 transition-colors"
|
|
183
|
+
>
|
|
184
|
+
{business.primaryCtaText || 'Contact Us'}
|
|
185
|
+
</button>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* Split layout - right side could show an image or additional content */}
|
|
191
|
+
{variant === 'split' && (
|
|
192
|
+
<div className="hidden md:block">
|
|
193
|
+
{/* Placeholder for hero image or additional content */}
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</section>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export default Hero;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hours Section Component
|
|
3
|
+
*
|
|
4
|
+
* Displays business hours with today highlighted.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import type { HoursEntry, DayOfWeek } from '../../data/types';
|
|
9
|
+
import { ClockIcon } from '../ui/Icons';
|
|
10
|
+
|
|
11
|
+
export interface HoursProps {
|
|
12
|
+
hours: HoursEntry[];
|
|
13
|
+
title?: string;
|
|
14
|
+
highlightToday?: boolean;
|
|
15
|
+
variant?: 'card' | 'inline' | 'minimal';
|
|
16
|
+
timezone?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const dayOrder: DayOfWeek[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
21
|
+
|
|
22
|
+
const dayNames: Record<DayOfWeek, string> = {
|
|
23
|
+
monday: 'Monday',
|
|
24
|
+
tuesday: 'Tuesday',
|
|
25
|
+
wednesday: 'Wednesday',
|
|
26
|
+
thursday: 'Thursday',
|
|
27
|
+
friday: 'Friday',
|
|
28
|
+
saturday: 'Saturday',
|
|
29
|
+
sunday: 'Sunday',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const dayAbbrev: Record<DayOfWeek, string> = {
|
|
33
|
+
monday: 'Mon',
|
|
34
|
+
tuesday: 'Tue',
|
|
35
|
+
wednesday: 'Wed',
|
|
36
|
+
thursday: 'Thu',
|
|
37
|
+
friday: 'Fri',
|
|
38
|
+
saturday: 'Sat',
|
|
39
|
+
sunday: 'Sun',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get today's day of week.
|
|
44
|
+
*/
|
|
45
|
+
function getTodayDay(timezone?: string): DayOfWeek {
|
|
46
|
+
const now = timezone
|
|
47
|
+
? new Date(new Date().toLocaleString('en-US', { timeZone: timezone }))
|
|
48
|
+
: new Date();
|
|
49
|
+
const jsDay = now.getDay();
|
|
50
|
+
// JavaScript: 0 = Sunday, 1 = Monday, etc.
|
|
51
|
+
return dayOrder[(jsDay + 6) % 7];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Hours section component.
|
|
56
|
+
*/
|
|
57
|
+
export function Hours({
|
|
58
|
+
hours,
|
|
59
|
+
title = 'Hours',
|
|
60
|
+
highlightToday = true,
|
|
61
|
+
variant = 'card',
|
|
62
|
+
timezone,
|
|
63
|
+
className = '',
|
|
64
|
+
}: HoursProps) {
|
|
65
|
+
const todayDay = getTodayDay(timezone);
|
|
66
|
+
|
|
67
|
+
// Sort hours by day order
|
|
68
|
+
const sortedHours = [...hours].sort(
|
|
69
|
+
(a, b) => dayOrder.indexOf(a.day) - dayOrder.indexOf(b.day)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (variant === 'inline') {
|
|
73
|
+
return (
|
|
74
|
+
<div className={`flex flex-wrap gap-4 ${className}`}>
|
|
75
|
+
{sortedHours.map((entry) => (
|
|
76
|
+
<HoursInlineItem
|
|
77
|
+
key={entry.day}
|
|
78
|
+
entry={entry}
|
|
79
|
+
isToday={highlightToday && entry.day === todayDay}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (variant === 'minimal') {
|
|
87
|
+
return (
|
|
88
|
+
<div className={`space-y-1 ${className}`}>
|
|
89
|
+
{sortedHours.map((entry) => (
|
|
90
|
+
<HoursMinimalItem
|
|
91
|
+
key={entry.day}
|
|
92
|
+
entry={entry}
|
|
93
|
+
isToday={highlightToday && entry.day === todayDay}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Default: card variant
|
|
101
|
+
return (
|
|
102
|
+
<div className={`bg-white rounded-lg shadow p-6 ${className}`}>
|
|
103
|
+
<div className="flex items-center mb-4">
|
|
104
|
+
<ClockIcon size={24} className="text-primary-600 mr-2" />
|
|
105
|
+
<h3 className="text-xl font-semibold text-gray-900">{title}</h3>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="space-y-2">
|
|
108
|
+
{sortedHours.map((entry) => (
|
|
109
|
+
<HoursCardItem
|
|
110
|
+
key={entry.day}
|
|
111
|
+
entry={entry}
|
|
112
|
+
isToday={highlightToday && entry.day === todayDay}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Card variant item.
|
|
122
|
+
*/
|
|
123
|
+
function HoursCardItem({ entry, isToday }: { entry: HoursEntry; isToday: boolean }) {
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={`flex justify-between py-1 ${
|
|
127
|
+
isToday ? 'bg-primary-50 -mx-2 px-2 rounded font-medium' : ''
|
|
128
|
+
}`}
|
|
129
|
+
>
|
|
130
|
+
<span className={isToday ? 'text-primary-700' : 'text-gray-600'}>
|
|
131
|
+
{dayNames[entry.day]}
|
|
132
|
+
{isToday && <span className="ml-2 text-xs">(Today)</span>}
|
|
133
|
+
</span>
|
|
134
|
+
<span className={isToday ? 'text-primary-700' : 'text-gray-900'}>
|
|
135
|
+
{entry.closed ? 'Closed' : `${entry.open} - ${entry.close}`}
|
|
136
|
+
</span>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Inline variant item.
|
|
143
|
+
*/
|
|
144
|
+
function HoursInlineItem({ entry, isToday }: { entry: HoursEntry; isToday: boolean }) {
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className={`text-center px-3 py-2 rounded ${
|
|
148
|
+
isToday ? 'bg-primary-100 ring-2 ring-primary-500' : 'bg-gray-100'
|
|
149
|
+
}`}
|
|
150
|
+
>
|
|
151
|
+
<div className={`text-sm font-medium ${isToday ? 'text-primary-700' : 'text-gray-500'}`}>
|
|
152
|
+
{dayAbbrev[entry.day]}
|
|
153
|
+
</div>
|
|
154
|
+
<div className={`text-sm ${isToday ? 'text-primary-900' : 'text-gray-900'}`}>
|
|
155
|
+
{entry.closed ? 'Closed' : `${entry.open?.split(' ')[0]}-${entry.close?.split(' ')[0]}`}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Minimal variant item.
|
|
163
|
+
*/
|
|
164
|
+
function HoursMinimalItem({ entry, isToday }: { entry: HoursEntry; isToday: boolean }) {
|
|
165
|
+
return (
|
|
166
|
+
<div className={`text-sm ${isToday ? 'font-semibold text-primary-600' : 'text-gray-600'}`}>
|
|
167
|
+
<span className="inline-block w-20">{dayAbbrev[entry.day]}</span>
|
|
168
|
+
<span>{entry.closed ? 'Closed' : `${entry.open} - ${entry.close}`}</span>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Get today's hours text.
|
|
175
|
+
*/
|
|
176
|
+
export function getTodayHours(hours: HoursEntry[], timezone?: string): string | null {
|
|
177
|
+
const todayDay = getTodayDay(timezone);
|
|
178
|
+
const todayHours = hours.find((h) => h.day === todayDay);
|
|
179
|
+
|
|
180
|
+
if (!todayHours) return null;
|
|
181
|
+
if (todayHours.closed) return 'Closed today';
|
|
182
|
+
return `${todayHours.open} - ${todayHours.close}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check if currently open.
|
|
187
|
+
*/
|
|
188
|
+
export function isCurrentlyOpen(hours: HoursEntry[], timezone?: string): boolean {
|
|
189
|
+
const todayDay = getTodayDay(timezone);
|
|
190
|
+
const todayHours = hours.find((h) => h.day === todayDay);
|
|
191
|
+
|
|
192
|
+
if (!todayHours || todayHours.closed) return false;
|
|
193
|
+
if (!todayHours.open || !todayHours.close) return false;
|
|
194
|
+
|
|
195
|
+
const now = timezone
|
|
196
|
+
? new Date(new Date().toLocaleString('en-US', { timeZone: timezone }))
|
|
197
|
+
: new Date();
|
|
198
|
+
|
|
199
|
+
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
|
200
|
+
const openMinutes = parseTimeToMinutes(todayHours.open);
|
|
201
|
+
const closeMinutes = parseTimeToMinutes(todayHours.close);
|
|
202
|
+
|
|
203
|
+
if (openMinutes === null || closeMinutes === null) return false;
|
|
204
|
+
|
|
205
|
+
return currentMinutes >= openMinutes && currentMinutes < closeMinutes;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse time string to minutes since midnight.
|
|
210
|
+
*/
|
|
211
|
+
function parseTimeToMinutes(time: string): number | null {
|
|
212
|
+
const match = time.match(/(\d{1,2}):?(\d{2})?\s*(AM|PM)?/i);
|
|
213
|
+
if (!match) return null;
|
|
214
|
+
|
|
215
|
+
let hours = parseInt(match[1], 10);
|
|
216
|
+
const minutes = parseInt(match[2] || '0', 10);
|
|
217
|
+
const period = match[3]?.toUpperCase();
|
|
218
|
+
|
|
219
|
+
if (period === 'PM' && hours !== 12) hours += 12;
|
|
220
|
+
if (period === 'AM' && hours === 12) hours = 0;
|
|
221
|
+
|
|
222
|
+
return hours * 60 + minutes;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export default Hours;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Services Section Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a grid of services offered by the business.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import type { Service } from '../../data/types';
|
|
9
|
+
import { Icon } from '../ui/Icons';
|
|
10
|
+
|
|
11
|
+
export interface ServicesProps {
|
|
12
|
+
services: Service[];
|
|
13
|
+
title?: string;
|
|
14
|
+
subtitle?: string;
|
|
15
|
+
columns?: 2 | 3 | 4;
|
|
16
|
+
showPrices?: boolean;
|
|
17
|
+
showIcons?: boolean;
|
|
18
|
+
variant?: 'cards' | 'list' | 'minimal';
|
|
19
|
+
limit?: number;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Services section component.
|
|
25
|
+
*/
|
|
26
|
+
export function Services({
|
|
27
|
+
services,
|
|
28
|
+
title = 'Our Services',
|
|
29
|
+
subtitle,
|
|
30
|
+
columns = 3,
|
|
31
|
+
showPrices = true,
|
|
32
|
+
showIcons = true,
|
|
33
|
+
variant = 'cards',
|
|
34
|
+
limit,
|
|
35
|
+
className = '',
|
|
36
|
+
}: ServicesProps) {
|
|
37
|
+
const displayedServices = limit ? services.slice(0, limit) : services;
|
|
38
|
+
|
|
39
|
+
const gridCols = {
|
|
40
|
+
2: 'md:grid-cols-2',
|
|
41
|
+
3: 'md:grid-cols-2 lg:grid-cols-3',
|
|
42
|
+
4: 'md:grid-cols-2 lg:grid-cols-4',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (variant === 'list') {
|
|
46
|
+
return (
|
|
47
|
+
<section className={`py-16 ${className}`}>
|
|
48
|
+
<div className="container mx-auto px-4">
|
|
49
|
+
<SectionHeader title={title} subtitle={subtitle} />
|
|
50
|
+
<div className="max-w-3xl mx-auto divide-y divide-gray-200">
|
|
51
|
+
{displayedServices.map((service) => (
|
|
52
|
+
<ServiceListItem
|
|
53
|
+
key={service.id || service.title}
|
|
54
|
+
service={service}
|
|
55
|
+
showPrice={showPrices}
|
|
56
|
+
showIcon={showIcons}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (variant === 'minimal') {
|
|
66
|
+
return (
|
|
67
|
+
<section className={`py-16 ${className}`}>
|
|
68
|
+
<div className="container mx-auto px-4">
|
|
69
|
+
<SectionHeader title={title} subtitle={subtitle} />
|
|
70
|
+
<div className={`grid gap-6 ${gridCols[columns]}`}>
|
|
71
|
+
{displayedServices.map((service) => (
|
|
72
|
+
<ServiceMinimalCard
|
|
73
|
+
key={service.id || service.title}
|
|
74
|
+
service={service}
|
|
75
|
+
showPrice={showPrices}
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Default: cards variant
|
|
85
|
+
return (
|
|
86
|
+
<section className={`py-16 bg-gray-50 ${className}`}>
|
|
87
|
+
<div className="container mx-auto px-4">
|
|
88
|
+
<SectionHeader title={title} subtitle={subtitle} />
|
|
89
|
+
<div className={`grid gap-6 ${gridCols[columns]}`}>
|
|
90
|
+
{displayedServices.map((service) => (
|
|
91
|
+
<ServiceCard
|
|
92
|
+
key={service.id || service.title}
|
|
93
|
+
service={service}
|
|
94
|
+
showPrice={showPrices}
|
|
95
|
+
showIcon={showIcons}
|
|
96
|
+
/>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Section header.
|
|
106
|
+
*/
|
|
107
|
+
function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="text-center mb-12">
|
|
110
|
+
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">{title}</h2>
|
|
111
|
+
{subtitle && (
|
|
112
|
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Service card component.
|
|
120
|
+
*/
|
|
121
|
+
function ServiceCard({
|
|
122
|
+
service,
|
|
123
|
+
showPrice,
|
|
124
|
+
showIcon,
|
|
125
|
+
}: {
|
|
126
|
+
service: Service;
|
|
127
|
+
showPrice: boolean;
|
|
128
|
+
showIcon: boolean;
|
|
129
|
+
}) {
|
|
130
|
+
return (
|
|
131
|
+
<div className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
|
|
132
|
+
{showIcon && service.icon && (
|
|
133
|
+
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center mb-4">
|
|
134
|
+
<Icon name={service.icon} size={24} className="text-primary-600" />
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
<h3 className="text-xl font-semibold text-gray-900 mb-2">{service.title}</h3>
|
|
138
|
+
{service.description && (
|
|
139
|
+
<p className="text-gray-600 mb-4">{service.description}</p>
|
|
140
|
+
)}
|
|
141
|
+
{showPrice && service.priceNote && (
|
|
142
|
+
<p className="text-primary-600 font-medium">{service.priceNote}</p>
|
|
143
|
+
)}
|
|
144
|
+
{showPrice && service.price && !service.priceNote && (
|
|
145
|
+
<p className="text-primary-600 font-medium">${service.price.toFixed(2)}</p>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Service list item component.
|
|
153
|
+
*/
|
|
154
|
+
function ServiceListItem({
|
|
155
|
+
service,
|
|
156
|
+
showPrice,
|
|
157
|
+
showIcon,
|
|
158
|
+
}: {
|
|
159
|
+
service: Service;
|
|
160
|
+
showPrice: boolean;
|
|
161
|
+
showIcon: boolean;
|
|
162
|
+
}) {
|
|
163
|
+
return (
|
|
164
|
+
<div className="py-4 flex items-start justify-between">
|
|
165
|
+
<div className="flex items-start">
|
|
166
|
+
{showIcon && service.icon && (
|
|
167
|
+
<div className="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center mr-4 flex-shrink-0">
|
|
168
|
+
<Icon name={service.icon} size={20} className="text-primary-600" />
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
<div>
|
|
172
|
+
<h3 className="text-lg font-semibold text-gray-900">{service.title}</h3>
|
|
173
|
+
{service.description && (
|
|
174
|
+
<p className="text-gray-600 text-sm mt-1">{service.description}</p>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
{showPrice && (service.priceNote || service.price) && (
|
|
179
|
+
<div className="text-right ml-4 flex-shrink-0">
|
|
180
|
+
<p className="text-primary-600 font-medium">
|
|
181
|
+
{service.priceNote || `$${service.price?.toFixed(2)}`}
|
|
182
|
+
</p>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Minimal service card component.
|
|
191
|
+
*/
|
|
192
|
+
function ServiceMinimalCard({
|
|
193
|
+
service,
|
|
194
|
+
showPrice,
|
|
195
|
+
}: {
|
|
196
|
+
service: Service;
|
|
197
|
+
showPrice: boolean;
|
|
198
|
+
}) {
|
|
199
|
+
return (
|
|
200
|
+
<div className="p-4 border border-gray-200 rounded-lg hover:border-primary-300 transition-colors">
|
|
201
|
+
<div className="flex items-center justify-between">
|
|
202
|
+
<h3 className="font-semibold text-gray-900">{service.title}</h3>
|
|
203
|
+
{showPrice && (service.priceNote || service.price) && (
|
|
204
|
+
<span className="text-primary-600 text-sm font-medium">
|
|
205
|
+
{service.priceNote || `$${service.price?.toFixed(2)}`}
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
{service.description && (
|
|
210
|
+
<p className="text-gray-600 text-sm mt-2">{service.description}</p>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default Services;
|