@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.
Files changed (57) hide show
  1. package/README.md +105 -0
  2. package/dist/components/index.js +1696 -0
  3. package/dist/components/index.js.map +1 -0
  4. package/dist/components/index.mjs +1630 -0
  5. package/dist/components/index.mjs.map +1 -0
  6. package/dist/config/index.js +1840 -0
  7. package/dist/config/index.js.map +1 -0
  8. package/dist/config/index.mjs +1793 -0
  9. package/dist/config/index.mjs.map +1 -0
  10. package/dist/data/index.js +1296 -0
  11. package/dist/data/index.js.map +1 -0
  12. package/dist/data/index.mjs +1220 -0
  13. package/dist/data/index.mjs.map +1 -0
  14. package/dist/index.js +5433 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/index.mjs +5285 -0
  17. package/dist/index.mjs.map +1 -0
  18. package/dist/seo/index.js +187 -0
  19. package/dist/seo/index.js.map +1 -0
  20. package/dist/seo/index.mjs +155 -0
  21. package/dist/seo/index.mjs.map +1 -0
  22. package/dist/theme/index.js +552 -0
  23. package/dist/theme/index.js.map +1 -0
  24. package/dist/theme/index.mjs +526 -0
  25. package/dist/theme/index.mjs.map +1 -0
  26. package/package.json +96 -0
  27. package/src/components/index.ts +41 -0
  28. package/src/components/layout/Footer.tsx +234 -0
  29. package/src/components/layout/Header.tsx +134 -0
  30. package/src/components/sections/FAQ.tsx +178 -0
  31. package/src/components/sections/Gallery.tsx +107 -0
  32. package/src/components/sections/Hero.tsx +202 -0
  33. package/src/components/sections/Hours.tsx +225 -0
  34. package/src/components/sections/Services.tsx +216 -0
  35. package/src/components/sections/Testimonials.tsx +184 -0
  36. package/src/components/ui/Button.tsx +158 -0
  37. package/src/components/ui/Card.tsx +162 -0
  38. package/src/components/ui/Icons.tsx +508 -0
  39. package/src/config/index.ts +207 -0
  40. package/src/config/presets/generic.ts +153 -0
  41. package/src/config/presets/home-kitchen.ts +154 -0
  42. package/src/config/presets/index.ts +708 -0
  43. package/src/config/presets/professional.ts +165 -0
  44. package/src/config/presets/repair.ts +160 -0
  45. package/src/config/presets/restaurant.ts +162 -0
  46. package/src/config/presets/salon.ts +178 -0
  47. package/src/config/presets/tailor.ts +159 -0
  48. package/src/config/types.ts +314 -0
  49. package/src/data/csv-parser.ts +154 -0
  50. package/src/data/defaults.ts +202 -0
  51. package/src/data/google-drive.ts +148 -0
  52. package/src/data/index.ts +535 -0
  53. package/src/data/sheets.ts +709 -0
  54. package/src/data/types.ts +379 -0
  55. package/src/seo/index.ts +272 -0
  56. package/src/theme/colors.ts +351 -0
  57. package/src/theme/index.ts +249 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Google Sheets Integration
