@bailierich/booking-components 2.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 (83) hide show
  1. package/README.md +319 -0
  2. package/TENANT_DATA_INTEGRATION.md +402 -0
  3. package/TENANT_SETUP.md +316 -0
  4. package/components/BookingFlow/BookingFlow.tsx +790 -0
  5. package/components/BookingFlow/index.ts +5 -0
  6. package/components/BookingFlow/steps/AddonsSelection.tsx +118 -0
  7. package/components/BookingFlow/steps/Confirmation.tsx +185 -0
  8. package/components/BookingFlow/steps/ContactForm.tsx +292 -0
  9. package/components/BookingFlow/steps/CycleAwareDateSelection.tsx +277 -0
  10. package/components/BookingFlow/steps/DateSelection.tsx +473 -0
  11. package/components/BookingFlow/steps/ServiceSelection.tsx +315 -0
  12. package/components/BookingFlow/steps/TimeSelection.tsx +230 -0
  13. package/components/BookingFlow/steps/index.ts +10 -0
  14. package/components/BottomSheet/index.tsx +120 -0
  15. package/components/Forms/FormBlock.tsx +283 -0
  16. package/components/Forms/FormField.tsx +385 -0
  17. package/components/Forms/FormRenderer.tsx +216 -0
  18. package/components/Forms/FormValidation.ts +122 -0
  19. package/components/Forms/index.ts +4 -0
  20. package/components/HoldTimer/HoldTimer.tsx +266 -0
  21. package/components/HoldTimer/index.ts +2 -0
  22. package/components/SectionRenderer.tsx +558 -0
  23. package/components/Sections/About.tsx +145 -0
  24. package/components/Sections/BeforeAfter.tsx +81 -0
  25. package/components/Sections/BookingSection.tsx +76 -0
  26. package/components/Sections/Contact.tsx +103 -0
  27. package/components/Sections/FAQSection.tsx +239 -0
  28. package/components/Sections/FeatureContent.tsx +113 -0
  29. package/components/Sections/FeaturedLink.tsx +103 -0
  30. package/components/Sections/FixedInfoCard.tsx +189 -0
  31. package/components/Sections/Gallery.tsx +83 -0
  32. package/components/Sections/Header.tsx +78 -0
  33. package/components/Sections/Hero.tsx +178 -0
  34. package/components/Sections/ImageSection.tsx +147 -0
  35. package/components/Sections/InstagramFeed.tsx +38 -0
  36. package/components/Sections/LinkList.tsx +76 -0
  37. package/components/Sections/LocationMap.tsx +202 -0
  38. package/components/Sections/Logo.tsx +61 -0
  39. package/components/Sections/MinimalFooter.tsx +78 -0
  40. package/components/Sections/MinimalHeader.tsx +81 -0
  41. package/components/Sections/MinimalNavigation.tsx +63 -0
  42. package/components/Sections/Navbar.tsx +258 -0
  43. package/components/Sections/PricingTable.tsx +106 -0
  44. package/components/Sections/ScrollingTextDivider.tsx +138 -0
  45. package/components/Sections/ScrollingTextDivider.tsx.bak +138 -0
  46. package/components/Sections/ServicesPreview.tsx +129 -0
  47. package/components/Sections/SocialBar.tsx +177 -0
  48. package/components/Sections/Team.tsx +80 -0
  49. package/components/Sections/Testimonials.tsx +92 -0
  50. package/components/Sections/TextSection.tsx +116 -0
  51. package/components/Sections/VideoSection.tsx +178 -0
  52. package/components/Sections/index.ts +57 -0
  53. package/components/index.ts +21 -0
  54. package/dist/index-DAai7Glf.d.mts +474 -0
  55. package/dist/index-DAai7Glf.d.ts +474 -0
  56. package/dist/index.d.mts +1075 -0
  57. package/dist/index.d.ts +1075 -0
  58. package/dist/index.js +22 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/index.mjs +22 -0
  61. package/dist/index.mjs.map +1 -0
  62. package/dist/styles/index.d.mts +1 -0
  63. package/dist/styles/index.d.ts +1 -0
  64. package/dist/styles/index.js +2 -0
  65. package/dist/styles/index.js.map +1 -0
  66. package/dist/styles/index.mjs +2 -0
  67. package/dist/styles/index.mjs.map +1 -0
  68. package/docs/API.md +849 -0
  69. package/docs/CALLBACKS.md +760 -0
  70. package/docs/COMPLETE_SESSION_SUMMARY.md +404 -0
  71. package/docs/DATA_SHAPES.md +684 -0
  72. package/docs/MIGRATION.md +662 -0
  73. package/docs/PAYMENT_INTEGRATION.md +766 -0
  74. package/docs/SESSION_SUMMARY.md +185 -0
  75. package/docs/STYLING.md +735 -0
  76. package/index.ts +4 -0
  77. package/lib/storage.ts +239 -0
  78. package/package.json +59 -0
  79. package/styles/animations.ts +210 -0
  80. package/styles/index.ts +1 -0
  81. package/tsconfig.json +32 -0
  82. package/tsup.config.ts +13 -0
  83. package/types/index.ts +369 -0
