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