3
+ *
4
+ * Provides data fetching from Google Sheets in two modes:
5
+ * 1. Public CSV: Fetch published sheets via CSV URLs (simpler, no auth)
6
+ * 2. Private API: Fetch via Google Sheets API with service account (more secure)
7
+ *
8
+ * Both modes return the same SiteData structure.
9
+ */
10
+
11
+ import {
12
+ type SiteData,
13
+ type PartialSiteData,
14
+ type SheetConfig,
15
+ type PublicCsvConfig,
16
+ type PrivateApiConfig,
17
+ type InlineDataConfig,
18
+ type BusinessInfo,
19
+ type HoursEntry,
20
+ type Service,
21
+ type GalleryItem,
22
+ type Testimonial,
23
+ type FAQItem,
24
+ type TeamMember,
25
+ type MenuItem,
26
+ type Product,
27
+ type Announcement,
28
+ type DayOfWeek,
29
+ } from './types';
30
+ import { parseCSV, type ParsedRow, parseBoolean, parseNumber, parseSortOrder } from './csv-parser';
31
+ import { normalizeGoogleDriveUrl } from './google-drive';
32
+
33
+ // =============================================================================
34
+ // CONFIGURATION
35
+ // =============================================================================
36
+
37
+ const DEFAULT_REVALIDATE = 300; // 5 minutes
38
+
39
+ export interface FetchOptions {
40
+ revalidate?: number;
41
+ cache?: RequestCache;
42
+ }
43
+
44
+ // =============================================================================
45
+ // PUBLIC CSV MODE
46
+ // =============================================================================
47
+
48
+ /**
49
+ * Fetch data from a published Google Sheet CSV URL.
50
+ */
51
+ async function fetchCSV(url: string, options: FetchOptions = {}): Promise<string> {
52
+ const response = await fetch(url, {
53
+ next: { revalidate: options.revalidate ?? DEFAULT_REVALIDATE },
54
+ cache: options.cache,
55
+ });
56
+
57
+ if (!response.ok) {
58
+ throw new Error(`Failed to fetch CSV: ${response.status} ${response.statusText}`);
59
+ }
60
+
61
+ return response.text();
62
+ }
63
+
64
+ /**
65
+ * Fetch and parse a CSV tab from a public sheet.
66
+ */
67
+ async function fetchPublicTab<T>(
68
+ url: string | undefined,
69
+ parser: (rows: ParsedRow[]) => T,
70
+ options: FetchOptions = {}
71
+ ): Promise<T | undefined> {
72
+ if (!url) return undefined;
73
+
74
+ try {
75
+ const csvText = await fetchCSV(url, options);
76
+ const rows = parseCSV(csvText);
77
+ return parser(rows);
78
+ } catch (error) {
79
+ console.error(`Error fetching public tab from ${url}:`, error);
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Fetch all data from a public CSV configuration.
86
+ */
87
+ export async function fetchPublicSheetData(
88
+ config: PublicCsvConfig,
89
+ options: FetchOptions = {}
90
+ ): Promise<PartialSiteData> {
91
+ const [business, hours, services, gallery, testimonials, faq, team, menu, products, announcements] =
92
+ await Promise.all([
93
+ fetchPublicTab(config.tabs.business, parseBusinessRows, options),
94
+ fetchPublicTab(config.tabs.hours, parseHoursRows, options),
95
+ fetchPublicTab(config.tabs.services, parseServicesRows, options),
96
+ fetchPublicTab(config.tabs.gallery, parseGalleryRows, options),
97
+ fetchPublicTab(config.tabs.testimonials, parseTestimonialsRows, options),
98
+ fetchPublicTab(config.tabs.faq, parseFAQRows, options),
99
+ fetchPublicTab(config.tabs.team, parseTeamRows, options),
100
+ fetchPublicTab(config.tabs.menu, parseMenuRows, options),
101
+ fetchPublicTab(config.tabs.products, parseProductsRows, options),
102
+ fetchPublicTab(config.tabs.announcements, parseAnnouncementsRows, options),
103
+ ]);
104
+
105
+ return {
106
+ business,
107
+ hours,
108
+ services,
109
+ gallery,
110
+ testimonials,
111
+ faq,
112
+ team,
113
+ menu,
114
+ products,
115
+ announcements,
116
+ };
117
+ }
118
+
119
+ // =============================================================================
120
+ // PRIVATE API MODE
121
+ // =============================================================================
122
+
123
+ /**
124
+ * Create a JWT for Google Sheets API authentication.
125
+ * Uses Web Crypto API - no external dependencies needed.
126
+ */
127
+ async function createJWT(clientEmail: string, privateKey: string): Promise<string> {
128
+ const now = Math.floor(Date.now() / 1000);
129
+
130
+ const header = {
131
+ alg: 'RS256',
132
+ typ: 'JWT',
133
+ };
134
+
135
+ const payload = {
136
+ iss: clientEmail,
137
+ scope: 'https://www.googleapis.com/auth/spreadsheets.readonly',
138
+ aud: 'https://oauth2.googleapis.com/token',
139
+ iat: now,
140
+ exp: now + 3600, // 1 hour
141
+ };
142
+
143
+ // Encode header and payload
144
+ const encoder = new TextEncoder();
145
+ const headerB64 = btoa(JSON.stringify(header))
146
+ .replace(/\+/g, '-')
147
+ .replace(/\//g, '_')
148
+ .replace(/=/g, '');
149
+ const payloadB64 = btoa(JSON.stringify(payload))
150
+ .replace(/\+/g, '-')
151
+ .replace(/\//g, '_')
152
+ .replace(/=/g, '');
153
+
154
+ const signInput = `${headerB64}.${payloadB64}`;
155
+
156
+ // Parse PEM private key
157
+ const pemContents = privateKey
158
+ .replace(/-----BEGIN PRIVATE KEY-----/g, '')
159
+ .replace(/-----END PRIVATE KEY-----/g, '')
160
+ .replace(/\\n/g, '')
161
+ .replace(/\n/g, '')
162
+ .replace(/\s/g, '');
163
+
164
+ const binaryKey = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0));
165
+
166
+ // Import key for signing
167
+ const cryptoKey = await crypto.subtle.importKey(
168
+ 'pkcs8',
169
+ binaryKey,
170
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
171
+ false,
172
+ ['sign']
173
+ );
174
+
175
+ // Sign
176
+ const signature = await crypto.subtle.sign(
177
+ 'RSASSA-PKCS1-v1_5',
178
+ cryptoKey,
179
+ encoder.encode(signInput)
180
+ );
181
+
182
+ // Encode signature
183
+ const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
184
+ .replace(/\+/g, '-')
185
+ .replace(/\//g, '_')
186
+ .replace(/=/g, '');
187
+
188
+ return `${signInput}.${signatureB64}`;
189
+ }
190
+
191
+ /**
192
+ * Get an access token for the Google Sheets API.
193
+ */
194
+ async function getAccessToken(clientEmail: string, privateKey: string): Promise<string> {
195
+ const jwt = await createJWT(clientEmail, privateKey);
196
+
197
+ const response = await fetch('https://oauth2.googleapis.com/token', {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
200
+ body: new URLSearchParams({
201
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
202
+ assertion: jwt,
203
+ }),
204
+ });
205
+
206
+ if (!response.ok) {
207
+ throw new Error(`Failed to get access token: ${response.status}`);
208
+ }
209
+
210
+ const data = await response.json();
211
+ return data.access_token;
212
+ }
213
+
214
+ /**
215
+ * Fetch a single tab from the Google Sheets API.
216
+ */
217
+ async function fetchPrivateTab<T>(
218
+ sheetId: string,
219
+ tabName: string,
220
+ accessToken: string,
221
+ parser: (rows: ParsedRow[]) => T
222
+ ): Promise<T | undefined> {
223
+ try {
224
+ const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${encodeURIComponent(tabName)}`;
225
+
226
+ const response = await fetch(url, {
227
+ headers: { Authorization: `Bearer ${accessToken}` },
228
+ });
229
+
230
+ if (!response.ok) {
231
+ if (response.status === 404) {
232
+ // Tab doesn't exist, return undefined
233
+ return undefined;
234
+ }
235
+ throw new Error(`Failed to fetch tab ${tabName}: ${response.status}`);
236
+ }
237
+
238
+ const data = await response.json();
239
+ const values: string[][] = data.values || [];
240
+
241
+ if (values.length < 2) return undefined;
242
+
243
+ // Convert array format to objects
244
+ const headers = values[0].map((h: string) =>
245
+ h.toLowerCase().trim().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
246
+ );
247
+ const rows: ParsedRow[] = [];
248
+
249
+ for (let i = 1; i < values.length; i++) {
250
+ const row: ParsedRow = {};
251
+ for (let j = 0; j < headers.length; j++) {
252
+ row[headers[j]] = values[i][j]?.trim() ?? '';
253
+ }
254
+ rows.push(row);
255
+ }
256
+
257
+ return parser(rows);
258
+ } catch (error) {
259
+ console.error(`Error fetching private tab ${tabName}:`, error);
260
+ return undefined;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Fetch all data from a private API configuration.
266
+ */
267
+ export async function fetchPrivateSheetData(
268
+ config: PrivateApiConfig,
269
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
270
+ _options: FetchOptions = {}
271
+ ): Promise<PartialSiteData> {
272
+ const accessToken = await getAccessToken(config.clientEmail, config.privateKey);
273
+
274
+ const tabs = config.tabs || {};
275
+ const sheetId = config.sheetId;
276
+
277
+ const [business, hours, services, gallery, testimonials, faq, team, menu, products, announcements] =
278
+ await Promise.all([
279
+ fetchPrivateTab(sheetId, tabs.business || 'business', accessToken, parseBusinessRows),
280
+ fetchPrivateTab(sheetId, tabs.hours || 'hours', accessToken, parseHoursRows),
281
+ fetchPrivateTab(sheetId, tabs.services || 'services', accessToken, parseServicesRows),
282
+ fetchPrivateTab(sheetId, tabs.gallery || 'gallery', accessToken, parseGalleryRows),
283
+ fetchPrivateTab(sheetId, tabs.testimonials || 'testimonials', accessToken, parseTestimonialsRows),
284
+ fetchPrivateTab(sheetId, tabs.faq || 'faq', accessToken, parseFAQRows),
285
+ fetchPrivateTab(sheetId, tabs.team || 'team', accessToken, parseTeamRows),
286
+ fetchPrivateTab(sheetId, tabs.menu || 'menu', accessToken, parseMenuRows),
287
+ fetchPrivateTab(sheetId, tabs.products || 'products', accessToken, parseProductsRows),
288
+ fetchPrivateTab(sheetId, tabs.announcements || 'announcements', accessToken, parseAnnouncementsRows),
289
+ ]);
290
+
291
+ return {
292
+ business,
293
+ hours,
294
+ services,
295
+ gallery,
296
+ testimonials,
297
+ faq,
298
+ team,
299
+ menu,
300
+ products,
301
+ announcements,
302
+ };
303
+ }
304
+
305
+ // =============================================================================
306
+ // INLINE DATA MODE
307
+ // =============================================================================
308
+
309
+ /**
310
+ * Process inline data configuration.
311
+ * Simply returns the provided data - no network requests needed.
312
+ */
313
+ export function fetchInlineData(config: InlineDataConfig): PartialSiteData {
314
+ return config.data;
315
+ }
316
+
317
+ // =============================================================================
318
+ // UNIFIED FETCH
319
+ // =============================================================================
320
+
321
+ /**
322
+ * Fetch site data using the provided configuration.
323
+ * Automatically detects the mode and fetches accordingly.
324
+ *
325
+ * Supported modes:
326
+ * - 'public_csv': Fetch from published Google Sheet CSV URLs
327
+ * - 'private_api': Fetch via Google Sheets API with service account
328
+ * - 'inline': Use provided data directly (no network requests)
329
+ */
330
+ export async function fetchSheetData(
331
+ config: SheetConfig,
332
+ options: FetchOptions = {}
333
+ ): Promise<PartialSiteData> {
334
+ switch (config.mode) {
335
+ case 'public_csv':
336
+ return fetchPublicSheetData(config, options);
337
+ case 'private_api':
338
+ return fetchPrivateSheetData(config, options);
339
+ case 'inline':
340
+ return fetchInlineData(config);
341
+ }
342
+ }
343
+
344
+ // =============================================================================
345
+ // ROW PARSERS
346
+ // =============================================================================
347
+
348
+ /**
349
+ * Parse business info from rows (key-value format).
350
+ */
351
+ function parseBusinessRows(rows: ParsedRow[]): Partial<BusinessInfo> {
352
+ const data: Record<string, string> = {};
353
+
354
+ for (const row of rows) {
355
+ const key = row.key?.toLowerCase().replace(/\s+/g, '_');
356
+ const value = row.value;
357
+ if (key && value) {
358
+ data[key] = value;
359
+ }
360
+ }
361
+
362
+ return {
363
+ name: data.name || data.business_name || '',
364
+ tagline: data.tagline,
365
+ description: data.description || data.about,
366
+ aboutShort: data.about_short,
367
+ aboutLong: data.about_long,
368
+ phone: data.phone,
369
+ phoneAlt: data.phone_alt || data.phone_secondary,
370
+ email: data.email,
371
+ addressLine1: data.address_line1 || data.address || data.street,
372
+ addressLine2: data.address_line2,
373
+ city: data.city,
374
+ state: data.state,
375
+ zip: data.zip || data.zipcode || data.postal_code,
376
+ country: data.country || 'USA',
377
+ googleMapsUrl: data.google_maps_url || data.maps_url,
378
+ mapsEmbedUrl: data.maps_embed_url || data.embed_url,
379
+ timezone: data.timezone || 'America/Los_Angeles',
380
+ logoUrl: normalizeGoogleDriveUrl(data.logo_url || data.logo),
381
+ heroImageUrl: normalizeGoogleDriveUrl(data.hero_image_url || data.hero_image || data.hero_photo_url),
382
+ ogImageUrl: normalizeGoogleDriveUrl(data.og_image_url || data.og_image),
383
+ socialYelp: data.social_yelp || data.yelp,
384
+ socialInstagram: data.social_instagram || data.instagram,
385
+ socialFacebook: data.social_facebook || data.facebook,
386
+ socialTwitter: data.social_twitter || data.twitter,
387
+ socialLinkedIn: data.social_linkedin || data.linkedin,
388
+ socialYoutube: data.social_youtube || data.youtube,
389
+ socialTiktok: data.social_tiktok || data.tiktok,
390
+ primaryCtaText: data.primary_cta_text || data.cta_text,
391
+ primaryCtaUrl: data.primary_cta_url || data.cta_url,
392
+ secondaryCtaText: data.secondary_cta_text,
393
+ secondaryCtaUrl: data.secondary_cta_url,
394
+ bookingUrl: data.booking_url || data.booking_or_quote_url,
395
+ bannerEnabled: parseBoolean(data.banner_enabled),
396
+ bannerText: data.banner_text,
397
+ bannerUrl: data.banner_url,
398
+ priceRange: data.price_range as BusinessInfo['priceRange'],
399
+ yearEstablished: parseNumber(data.year_established),
400
+ licenseNumber: data.license_number,
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Parse hours from rows.
406
+ */
407
+ function parseHoursRows(rows: ParsedRow[]): HoursEntry[] {
408
+ const dayMap: Record<string, DayOfWeek> = {
409
+ monday: 'monday',
410
+ mon: 'monday',
411
+ tuesday: 'tuesday',
412
+ tue: 'tuesday',
413
+ tues: 'tuesday',
414
+ wednesday: 'wednesday',
415
+ wed: 'wednesday',
416
+ thursday: 'thursday',
417
+ thu: 'thursday',
418
+ thur: 'thursday',
419
+ thurs: 'thursday',
420
+ friday: 'friday',
421
+ fri: 'friday',
422
+ saturday: 'saturday',
423
+ sat: 'saturday',
424
+ sunday: 'sunday',
425
+ sun: 'sunday',
426
+ };
427
+
428
+ return rows
429
+ .map((row) => {
430
+ const dayKey = (row.day || row.day_of_week || '').toLowerCase();
431
+ const day = dayMap[dayKey];
432
+ if (!day) return null;
433
+
434
+ return {
435
+ day,
436
+ open: row.open || row.open_time || row.opens,
437
+ close: row.close || row.close_time || row.closes,
438
+ closed: parseBoolean(row.closed),
439
+ };
440
+ })
441
+ .filter((entry): entry is HoursEntry => entry !== null);
442
+ }
443
+
444
+ /**
445
+ * Parse services from rows.
446
+ */
447
+ function parseServicesRows(rows: ParsedRow[]): Service[] {
448
+ return rows
449
+ .map((row, index) => {
450
+ const title = row.title || row.name || row.service;
451
+ if (!title) return null;
452
+
453
+ return {
454
+ id: row.id || `service-${index}`,
455
+ title,
456
+ description: row.description || row.desc,
457
+ priceNote: row.price_note || row.price_hint || row.pricing,
458
+ price: parseNumber(row.price),
459
+ duration: row.duration || row.time,
460
+ icon: row.icon,
461
+ imageUrl: normalizeGoogleDriveUrl(row.image_url || row.image),
462
+ featured: parseBoolean(row.featured),
463
+ sortOrder: parseSortOrder(row.sort_order || row.sort),
464
+ category: row.category,
465
+ };
466
+ })
467
+ .filter((service): service is Service => service !== null)
468
+ .sort((a, b) => a.sortOrder - b.sortOrder);
469
+ }
470
+
471
+ /**
472
+ * Parse gallery items from rows.
473
+ */
474
+ function parseGalleryRows(rows: ParsedRow[]): GalleryItem[] {
475
+ return rows
476
+ .map((row, index) => {
477
+ const imageUrl = normalizeGoogleDriveUrl(row.image_url || row.url || row.image);
478
+ if (!imageUrl) return null;
479
+
480
+ return {
481
+ id: row.id || `gallery-${index}`,
482
+ imageUrl,
483
+ alt: row.alt || row.alt_text || row.description || '',
484
+ caption: row.caption,
485
+ featured: parseBoolean(row.featured),
486
+ sortOrder: parseSortOrder(row.sort_order || row.sort),
487
+ category: row.category,
488
+ };
489
+ })
490
+ .filter((item): item is GalleryItem => item !== null)
491
+ .sort((a, b) => a.sortOrder - b.sortOrder);
492
+ }
493
+
494
+ /**
495
+ * Parse testimonials from rows.
496
+ */
497
+ function parseTestimonialsRows(rows: ParsedRow[]): Testimonial[] {
498
+ return rows
499
+ .map((row, index) => {
500
+ const quote = row.quote || row.text || row.review || row.testimonial;
501
+ const name = row.name || row.author || row.reviewer;
502
+ if (!quote || !name) return null;
503
+
504
+ return {
505
+ id: row.id || `testimonial-${index}`,
506
+ quote,
507
+ name,
508
+ context: row.context || row.service,
509
+ rating: parseNumber(row.rating),
510
+ source: row.source || row.source_label || row.platform,
511
+ date: row.date,
512
+ imageUrl: normalizeGoogleDriveUrl(row.image_url || row.photo),
513
+ featured: parseBoolean(row.featured),
514
+ sortOrder: parseSortOrder(row.sort_order || row.sort),
515
+ };
516
+ })
517
+ .filter((item): item is Testimonial => item !== null)
518
+ .sort((a, b) => a.sortOrder - b.sortOrder);
519
+ }
520
+
521
+ /**
522
+ * Parse FAQ items from rows.
523
+ */
524
+ function parseFAQRows(rows: ParsedRow[]): FAQItem[] {
525
+ return rows
526
+ .map((row, index) => {
527
+ const question = row.question || row.q;
528
+ const answer = row.answer || row.a;
529
+ if (!question || !answer) return null;
530
+
531
+ return {
532
+ id: row.id || `faq-${index}`,
533
+ question,
534
+ answer,
535
+ category: row.category,
536
+ sortOrder: parseSortOrder(row.sort_order || row.sort),
537
+ };
538
+ })
539
+ .filter((item): item is FAQItem => item !== null)
540
+ .sort((a, b) => a.sortOrder - b.sortOrder);
541
+ }
542
+
543
+ /**
544
+ * Parse team members from rows.
545
+ */
546
+ function parseTeamRows(rows: ParsedRow[]): TeamMember[] {
547
+ return rows
548
+ .map((row, index) => {
549
+ const name = row.name;
550
+ if (!name) return null;
551
+
552
+ return {
553
+ id: row.id || `team-${index}`,
554
+ name,
555
+ role: row.role || row.title || row.position,
556
+ bio: row.bio || row.description,
557
+ imageUrl: normalizeGoogleDriveUrl(row.image_url || row.photo || row.image),
558
+ email: row.email,
559
+ phone: row.phone,
560
+ sortOrder: parseSortOrder(row.sort_order || row.sort),
561
+ };
562
+ })
563
+ .filter((item): item is TeamMember => item !== null)
564
+ .sort((a, b) => a.sortOrder - b.sortOrder);
565
+ }
566
+
567
+ /**
568
+ * Parse menu items from rows.
569
+ */
570
+ function parseMenuRows(rows: ParsedRow[]): MenuItem[] {
571
+ return rows
572
+ .map((row, index) => {
573
+ const name = row.name || row.item || row.title;
574
+ if (!name) return null;
575
+
576
+ const dietary = row.dietary || row.tags || row.dietary_tags;
577
+
578
+ return {
579
+ id: row.id || `menu-${index}`,
580
+ name,
581
+ description: row.description || row.desc,
582
+ price: parseNumber(row.price),
583
+ priceNote: row.price_note || row.pricing,
584
+ category: row.category || row.section,
585
+ imageUrl: normalizeGoogleDriveUrl(row.image_url || row.image),
586
+ dietary: dietary ? dietary.split(',').map((s: string) => s.trim()) : undefined,
587
+ featured: parseBoolean(row.featured),
588
+ available: row.available !== undefined ? parseBoolean(row.available) : true,
589
+ sortOrder: parseSortOrder(row.sort_order || row.sort),
590
+ };
591
+ })
592
+ .filter((item): item is MenuItem => item !== null)
593
+ .sort((a, b) => a.sortOrder - b.sortOrder);
594
+ }
595
+
596
+ /**
597
+ * Parse products from rows.
598
+ */
599
+ function parseProductsRows(rows: ParsedRow[]): Product[] {
600
+ const products = rows
601
+ .map((row, index): Product | null => {
602
+ const name = row.name || row.product || row.title;
603
+ if (!name) return null;
604
+
605
+ const images = row.images || row.additional_images;
606
+
607
+ const product: Product = {
608
+ name,
609
+ featured: parseBoolean(row.featured),
610
+ sortOrder: parseSortOrder(row.sort_order || row.sort),
611
+ inStock: row.in_stock !== undefined ? parseBoolean(row.in_stock) : true,
612
+ };
613
+
614
+ if (row.id) product.id = row.id;
615
+ else product.id = `product-${index}`;
616
+
617
+ if (row.description || row.desc) product.description = row.description || row.desc;
618
+ if (parseNumber(row.price) !== undefined) product.price = parseNumber(row.price);
619
+ if (row.price_note || row.pricing) product.priceNote = row.price_note || row.pricing;
620
+ if (row.category) product.category = row.category;
621
+
622
+ const imageUrl = normalizeGoogleDriveUrl(row.image_url || row.image);
623
+ if (imageUrl) product.imageUrl = imageUrl;
624
+
625
+ if (images) {
626
+ product.images = images
627
+ .split(',')
628
+ .map((s: string) => normalizeGoogleDriveUrl(s.trim()))
629
+ .filter(Boolean) as string[];
630
+ }
631
+
632
+ if (row.sku) product.sku = row.sku;
633
+ if (row.purchase_url || row.buy_url || row.shop_url) {
634
+ product.purchaseUrl = row.purchase_url || row.buy_url || row.shop_url;
635
+ }
636
+
637
+ return product;
638
+ })
639
+ .filter((item): item is Product => item !== null);
640
+
641
+ return products.sort((a, b) => a.sortOrder - b.sortOrder);
642
+ }
643
+
644
+ /**
645
+ * Parse announcements from rows.
646
+ */
647
+ function parseAnnouncementsRows(rows: ParsedRow[]): Announcement[] {
648
+ const announcements = rows
649
+ .map((row, index): Announcement | null => {
650
+ const title = row.title || row.headline;
651
+ if (!title) return null;
652
+
653
+ const announcement: Announcement = {
654
+ title,
655
+ sortOrder: parseSortOrder(row.sort_order || row.sort),
656
+ active: row.active !== undefined ? parseBoolean(row.active) : true,
657
+ };
658
+
659
+ if (row.id) announcement.id = row.id;
660
+ else announcement.id = `announcement-${index}`;
661
+
662
+ if (row.content || row.description || row.body) {
663
+ announcement.content = row.content || row.description || row.body;
664
+ }
665
+
666
+ const imageUrl = normalizeGoogleDriveUrl(row.image_url || row.image);
667
+ if (imageUrl) announcement.imageUrl = imageUrl;
668
+
669
+ if (row.link_url || row.url || row.link) {
670
+ announcement.linkUrl = row.link_url || row.url || row.link;
671
+ }
672
+ if (row.link_text || row.cta) announcement.linkText = row.link_text || row.cta;
673
+ if (row.start_date) announcement.startDate = row.start_date;
674
+ if (row.end_date) announcement.endDate = row.end_date;
675
+
676
+ return announcement;
677
+ })
678
+ .filter((item): item is Announcement => item !== null);
679
+
680
+ return announcements.sort((a, b) => a.sortOrder - b.sortOrder);
681
+ }
682
+
683
+ // =============================================================================
684
+ // MERGE WITH DEFAULTS
685
+ // =============================================================================
686
+
687
+ /**
688
+ * Merge partial site data with defaults to create complete site data.
689
+ */
690
+ export function mergeSiteData(
691
+ partial: PartialSiteData,
692
+ defaults: SiteData
693
+ ): SiteData {
694
+ return {
695
+ business: {
696
+ ...defaults.business,
697
+ ...partial.business,
698
+ },
699
+ hours: partial.hours ?? defaults.hours,
700
+ services: partial.services ?? defaults.services,
701
+ gallery: partial.gallery ?? defaults.gallery,
702
+ testimonials: partial.testimonials ?? defaults.testimonials,
703
+ faq: partial.faq ?? defaults.faq,
704
+ team: partial.team ?? defaults.team,
705
+ menu: partial.menu ?? defaults.menu,
706
+ products: partial.products ?? defaults.products,
707
+ announcements: partial.announcements ?? defaults.announcements,
708
+ };
709
+ }