@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,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
|
+
}
|