@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,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SheetSite Data Types
|
|
3
|
+
*
|
|
4
|
+
* These types define the structure of data fetched from Google Sheets.
|
|
5
|
+
* All data flows through these types, providing type safety across the entire site.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// BUSINESS INFO
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export const BusinessInfoSchema = z.object({
|
|
15
|
+
name: z.string().min(1),
|
|
16
|
+
tagline: z.string().optional(),
|
|
17
|
+
description: z.string().optional(),
|
|
18
|
+
aboutShort: z.string().optional(),
|
|
19
|
+
aboutLong: z.string().optional(),
|
|
20
|
+
|
|
21
|
+
// Contact
|
|
22
|
+
phone: z.string().optional(),
|
|
23
|
+
phoneAlt: z.string().optional(),
|
|
24
|
+
email: z.string().email().optional().or(z.literal('')),
|
|
25
|
+
|
|
26
|
+
// Address
|
|
27
|
+
addressLine1: z.string().optional(),
|
|
28
|
+
addressLine2: z.string().optional(),
|
|
29
|
+
city: z.string().optional(),
|
|
30
|
+
state: z.string().optional(),
|
|
31
|
+
zip: z.string().optional(),
|
|
32
|
+
country: z.string().default('USA'),
|
|
33
|
+
|
|
34
|
+
// Location
|
|
35
|
+
googleMapsUrl: z.string().url().optional().or(z.literal('')),
|
|
36
|
+
mapsEmbedUrl: z.string().url().optional().or(z.literal('')),
|
|
37
|
+
timezone: z.string().default('America/Los_Angeles'),
|
|
38
|
+
|
|
39
|
+
// Images
|
|
40
|
+
logoUrl: z.string().optional(),
|
|
41
|
+
heroImageUrl: z.string().optional(),
|
|
42
|
+
ogImageUrl: z.string().optional(),
|
|
43
|
+
|
|
44
|
+
// Social
|
|
45
|
+
socialYelp: z.string().url().optional().or(z.literal('')),
|
|
46
|
+
socialInstagram: z.string().url().optional().or(z.literal('')),
|
|
47
|
+
socialFacebook: z.string().url().optional().or(z.literal('')),
|
|
48
|
+
socialTwitter: z.string().url().optional().or(z.literal('')),
|
|
49
|
+
socialLinkedIn: z.string().url().optional().or(z.literal('')),
|
|
50
|
+
socialYoutube: z.string().url().optional().or(z.literal('')),
|
|
51
|
+
socialTiktok: z.string().url().optional().or(z.literal('')),
|
|
52
|
+
|
|
53
|
+
// CTAs
|
|
54
|
+
primaryCtaText: z.string().optional(),
|
|
55
|
+
primaryCtaUrl: z.string().optional(),
|
|
56
|
+
secondaryCtaText: z.string().optional(),
|
|
57
|
+
secondaryCtaUrl: z.string().optional(),
|
|
58
|
+
bookingUrl: z.string().url().optional().or(z.literal('')),
|
|
59
|
+
|
|
60
|
+
// Banner
|
|
61
|
+
bannerEnabled: z.boolean().default(false),
|
|
62
|
+
bannerText: z.string().optional(),
|
|
63
|
+
bannerUrl: z.string().optional(),
|
|
64
|
+
|
|
65
|
+
// Business metadata
|
|
66
|
+
priceRange: z.enum(['$', '$$', '$$$', '$$$$']).optional(),
|
|
67
|
+
yearEstablished: z.number().optional(),
|
|
68
|
+
licenseNumber: z.string().optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export type BusinessInfo = z.infer<typeof BusinessInfoSchema>;
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// HOURS
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
export const DayOfWeek = z.enum(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
|
|
78
|
+
export type DayOfWeek = z.infer<typeof DayOfWeek>;
|
|
79
|
+
|
|
80
|
+
export const HoursEntrySchema = z.object({
|
|
81
|
+
day: DayOfWeek,
|
|
82
|
+
open: z.string().optional(), // e.g., "9:00 AM"
|
|
83
|
+
close: z.string().optional(), // e.g., "5:00 PM"
|
|
84
|
+
closed: z.boolean().default(false),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export type HoursEntry = z.infer<typeof HoursEntrySchema>;
|
|
88
|
+
|
|
89
|
+
export const HoursSchema = z.array(HoursEntrySchema);
|
|
90
|
+
export type Hours = z.infer<typeof HoursSchema>;
|
|
91
|
+
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// SERVICES
|
|
94
|
+
// =============================================================================
|
|
95
|
+
|
|
96
|
+
export const ServiceSchema = z.object({
|
|
97
|
+
id: z.string().optional(),
|
|
98
|
+
title: z.string().min(1),
|
|
99
|
+
description: z.string().optional(),
|
|
100
|
+
priceNote: z.string().optional(), // e.g., "Starting at $15" or "Call for quote"
|
|
101
|
+
price: z.number().optional(), // Exact price if applicable
|
|
102
|
+
duration: z.string().optional(), // e.g., "30 min"
|
|
103
|
+
icon: z.string().optional(), // Icon identifier
|
|
104
|
+
imageUrl: z.string().optional(),
|
|
105
|
+
featured: z.boolean().default(false),
|
|
106
|
+
sortOrder: z.number().default(0),
|
|
107
|
+
category: z.string().optional(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export type Service = z.infer<typeof ServiceSchema>;
|
|
111
|
+
|
|
112
|
+
export const ServicesSchema = z.array(ServiceSchema);
|
|
113
|
+
export type Services = z.infer<typeof ServicesSchema>;
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// GALLERY
|
|
117
|
+
// =============================================================================
|
|
118
|
+
|
|
119
|
+
export const GalleryItemSchema = z.object({
|
|
120
|
+
id: z.string().optional(),
|
|
121
|
+
imageUrl: z.string().min(1),
|
|
122
|
+
alt: z.string().optional(),
|
|
123
|
+
caption: z.string().optional(),
|
|
124
|
+
featured: z.boolean().default(false),
|
|
125
|
+
sortOrder: z.number().default(0),
|
|
126
|
+
category: z.string().optional(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export type GalleryItem = z.infer<typeof GalleryItemSchema>;
|
|
130
|
+
|
|
131
|
+
export const GallerySchema = z.array(GalleryItemSchema);
|
|
132
|
+
export type Gallery = z.infer<typeof GallerySchema>;
|
|
133
|
+
|
|
134
|
+
// =============================================================================
|
|
135
|
+
// TESTIMONIALS / REVIEWS
|
|
136
|
+
// =============================================================================
|
|
137
|
+
|
|
138
|
+
export const TestimonialSchema = z.object({
|
|
139
|
+
id: z.string().optional(),
|
|
140
|
+
quote: z.string().min(1),
|
|
141
|
+
name: z.string().min(1),
|
|
142
|
+
context: z.string().optional(), // e.g., "Wedding alterations"
|
|
143
|
+
rating: z.number().min(1).max(5).optional(),
|
|
144
|
+
source: z.string().optional(), // e.g., "Google", "Yelp"
|
|
145
|
+
date: z.string().optional(),
|
|
146
|
+
imageUrl: z.string().optional(), // Reviewer photo
|
|
147
|
+
featured: z.boolean().default(false),
|
|
148
|
+
sortOrder: z.number().default(0),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export type Testimonial = z.infer<typeof TestimonialSchema>;
|
|
152
|
+
|
|
153
|
+
export const TestimonialsSchema = z.array(TestimonialSchema);
|
|
154
|
+
export type Testimonials = z.infer<typeof TestimonialsSchema>;
|
|
155
|
+
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// FAQ
|
|
158
|
+
// =============================================================================
|
|
159
|
+
|
|
160
|
+
export const FAQItemSchema = z.object({
|
|
161
|
+
id: z.string().optional(),
|
|
162
|
+
question: z.string().min(1),
|
|
163
|
+
answer: z.string().min(1),
|
|
164
|
+
category: z.string().optional(),
|
|
165
|
+
sortOrder: z.number().default(0),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
export type FAQItem = z.infer<typeof FAQItemSchema>;
|
|
169
|
+
|
|
170
|
+
export const FAQSchema = z.array(FAQItemSchema);
|
|
171
|
+
export type FAQ = z.infer<typeof FAQSchema>;
|
|
172
|
+
|
|
173
|
+
// =============================================================================
|
|
174
|
+
// TEAM MEMBERS (for businesses that want to showcase staff)
|
|
175
|
+
// =============================================================================
|
|
176
|
+
|
|
177
|
+
export const TeamMemberSchema = z.object({
|
|
178
|
+
id: z.string().optional(),
|
|
179
|
+
name: z.string().min(1),
|
|
180
|
+
role: z.string().optional(),
|
|
181
|
+
bio: z.string().optional(),
|
|
182
|
+
imageUrl: z.string().optional(),
|
|
183
|
+
email: z.string().email().optional().or(z.literal('')),
|
|
184
|
+
phone: z.string().optional(),
|
|
185
|
+
sortOrder: z.number().default(0),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
export type TeamMember = z.infer<typeof TeamMemberSchema>;
|
|
189
|
+
|
|
190
|
+
export const TeamSchema = z.array(TeamMemberSchema);
|
|
191
|
+
export type Team = z.infer<typeof TeamSchema>;
|
|
192
|
+
|
|
193
|
+
// =============================================================================
|
|
194
|
+
// MENU ITEMS (for restaurants, cafes, food businesses)
|
|
195
|
+
// =============================================================================
|
|
196
|
+
|
|
197
|
+
export const MenuItemSchema = z.object({
|
|
198
|
+
id: z.string().optional(),
|
|
199
|
+
name: z.string().min(1),
|
|
200
|
+
description: z.string().optional(),
|
|
201
|
+
price: z.number().optional(),
|
|
202
|
+
priceNote: z.string().optional(),
|
|
203
|
+
category: z.string().optional(),
|
|
204
|
+
imageUrl: z.string().optional(),
|
|
205
|
+
dietary: z.array(z.string()).optional(), // e.g., ["vegetarian", "gluten-free"]
|
|
206
|
+
featured: z.boolean().default(false),
|
|
207
|
+
available: z.boolean().default(true),
|
|
208
|
+
sortOrder: z.number().default(0),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
export type MenuItem = z.infer<typeof MenuItemSchema>;
|
|
212
|
+
|
|
213
|
+
export const MenuSchema = z.array(MenuItemSchema);
|
|
214
|
+
export type Menu = z.infer<typeof MenuSchema>;
|
|
215
|
+
|
|
216
|
+
// =============================================================================
|
|
217
|
+
// PRODUCTS (for craft sellers, retail)
|
|
218
|
+
// =============================================================================
|
|
219
|
+
|
|
220
|
+
export const ProductSchema = z.object({
|
|
221
|
+
id: z.string().optional(),
|
|
222
|
+
name: z.string().min(1),
|
|
223
|
+
description: z.string().optional(),
|
|
224
|
+
price: z.number().optional(),
|
|
225
|
+
priceNote: z.string().optional(),
|
|
226
|
+
category: z.string().optional(),
|
|
227
|
+
imageUrl: z.string().optional(),
|
|
228
|
+
images: z.array(z.string()).optional(),
|
|
229
|
+
inStock: z.boolean().default(true),
|
|
230
|
+
featured: z.boolean().default(false),
|
|
231
|
+
sortOrder: z.number().default(0),
|
|
232
|
+
sku: z.string().optional(),
|
|
233
|
+
purchaseUrl: z.string().url().optional().or(z.literal('')),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
export type Product = z.infer<typeof ProductSchema>;
|
|
237
|
+
|
|
238
|
+
export const ProductsSchema = z.array(ProductSchema);
|
|
239
|
+
export type Products = z.infer<typeof ProductsSchema>;
|
|
240
|
+
|
|
241
|
+
// =============================================================================
|
|
242
|
+
// ANNOUNCEMENTS / SPECIALS
|
|
243
|
+
// =============================================================================
|
|
244
|
+
|
|
245
|
+
export const AnnouncementSchema = z.object({
|
|
246
|
+
id: z.string().optional(),
|
|
247
|
+
title: z.string().min(1),
|
|
248
|
+
content: z.string().optional(),
|
|
249
|
+
imageUrl: z.string().optional(),
|
|
250
|
+
linkUrl: z.string().optional(),
|
|
251
|
+
linkText: z.string().optional(),
|
|
252
|
+
startDate: z.string().optional(),
|
|
253
|
+
endDate: z.string().optional(),
|
|
254
|
+
active: z.boolean().default(true),
|
|
255
|
+
sortOrder: z.number().default(0),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
export type Announcement = z.infer<typeof AnnouncementSchema>;
|
|
259
|
+
|
|
260
|
+
export const AnnouncementsSchema = z.array(AnnouncementSchema);
|
|
261
|
+
export type Announcements = z.infer<typeof AnnouncementsSchema>;
|
|
262
|
+
|
|
263
|
+
// =============================================================================
|
|
264
|
+
// COMPLETE SITE DATA
|
|
265
|
+
// =============================================================================
|
|
266
|
+
|
|
267
|
+
export const SiteDataSchema = z.object({
|
|
268
|
+
business: BusinessInfoSchema,
|
|
269
|
+
hours: HoursSchema,
|
|
270
|
+
services: ServicesSchema,
|
|
271
|
+
gallery: GallerySchema,
|
|
272
|
+
testimonials: TestimonialsSchema,
|
|
273
|
+
faq: FAQSchema,
|
|
274
|
+
team: TeamSchema.optional(),
|
|
275
|
+
menu: MenuSchema.optional(),
|
|
276
|
+
products: ProductsSchema.optional(),
|
|
277
|
+
announcements: AnnouncementsSchema.optional(),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
export type SiteData = z.infer<typeof SiteDataSchema>;
|
|
281
|
+
|
|
282
|
+
// =============================================================================
|
|
283
|
+
// PARTIAL SITE DATA (for merging with defaults)
|
|
284
|
+
// =============================================================================
|
|
285
|
+
|
|
286
|
+
export const PartialSiteDataSchema = z.object({
|
|
287
|
+
business: BusinessInfoSchema.partial().optional(),
|
|
288
|
+
hours: HoursSchema.optional(),
|
|
289
|
+
services: ServicesSchema.optional(),
|
|
290
|
+
gallery: GallerySchema.optional(),
|
|
291
|
+
testimonials: TestimonialsSchema.optional(),
|
|
292
|
+
faq: FAQSchema.optional(),
|
|
293
|
+
team: TeamSchema.optional(),
|
|
294
|
+
menu: MenuSchema.optional(),
|
|
295
|
+
products: ProductsSchema.optional(),
|
|
296
|
+
announcements: AnnouncementsSchema.optional(),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
export type PartialSiteData = z.infer<typeof PartialSiteDataSchema>;
|
|
300
|
+
|
|
301
|
+
// =============================================================================
|
|
302
|
+
// SHEET CONFIGURATION
|
|
303
|
+
// =============================================================================
|
|
304
|
+
|
|
305
|
+
export type SheetMode = 'public_csv' | 'private_api';
|
|
306
|
+
|
|
307
|
+
export interface SheetTabConfig {
|
|
308
|
+
name: string;
|
|
309
|
+
required: boolean;
|
|
310
|
+
csvUrl?: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface PublicCsvConfig {
|
|
314
|
+
mode: 'public_csv';
|
|
315
|
+
tabs: {
|
|
316
|
+
business: string;
|
|
317
|
+
hours?: string;
|
|
318
|
+
services?: string;
|
|
319
|
+
gallery?: string;
|
|
320
|
+
testimonials?: string;
|
|
321
|
+
faq?: string;
|
|
322
|
+
team?: string;
|
|
323
|
+
menu?: string;
|
|
324
|
+
products?: string;
|
|
325
|
+
announcements?: string;
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface PrivateApiConfig {
|
|
330
|
+
mode: 'private_api';
|
|
331
|
+
sheetId: string;
|
|
332
|
+
clientEmail: string;
|
|
333
|
+
privateKey: string;
|
|
334
|
+
tabs?: {
|
|
335
|
+
business?: string;
|
|
336
|
+
hours?: string;
|
|
337
|
+
services?: string;
|
|
338
|
+
gallery?: string;
|
|
339
|
+
testimonials?: string;
|
|
340
|
+
faq?: string;
|
|
341
|
+
team?: string;
|
|
342
|
+
menu?: string;
|
|
343
|
+
products?: string;
|
|
344
|
+
announcements?: string;
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Inline data configuration - pass data directly without Google Sheets.
|
|
350
|
+
* This is useful for:
|
|
351
|
+
* - Rapid prototyping
|
|
352
|
+
* - Automated site generation from CSV/JSON leads
|
|
353
|
+
* - Testing without network requests
|
|
354
|
+
* - Demo sites with pre-populated content
|
|
355
|
+
*/
|
|
356
|
+
export interface InlineDataConfig {
|
|
357
|
+
mode: 'inline';
|
|
358
|
+
data: PartialSiteData;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export type SheetConfig = PublicCsvConfig | PrivateApiConfig | InlineDataConfig;
|
|
362
|
+
|
|
363
|
+
// =============================================================================
|
|
364
|
+
// UTILITY TYPES
|
|
365
|
+
// =============================================================================
|
|
366
|
+
|
|
367
|
+
export interface ContactFormData {
|
|
368
|
+
name: string;
|
|
369
|
+
email: string;
|
|
370
|
+
phone?: string;
|
|
371
|
+
service?: string;
|
|
372
|
+
message: string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface ContactFormResult {
|
|
376
|
+
success: boolean;
|
|
377
|
+
messageId?: string;
|
|
378
|
+
error?: string;
|
|
379
|
+
}
|
package/src/seo/index.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SheetSite SEO Module
|
|
3
|
+
*
|
|
4
|
+
* Utilities for generating SEO metadata, JSON-LD structured data,
|
|
5
|
+
* sitemaps, and robots.txt.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BusinessInfo, HoursEntry, SiteData } from '../data/types';
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// METADATA GENERATION
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export interface MetadataOptions {
|
|
15
|
+
titleTemplate?: string;
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate metadata for a page.
|
|
21
|
+
*/
|
|
22
|
+
export function generateMetadata(
|
|
23
|
+
business: BusinessInfo,
|
|
24
|
+
pageTitle?: string,
|
|
25
|
+
pageDescription?: string,
|
|
26
|
+
options: MetadataOptions = {}
|
|
27
|
+
) {
|
|
28
|
+
const { titleTemplate = '%s', baseUrl } = options;
|
|
29
|
+
|
|
30
|
+
const title = pageTitle
|
|
31
|
+
? titleTemplate.replace('%s', pageTitle)
|
|
32
|
+
: business.name;
|
|
33
|
+
|
|
34
|
+
const description = pageDescription || business.description || business.aboutShort || '';
|
|
35
|
+
|
|
36
|
+
const locationString = [business.city, business.state].filter(Boolean).join(', ');
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
title,
|
|
40
|
+
description,
|
|
41
|
+
keywords: [
|
|
42
|
+
business.name,
|
|
43
|
+
locationString,
|
|
44
|
+
// Add more keywords based on business type
|
|
45
|
+
].filter(Boolean),
|
|
46
|
+
openGraph: {
|
|
47
|
+
title,
|
|
48
|
+
description,
|
|
49
|
+
type: 'website',
|
|
50
|
+
...(baseUrl && { url: baseUrl }),
|
|
51
|
+
...(business.ogImageUrl && { images: [{ url: business.ogImageUrl }] }),
|
|
52
|
+
},
|
|
53
|
+
twitter: {
|
|
54
|
+
card: 'summary_large_image',
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
...(business.ogImageUrl && { images: [business.ogImageUrl] }),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// JSON-LD STRUCTURED DATA
|
|
64
|
+
// =============================================================================
|
|
65
|
+
|
|
66
|
+
export type JsonLdType =
|
|
67
|
+
| 'LocalBusiness'
|
|
68
|
+
| 'Restaurant'
|
|
69
|
+
| 'Store'
|
|
70
|
+
| 'ProfessionalService'
|
|
71
|
+
| 'HealthAndBeautyBusiness'
|
|
72
|
+
| 'FoodEstablishment'
|
|
73
|
+
| 'HomeAndConstructionBusiness'
|
|
74
|
+
| 'AutomotiveBusiness';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert 12-hour time to 24-hour format for JSON-LD.
|
|
78
|
+
*/
|
|
79
|
+
function convertTo24Hour(time: string): string {
|
|
80
|
+
const match = time.match(/(\d{1,2}):?(\d{2})?\s*(AM|PM)?/i);
|
|
81
|
+
if (!match) return time;
|
|
82
|
+
|
|
83
|
+
let hours = parseInt(match[1], 10);
|
|
84
|
+
const minutes = match[2] || '00';
|
|
85
|
+
const period = match[3]?.toUpperCase();
|
|
86
|
+
|
|
87
|
+
if (period === 'PM' && hours !== 12) hours += 12;
|
|
88
|
+
if (period === 'AM' && hours === 12) hours = 0;
|
|
89
|
+
|
|
90
|
+
return `${hours.toString().padStart(2, '0')}:${minutes}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate opening hours specification for JSON-LD.
|
|
95
|
+
*/
|
|
96
|
+
function generateOpeningHoursSpec(hours: HoursEntry[]) {
|
|
97
|
+
const dayMap: Record<string, string> = {
|
|
98
|
+
monday: 'Monday',
|
|
99
|
+
tuesday: 'Tuesday',
|
|
100
|
+
wednesday: 'Wednesday',
|
|
101
|
+
thursday: 'Thursday',
|
|
102
|
+
friday: 'Friday',
|
|
103
|
+
saturday: 'Saturday',
|
|
104
|
+
sunday: 'Sunday',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return hours
|
|
108
|
+
.filter((h) => !h.closed && h.open && h.close)
|
|
109
|
+
.map((h) => ({
|
|
110
|
+
'@type': 'OpeningHoursSpecification',
|
|
111
|
+
dayOfWeek: dayMap[h.day],
|
|
112
|
+
opens: convertTo24Hour(h.open!),
|
|
113
|
+
closes: convertTo24Hour(h.close!),
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Generate JSON-LD structured data for a local business.
|
|
119
|
+
*/
|
|
120
|
+
export function generateLocalBusinessJsonLd(
|
|
121
|
+
data: SiteData,
|
|
122
|
+
options: {
|
|
123
|
+
type?: JsonLdType;
|
|
124
|
+
baseUrl?: string;
|
|
125
|
+
} = {}
|
|
126
|
+
): object {
|
|
127
|
+
const { business, hours, testimonials } = data;
|
|
128
|
+
const { type = 'LocalBusiness', baseUrl } = options;
|
|
129
|
+
|
|
130
|
+
const address = {
|
|
131
|
+
'@type': 'PostalAddress',
|
|
132
|
+
streetAddress: [business.addressLine1, business.addressLine2].filter(Boolean).join(', '),
|
|
133
|
+
addressLocality: business.city,
|
|
134
|
+
addressRegion: business.state,
|
|
135
|
+
postalCode: business.zip,
|
|
136
|
+
addressCountry: business.country || 'US',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const jsonLd: Record<string, unknown> = {
|
|
140
|
+
'@context': 'https://schema.org',
|
|
141
|
+
'@type': type,
|
|
142
|
+
name: business.name,
|
|
143
|
+
description: business.description || business.aboutShort,
|
|
144
|
+
...(baseUrl && { url: baseUrl }),
|
|
145
|
+
...(business.phone && { telephone: business.phone }),
|
|
146
|
+
...(business.email && { email: business.email }),
|
|
147
|
+
...(business.logoUrl && { logo: business.logoUrl }),
|
|
148
|
+
...(business.heroImageUrl && { image: business.heroImageUrl }),
|
|
149
|
+
...(business.addressLine1 && { address }),
|
|
150
|
+
...(business.priceRange && { priceRange: business.priceRange }),
|
|
151
|
+
...(hours.length > 0 && { openingHoursSpecification: generateOpeningHoursSpec(hours) }),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Add aggregate rating if testimonials exist
|
|
155
|
+
if (testimonials.length > 0) {
|
|
156
|
+
const ratings = testimonials.filter((t) => t.rating).map((t) => t.rating!);
|
|
157
|
+
if (ratings.length > 0) {
|
|
158
|
+
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;
|
|
159
|
+
jsonLd.aggregateRating = {
|
|
160
|
+
'@type': 'AggregateRating',
|
|
161
|
+
ratingValue: avgRating.toFixed(1),
|
|
162
|
+
reviewCount: ratings.length,
|
|
163
|
+
bestRating: 5,
|
|
164
|
+
worstRating: 1,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return jsonLd;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Generate the script tag content for JSON-LD.
|
|
174
|
+
*/
|
|
175
|
+
export function generateJsonLdScript(jsonLd: object): string {
|
|
176
|
+
return JSON.stringify(jsonLd, null, 2);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// =============================================================================
|
|
180
|
+
// SITEMAP GENERATION
|
|
181
|
+
// =============================================================================
|
|
182
|
+
|
|
183
|
+
export interface SitemapUrl {
|
|
184
|
+
loc: string;
|
|
185
|
+
lastmod?: string;
|
|
186
|
+
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
187
|
+
priority?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Generate sitemap URLs for common pages.
|
|
192
|
+
*/
|
|
193
|
+
export function generateSitemapUrls(
|
|
194
|
+
baseUrl: string,
|
|
195
|
+
pages: string[] = ['/', '/services', '/gallery', '/about', '/contact', '/faq', '/privacy']
|
|
196
|
+
): SitemapUrl[] {
|
|
197
|
+
const now = new Date().toISOString().split('T')[0];
|
|
198
|
+
|
|
199
|
+
return pages.map((page) => ({
|
|
200
|
+
loc: `${baseUrl}${page}`,
|
|
201
|
+
lastmod: now,
|
|
202
|
+
changefreq: page === '/' ? 'weekly' : 'monthly',
|
|
203
|
+
priority: page === '/' ? 1.0 : 0.8,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Generate XML sitemap content.
|
|
209
|
+
*/
|
|
210
|
+
export function generateSitemapXml(urls: SitemapUrl[]): string {
|
|
211
|
+
const urlEntries = urls
|
|
212
|
+
.map(
|
|
213
|
+
(url) => `
|
|
214
|
+
<url>
|
|
215
|
+
<loc>${url.loc}</loc>
|
|
216
|
+
${url.lastmod ? `<lastmod>${url.lastmod}</lastmod>` : ''}
|
|
217
|
+
${url.changefreq ? `<changefreq>${url.changefreq}</changefreq>` : ''}
|
|
218
|
+
${url.priority !== undefined ? `<priority>${url.priority}</priority>` : ''}
|
|
219
|
+
</url>`
|
|
220
|
+
)
|
|
221
|
+
.join('');
|
|
222
|
+
|
|
223
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
224
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
225
|
+
${urlEntries}
|
|
226
|
+
</urlset>`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// =============================================================================
|
|
230
|
+
// ROBOTS.TXT GENERATION
|
|
231
|
+
// =============================================================================
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Generate robots.txt content.
|
|
235
|
+
*/
|
|
236
|
+
export function generateRobotsTxt(baseUrl?: string): string {
|
|
237
|
+
return `User-agent: *
|
|
238
|
+
Allow: /
|
|
239
|
+
${baseUrl ? `\nSitemap: ${baseUrl}/sitemap.xml` : ''}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// =============================================================================
|
|
243
|
+
// CANONICAL URL HELPERS
|
|
244
|
+
// =============================================================================
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Generate canonical URL.
|
|
248
|
+
*/
|
|
249
|
+
export function getCanonicalUrl(baseUrl: string, path: string): string {
|
|
250
|
+
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
|
251
|
+
return `${baseUrl}${cleanPath}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// =============================================================================
|
|
255
|
+
// META TAGS HELPERS
|
|
256
|
+
// =============================================================================
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Generate common meta tags as an object.
|
|
260
|
+
*/
|
|
261
|
+
export function generateCommonMetaTags(business: BusinessInfo) {
|
|
262
|
+
const location = [business.city, business.state].filter(Boolean).join(', ');
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
'geo.region': business.state ? `US-${business.state}` : undefined,
|
|
266
|
+
'geo.placename': business.city,
|
|
267
|
+
'og:locale': 'en_US',
|
|
268
|
+
'og:site_name': business.name,
|
|
269
|
+
'twitter:card': 'summary_large_image',
|
|
270
|
+
...(location && { 'geo.position': location }),
|
|
271
|
+
};
|
|
272
|
+
}
|