package/lib/storage.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Form Image Storage Utilities for OVIAH Booking Components
3
+ *
4
+ * Handles image uploads for form photo fields using Supabase Storage
5
+ * Targets the "form-uploads" bucket for form submission attachments
6
+ */
7
+
8
+ import { createClient } from '@supabase/supabase-js';
9
+
10
+ // Supabase client will be initialized from environment variables
11
+ let supabaseClient: ReturnType<typeof createClient> | null = null;
12
+
13
+ export function getSupabaseClient() {
14
+ if (!supabaseClient) {
15
+ const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
16
+ const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
17
+
18
+ if (!supabaseUrl || !supabaseKey) {
19
+ throw new Error('Supabase URL and Anon Key must be provided');
20
+ }
21
+
22
+ supabaseClient = createClient(supabaseUrl, supabaseKey);
23
+ }
24
+ return supabaseClient;
25
+ }
26
+
27
+ // Storage configuration for form uploads
28
+ export const FORM_UPLOAD_CONFIG = {
29
+ bucket: 'form-uploads',
30
+ structure: '{clientId}/{formId}/{timestamp}_{filename}',
31
+ limits: {
32
+ maxSize: 5 * 1024 * 1024, // 5MB in bytes
33
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'],
34
+ }
35
+ } as const;
36
+
37
+ export interface UploadedFormImage {
38
+ url: string;
39
+ path: string;
40
+ filename: string;
41
+ size: number;
42
+ type: string;
43
+ }
44
+
45
+ /**
46
+ * Upload a form image to Supabase Storage
47
+ */
48
+ export async function uploadFormImage(
49
+ file: File,
50
+ clientId: string,
51
+ formId: string
52
+ ): Promise<UploadedFormImage> {
53
+ console.log('📦 uploadFormImage called:', { fileName: file.name, clientId, formId });
54
+
55
+ // Validate file size
56
+ if (file.size > FORM_UPLOAD_CONFIG.limits.maxSize) {
57
+ throw new Error(`File size exceeds maximum of ${FORM_UPLOAD_CONFIG.limits.maxSize / 1024 / 1024}MB`);
58
+ }
59
+
60
+ // Validate file type
61
+ if (!FORM_UPLOAD_CONFIG.limits.allowedTypes.includes(file.type)) {
62
+ throw new Error(`File type ${file.type} not allowed. Accepted types: JPG, PNG, WebP`);
63
+ }
64
+
65
+ console.log('🎨 Starting image optimization...');
66
+ // Optimize image before upload
67
+ const optimizedFile = await optimizeImage(file);
68
+ console.log('✅ Image optimized:', { originalSize: file.size, optimizedSize: optimizedFile.size });
69
+
70
+ // Generate unique filename
71
+ const timestamp = Date.now();
72
+ const fileExt = file.name.split('.').pop()?.toLowerCase() || 'jpg';
73
+ const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50);
74
+ const fileName = `${timestamp}_${sanitizedName}`;
75
+ const filePath = `${clientId}/${formId}/${fileName}`;
76
+
77
+ console.log('☁️ Uploading to Supabase:', { filePath, bucket: FORM_UPLOAD_CONFIG.bucket });
78
+
79
+ // Get Supabase client
80
+ const supabase = getSupabaseClient();
81
+
82
+ // Upload to Supabase Storage
83
+ const { data, error } = await supabase.storage
84
+ .from(FORM_UPLOAD_CONFIG.bucket)
85
+ .upload(filePath, optimizedFile, {
86
+ cacheControl: '3600',
87
+ upsert: false,
88
+ contentType: optimizedFile.type
89
+ });
90
+
91
+ console.log('📤 Upload result:', { data, error });
92
+
93
+ if (error) {
94
+ console.error('❌ Supabase upload error:', error);
95
+ throw new Error(`Upload failed: ${error.message}`);
96
+ }
97
+
98
+ console.log('✅ Upload successful, getting public URL...');
99
+
100
+ // Get public URL
101
+ const { data: { publicUrl } } = supabase.storage
102
+ .from(FORM_UPLOAD_CONFIG.bucket)
103
+ .getPublicUrl(filePath);
104
+
105
+ console.log('✅ Public URL obtained:', publicUrl);
106
+
107
+ return {
108
+ url: publicUrl,
109
+ path: filePath,
110
+ filename: file.name,
111
+ size: optimizedFile.size,
112
+ type: optimizedFile.type
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Optimize image before upload
118
+ * - Resize to max 1920px width
119
+ * - Compress to 85% quality
120
+ */
121
+ export async function optimizeImage(
122
+ file: File,
123
+ maxWidth: number = 1920,
124
+ quality: number = 0.85
125
+ ): Promise<File> {
126
+ console.log('🖼️ optimizeImage: Starting optimization');
127
+
128
+ // Determine if the image supports transparency
129
+ const supportsTransparency = file.type === 'image/png' || file.type === 'image/webp';
130
+ const outputType = supportsTransparency ? file.type : 'image/jpeg';
131
+
132
+ console.log('🖼️ optimizeImage: Output type:', outputType, 'Transparency:', supportsTransparency);
133
+
134
+ return new Promise((resolve, reject) => {
135
+ // Set a timeout to prevent hanging
136
+ const timeout = setTimeout(() => {
137
+ console.error('⏱️ optimizeImage: Timeout after 30 seconds');
138
+ reject(new Error('Image optimization timed out'));
139
+ }, 30000);
140
+
141
+ const img = new Image();
142
+ const canvas = document.createElement('canvas');
143
+ const ctx = canvas.getContext('2d', { alpha: supportsTransparency });
144
+
145
+ if (!ctx) {
146
+ clearTimeout(timeout);
147
+ console.error('❌ optimizeImage: Canvas context not available');
148
+ reject(new Error('Canvas context not available'));
149
+ return;
150
+ }
151
+
152
+ console.log('🖼️ optimizeImage: Created canvas and context');
153
+
154
+ img.onload = () => {
155
+ console.log('🖼️ optimizeImage: Image loaded', { width: img.width, height: img.height });
156
+
157
+ try {
158
+ // Calculate new dimensions
159
+ const ratio = Math.min(maxWidth / img.width, 1);
160
+ canvas.width = img.width * ratio;
161
+ canvas.height = img.height * ratio;
162
+
163
+ console.log('🖼️ optimizeImage: Resizing to', { width: canvas.width, height: canvas.height });
164
+
165
+ // Clear canvas with transparency if needed
166
+ if (supportsTransparency) {
167
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
168
+ }
169
+
170
+ // Draw resized image
171
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
172
+
173
+ console.log('🖼️ optimizeImage: Image drawn, converting to blob...');
174
+
175
+ // Convert to blob
176
+ canvas.toBlob(
177
+ (blob) => {
178
+ clearTimeout(timeout);
179
+
180
+ if (!blob) {
181
+ console.error('❌ optimizeImage: Blob conversion failed');
182
+ reject(new Error('Image optimization failed'));
183
+ return;
184
+ }
185
+
186
+ console.log('✅ optimizeImage: Blob created', { size: blob.size, type: outputType });
187
+
188
+ // Create new file with optimized blob
189
+ const optimizedFile = new File([blob], file.name, {
190
+ type: outputType,
191
+ lastModified: Date.now()
192
+ });
193
+
194
+ console.log('✅ optimizeImage: Optimization complete');
195
+ resolve(optimizedFile);
196
+ },
197
+ outputType,
198
+ quality
199
+ );
200
+ } catch (err) {
201
+ clearTimeout(timeout);
202
+ console.error('❌ optimizeImage: Error during processing', err);
203
+ reject(err);
204
+ }
205
+ };
206
+
207
+ img.onerror = (err) => {
208
+ clearTimeout(timeout);
209
+ console.error('❌ optimizeImage: Failed to load image', err);
210
+ reject(new Error('Failed to load image'));
211
+ };
212
+
213
+ console.log('🖼️ optimizeImage: Creating object URL and loading image...');
214
+ img.src = URL.createObjectURL(file);
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Validate image file
220
+ */
221
+ export function validateImageFile(file: File): { valid: boolean; error?: string } {
222
+ // Check file size
223
+ if (file.size > FORM_UPLOAD_CONFIG.limits.maxSize) {
224
+ return {
225
+ valid: false,
226
+ error: `File size exceeds maximum of ${FORM_UPLOAD_CONFIG.limits.maxSize / 1024 / 1024}MB`
227
+ };
228
+ }
229
+
230
+ // Check file type
231
+ if (!FORM_UPLOAD_CONFIG.limits.allowedTypes.includes(file.type)) {
232
+ return {
233
+ valid: false,
234
+ error: `File type not allowed. Accepted: JPG, PNG, WebP`
235
+ };
236
+ }
237
+
238
+ return { valid: true };
239
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@bailierich/booking-components",
3
+ "version": "2.0.0",
4
+ "description": "Shared booking flow components for OVIAH platform",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/bailierich/oviah-saas-dashboard.git",
11
+ "directory": "packages/booking-components"
12
+ },
13
+ "publishConfig": {
14
+ "registry": "https://registry.npmjs.org",
15
+ "access": "public"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.mjs",
21
+ "require": "./dist/index.js"
22
+ },
23
+ "./styles": {
24
+ "types": "./dist/styles/index.d.ts",
25
+ "import": "./dist/styles/index.mjs",
26
+ "require": "./dist/styles/index.js"
27
+ }
28
+ },
29
+ "scripts": {
30
+ "build": "tsup",
31
+ "dev": "tsup --watch",
32
+ "type-check": "tsc --noEmit"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^18.0.0 || ^19.0.0",
36
+ "react-dom": "^18.0.0 || ^19.0.0"
37
+ },
38
+ "dependencies": {
39
+ "framer-motion": "^12.23.24",
40
+ "lucide-react": "^0.542.0",
41
+ "clsx": "^2.1.1",
42
+ "@supabase/supabase-js": "^2.39.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/react": "^19",
46
+ "@types/react-dom": "^19",
47
+ "typescript": "^5",
48
+ "tsup": "^8.0.0"
49
+ },
50
+ "keywords": [
51
+ "booking",
52
+ "appointments",
53
+ "react",
54
+ "components",
55
+ "oviah"
56
+ ],
57
+ "author": "OVIAH",
58
+ "license": "UNLICENSED"
59
+ }
@@ -0,0 +1,210 @@
1
+ import type { AnimationVariants, TransitionDirection } from '../types';
2
+
3
+ /**
4
+ * Animation duration calculator based on speed and reduced motion preference
5
+ */
6
+ export function getAnimationDuration(
7
+ speed: 'fast' | 'normal' | 'slow',
8
+ reducedMotion: boolean = false
9
+ ): number {
10
+ if (reducedMotion) return 0.15;
11
+
12
+ switch (speed) {
13
+ case 'fast':
14
+ return 0.2;
15
+ case 'slow':
16
+ return 0.5;
17
+ default:
18
+ return 0.3;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Slide animation variants for step transitions
24
+ */
25
+ export function getSlideVariants(slideDistance: number = 50): AnimationVariants {
26
+ return {
27
+ enter: (direction: TransitionDirection) => ({
28
+ x: direction === 'forward' ? slideDistance : -slideDistance,
29
+ opacity: 0,
30
+ }),
31
+ center: {
32
+ x: 0,
33
+ opacity: 1,
34
+ },
35
+ exit: (direction: TransitionDirection) => ({
36
+ x: direction === 'forward' ? -slideDistance : slideDistance,
37
+ opacity: 0,
38
+ }),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Fade animation variants for step transitions
44
+ */
45
+ export function getFadeVariants(): AnimationVariants {
46
+ return {
47
+ enter: () => ({
48
+ opacity: 0,
49
+ scale: 0.95,
50
+ }),
51
+ center: {
52
+ opacity: 1,
53
+ scale: 1,
54
+ },
55
+ exit: () => ({
56
+ opacity: 0,
57
+ scale: 0.95,
58
+ }),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Get transition variants based on style preference
64
+ */
65
+ export function getTransitionVariants(
66
+ style: 'slide' | 'fade',
67
+ slideDistance?: number
68
+ ): AnimationVariants {
69
+ return style === 'fade' ? getFadeVariants() : getSlideVariants(slideDistance);
70
+ }
71
+
72
+ /**
73
+ * Common animation configurations
74
+ */
75
+ export const animations = {
76
+ /**
77
+ * Smooth easing curve for natural motion
78
+ */
79
+ easing: [0.4, 0, 0.2, 1] as const,
80
+
81
+ /**
82
+ * Spring configuration for elastic animations
83
+ */
84
+ spring: {
85
+ type: 'spring' as const,
86
+ damping: 30,
87
+ stiffness: 300,
88
+ },
89
+
90
+ /**
91
+ * Stagger children animation
92
+ */
93
+ staggerChildren: {
94
+ staggerChildren: 0.05,
95
+ delayChildren: 0.1,
96
+ },
97
+
98
+ /**
99
+ * Card hover animation
100
+ */
101
+ cardHover: {
102
+ scale: 1.02,
103
+ transition: { duration: 0.2 },
104
+ },
105
+
106
+ /**
107
+ * Button hover animation
108
+ */
109
+ buttonHover: {
110
+ scale: 1.05,
111
+ transition: { duration: 0.2 },
112
+ },
113
+
114
+ /**
115
+ * Fade in animation
116
+ */
117
+ fadeIn: {
118
+ initial: { opacity: 0 },
119
+ animate: { opacity: 1 },
120
+ exit: { opacity: 0 },
121
+ },
122
+
123
+ /**
124
+ * Slide up animation (for bottom sheets)
125
+ */
126
+ slideUp: {
127
+ initial: { y: '100%' },
128
+ animate: { y: 0 },
129
+ exit: { y: '100%' },
130
+ },
131
+
132
+ /**
133
+ * Scale in animation
134
+ */
135
+ scaleIn: {
136
+ initial: { opacity: 0, scale: 0.9 },
137
+ animate: { opacity: 1, scale: 1 },
138
+ exit: { opacity: 0, scale: 0.9 },
139
+ },
140
+
141
+ /**
142
+ * Slide in from left
143
+ */
144
+ slideInLeft: {
145
+ initial: { opacity: 0, x: -10 },
146
+ animate: { opacity: 1, x: 0 },
147
+ },
148
+
149
+ /**
150
+ * Slide in from right
151
+ */
152
+ slideInRight: {
153
+ initial: { opacity: 0, x: 10 },
154
+ animate: { opacity: 1, x: 0 },
155
+ },
156
+
157
+ /**
158
+ * Backdrop animation
159
+ */
160
+ backdrop: {
161
+ initial: { opacity: 0 },
162
+ animate: { opacity: 1 },
163
+ exit: { opacity: 0 },
164
+ },
165
+ };
166
+
167
+ /**
168
+ * Accessibility: Check for reduced motion preference
169
+ */
170
+ export function shouldReduceMotion(): boolean {
171
+ if (typeof window === 'undefined') return false;
172
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
173
+ }
174
+
175
+ /**
176
+ * Create stagger animation for list items
177
+ */
178
+ export function createStaggerAnimation(
179
+ itemCount: number,
180
+ baseDelay: number = 0.1,
181
+ delayIncrement: number = 0.05
182
+ ) {
183
+ return Array.from({ length: itemCount }, (_, index) => ({
184
+ initial: { opacity: 0, y: 10 },
185
+ animate: { opacity: 1, y: 0 },
186
+ transition: {
187
+ duration: 0.3,
188
+ delay: baseDelay + index * delayIncrement,
189
+ ease: animations.easing,
190
+ },
191
+ }));
192
+ }
193
+
194
+ /**
195
+ * Utility to create entrance animation with delay
196
+ */
197
+ export function createEntranceAnimation(
198
+ delay: number = 0,
199
+ duration: number = 0.3
200
+ ) {
201
+ return {
202
+ initial: { opacity: 0, y: 10 },
203
+ animate: { opacity: 1, y: 0 },
204
+ transition: {
205
+ duration,
206
+ delay,
207
+ ease: animations.easing,
208
+ },
209
+ };
210
+ }
@@ -0,0 +1 @@
1
+ export * from './animations';
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "jsx": "react-jsx",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "outDir": "./dist",
10
+ "rootDir": ".",
11
+ "strict": true,
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "esModuleInterop": true,
17
+ "skipLibCheck": true,
18
+ "forceConsistentCasingInFileNames": true,
19
+ "noEmit": true,
20
+ "paths": {
21
+ "@/*": ["./*"]
22
+ }
23
+ },
24
+ "include": [
25
+ "components/**/*",
26
+ "types/**/*",
27
+ "styles/**/*",
28
+ "hooks/**/*",
29
+ "index.ts"
30
+ ],
31
+ "exclude": ["node_modules", "dist"]
32
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['index.ts', 'styles/index.ts'],
5
+ format: ['cjs', 'esm'],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ external: ['react', 'react-dom'],
10
+ treeshake: true,
11
+ splitting: false,
12
+ minify: true,
13
+ });