diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index dca2dff..ebf071d 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -45,6 +45,7 @@ jobs: --build-arg VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }} \ --build-arg VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} \ --build-arg VITE_API_URL=${{ secrets.VITE_API_URL }} \ + --build-arg VITE_GCP_FUNCTION_BASE_URL=https://${{ env.REGION }}-${{ env.PROJECT_ID }}.cloudfunctions.net \ -t ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE }}:${{ github.sha }} webapp/ docker tag ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE }}:${{ github.sha }} \ ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE }}:latest diff --git a/.gitignore b/.gitignore index 526ee7f..dafaf08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ local-dev +dist/ + static_assets/ scripts/ tmp/ diff --git a/_infra/cloud-run.tf b/_infra/cloud-run.tf index bdd3b49..3e8e3e2 100644 --- a/_infra/cloud-run.tf +++ b/_infra/cloud-run.tf @@ -9,6 +9,12 @@ resource "google_cloud_run_service" "webapp" { containers { image = "${var.region}-docker.pkg.dev/${var.project_id}/${var.repository_name}/${var.image_name}:latest" + # Add environment variables for the webapp + env { + name = "VITE_GCP_FUNCTION_BASE_URL" + value = "https://${var.region}-${var.project_id}.cloudfunctions.net" + } + resources { limits = { cpu = "1000m" diff --git a/_infra/cloud_functions.tf b/_infra/cloud_functions.tf index 44231ca..15e85d2 100644 --- a/_infra/cloud_functions.tf +++ b/_infra/cloud_functions.tf @@ -42,6 +42,11 @@ resource "google_service_account" "coach_file_uploader" { project = var.project_id } +resource "google_service_account" "coach_avatar_generator" { + account_id = "coach-avatar-generator" + display_name = "Service Account for Coach Avatar Generator Function" + project = var.project_id +} # Create conversation storage bucket resource "google_storage_bucket" "conversation_storage" { @@ -134,6 +139,19 @@ resource "google_project_iam_member" "coach_file_uploader_roles" { member = "serviceAccount:${google_service_account.coach_file_uploader.email}" } +resource "google_project_iam_member" "coach_avatar_generator_roles" { + for_each = toset([ + "roles/cloudfunctions.invoker", + "roles/storage.objectUser", + "roles/logging.logWriter", + "roles/iam.serviceAccountTokenCreator" + ]) + + project = var.project_id + role = each.key + member = "serviceAccount:${google_service_account.coach_avatar_generator.email}" +} + # Allow the coach file uploader service account to create tokens for itself resource "google_service_account_iam_member" "coach_file_uploader_self_token_creator" { service_account_id = google_service_account.coach_file_uploader.name @@ -173,6 +191,12 @@ resource "google_storage_bucket_iam_member" "coach_file_uploader_bucket_access" member = "serviceAccount:${google_service_account.coach_file_uploader.email}" } +resource "google_storage_bucket_iam_member" "coach_avatar_generator_content_bucket_access" { + bucket = google_storage_bucket.coach_content_bucket.name + role = "roles/storage.objectUser" + member = "serviceAccount:${google_service_account.coach_avatar_generator.email}" +} + # Grant the motivational images function access to the image bucket resource "google_storage_bucket_iam_member" "motivational_images_bucket_access" { bucket = "${var.project_id}-image-bucket" @@ -180,6 +204,13 @@ resource "google_storage_bucket_iam_member" "motivational_images_bucket_access" member = "serviceAccount:${google_service_account.motivational_images.email}" } +# Grant the avatar generator function access to the image bucket +resource "google_storage_bucket_iam_member" "coach_avatar_generator_image_bucket_access" { + bucket = "${var.project_id}-image-bucket" + role = "roles/storage.objectUser" + member = "serviceAccount:${google_service_account.coach_avatar_generator.email}" +} + resource "google_project_iam_member" "motivational_images_roles" { for_each = toset([ "roles/cloudfunctions.invoker", @@ -272,6 +303,12 @@ data "archive_file" "coach_file_uploader_zip" { excludes = ["node_modules"] } +data "archive_file" "coach_avatar_generator_zip" { + type = "zip" + source_dir = "${path.root}/../functions/coach-avatar-generator" + output_path = "${path.root}/tmp/coach-avatar-generator.zip" + excludes = ["node_modules"] +} # Upload the function sources to Cloud Storage resource "google_storage_bucket_object" "motivational_images_source" { @@ -341,6 +378,12 @@ resource "google_storage_bucket_object" "coach_file_uploader_source" { source = data.archive_file.coach_file_uploader_zip.output_path } +resource "google_storage_bucket_object" "coach_avatar_generator_source" { + name = "coach-avatar-generator-${data.archive_file.coach_avatar_generator_zip.output_md5}.zip" + bucket = google_storage_bucket.function_bucket.name + source = data.archive_file.coach_avatar_generator_zip.output_path +} + # Deploy Cloud Functions using the module module "motivation_function" { source = "./modules/cloud_function" @@ -589,6 +632,29 @@ module "coach_file_uploader_function" { ] } +module "coach_avatar_generator_function" { + source = "./modules/cloud_function" + + name = "coach-avatar-generator" + description = "Function to generate professional avatars from coach selfies" + region = var.region + bucket_name = google_storage_bucket.function_bucket.name + source_object = google_storage_bucket_object.coach_avatar_generator_source.name + entry_point = "generateCoachAvatar" + memory = "1Gi" + timeout = 540 # 9 minutes for AI image generation + service_account_email = google_service_account.coach_avatar_generator.email + + environment_variables = { + PROJECT_ID = var.project_id + SUPABASE_URL = var.supabase_url + SUPABASE_SERVICE_ROLE_KEY = var.supabase_service_role_key + REPLICATE_API_TOKEN = var.replicate_api_key + ALLOWED_ORIGINS = var.allowed_origins + } + depends_on = [google_storage_bucket_object.coach_avatar_generator_source] +} + resource "google_cloud_run_service_iam_member" "process_sms_invoker" { location = module.process_sms_function.function.location service = module.process_sms_function.function.name @@ -651,4 +717,11 @@ resource "google_cloud_run_service_iam_member" "coach_file_uploader_invoker" { service = module.coach_file_uploader_function.function.name role = "roles/run.invoker" member = "allUsers" +} + +resource "google_cloud_run_service_iam_member" "coach_avatar_generator_invoker" { + location = module.coach_avatar_generator_function.function.location + service = module.coach_avatar_generator_function.function.name + role = "roles/run.invoker" + member = "allUsers" } \ No newline at end of file diff --git a/_infra/outputs.tf b/_infra/outputs.tf index fc5d98f..091d7f9 100644 --- a/_infra/outputs.tf +++ b/_infra/outputs.tf @@ -106,6 +106,11 @@ output "coach_file_uploader_url" { value = module.coach_file_uploader_function.url } +output "coach_avatar_generator_url" { + description = "URL of the coach avatar generator function" + value = module.coach_avatar_generator_function.url +} + output "conversation_bucket_name" { description = "Name of the conversation storage bucket" value = google_storage_bucket.conversation_storage.name @@ -125,9 +130,6 @@ output "webapp_environment_variables" { description = "Environment variables needed for the webapp" value = { VITE_GCP_FUNCTION_BASE_URL = "https://${var.region}-${var.project_id}.cloudfunctions.net" - VITE_COACH_CONTENT_PROCESSOR_URL = module.coach_content_processor_function.url - VITE_COACH_RESPONSE_GENERATOR_URL = module.coach_response_generator_function.url - VITE_COACH_FILE_UPLOADER_URL = module.coach_file_uploader_function.url } sensitive = false } \ No newline at end of file diff --git a/functions/coach-avatar-generator/avatar-generation.js b/functions/coach-avatar-generator/avatar-generation.js new file mode 100644 index 0000000..eadbc7b --- /dev/null +++ b/functions/coach-avatar-generator/avatar-generation.js @@ -0,0 +1,243 @@ +const { Storage } = require('@google-cloud/storage'); +const Replicate = require('replicate'); + +const storage = new Storage(); +const projectId = process.env.PROJECT_ID || process.env.GOOGLE_CLOUD_PROJECT; +const bucketName = `${projectId}-image-bucket`; +const contentBucketName = `${projectId}-coach-content`; + +// Avatar styles (from motivational-images styles) +const AVATAR_STYLES = [ + 'Digital Art', + 'Comic book', + 'Disney Charactor', // Note: keeping original spelling from existing code +]; + +const AVATAR_MODELS = { + STYLE: { + id: "tencentarc/photomaker-style:467d062309da518648ba89d226490e02b8ed09b5abc15026e54e31c5a8cd0769", + getInput: (prompt, userPhotoUrl, style) => ({ + prompt: prompt + " professional headshot, perfect eyes, natural skin, clean background", + num_steps: 50, + input_image: userPhotoUrl, + num_outputs: 1, + style_name: style, + style_strength_ratio: 35, + negative_prompt: "nsfw, lowres, nudity, nude, naked, bad anatomy, bad hands, bad eyes, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, casual clothes, gym clothes, workout clothes", + disable_safety_checker: true + }), + requiresImage: true + }, + REALISTIC: { + id: "tencentarc/photomaker:ddfc2b08d209f9fa8c1eca692712918bd449f695dabb4a958da31802a9570fe4", + getInput: (prompt, userPhotoUrl) => ({ + prompt: prompt + " professional headshot, perfect eyes, natural skin, clean background", + num_steps: 50, + input_image: userPhotoUrl, + num_outputs: 1, + negative_prompt: "nsfw, lowres, nudity, nude, naked, bad anatomy, bad hands, bad eyes, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, casual clothes, gym clothes, workout clothes", + disable_safety_checker: true + }), + requiresImage: true + } +}; + +const AVATAR_PROMPTS = { + 'Digital Art': 'professional fitness coach portrait, digital art style, confident expression, business casual attire', + 'Comic book': 'professional fitness coach portrait, comic book art style, heroic pose, confident expression', + 'Disney Charactor': 'professional fitness coach portrait, Disney animation style, friendly expression, professional attire' +}; + +async function saveImageToBucket(imageUrl, filename) { + const bucket = storage.bucket(bucketName); + const file = bucket.file(`generated-images/coach-avatars/${filename}`); + + try { + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`); + } + + const buffer = await response.blob(); + const writeStream = file.createWriteStream({ + metadata: { + contentType: 'image/png', + cacheControl: 'public, max-age=3600', + }, + resumable: false + }); + + return new Promise(async (resolve, reject) => { + writeStream.on('finish', () => { + resolve(`https://storage.googleapis.com/${bucketName}/generated-images/coach-avatars/${filename}`); + }); + + writeStream.on('error', (error) => { + console.error(`Error writing to bucket for ${filename}:`, error); + reject(error); + }); + + try { + const reader = buffer.stream().getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + writeStream.end(); + break; + } + if (!writeStream.write(value)) { + await new Promise(resolve => writeStream.once('drain', resolve)); + } + } + } catch (error) { + writeStream.destroy(error); + reject(error); + } + }); + } catch (error) { + console.error('Error saving image to bucket:', error); + throw error; + } +} + +async function storeSelfie(coachId, imageBuffer, mimeType) { + const bucket = storage.bucket(contentBucketName); + const extension = mimeType.includes('jpeg') ? 'jpg' : 'png'; + const filename = `coach-content/${coachId}/selfie.${extension}`; + const file = bucket.file(filename); + + try { + await file.save(imageBuffer, { + metadata: { + contentType: mimeType, + cacheControl: 'private, max-age=86400', + }, + resumable: false + }); + + // Generate signed URL for internal use (24 hours) + const [signedUrl] = await file.getSignedUrl({ + version: 'v4', + action: 'read', + expires: Date.now() + 24 * 60 * 60 * 1000 // 24 hours + }); + + return { + storagePath: filename, + signedUrl: signedUrl + }; + } catch (error) { + console.error('Error storing selfie:', error); + throw error; + } +} + +async function generateAvatar(replicate, model, prompt, userPhotoUrl, style = null) { + const maxRetries = 3; + const retryDelay = 5000; // 5 seconds + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + console.log(`Generating avatar with prompt (attempt ${attempt}/${maxRetries}):`, prompt); + console.log('Using model:', model.id); + + const output = await replicate.run( + model.id, + { + input: model.getInput(prompt, userPhotoUrl, style) + } + ); + + if (!output || output.length === 0) { + throw new Error('Model returned no output'); + } + + return output[0]; + } catch (error) { + const isPaymentError = error.message?.includes('Payment Required') || + error.message?.includes('spend limit'); + + if (isPaymentError || attempt === maxRetries) { + throw error; + } + + console.warn(`Attempt ${attempt} failed:`, error.message); + console.log(`Retrying in ${retryDelay/1000} seconds...`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } +} + +async function generateCoachAvatars(coachId, imageBuffer, mimeType) { + const replicate = new Replicate({ + auth: process.env.REPLICATE_API_TOKEN, + }); + + try { + // Store the original selfie + console.log(`Storing selfie for coach ${coachId}`); + const selfieResult = await storeSelfie(coachId, imageBuffer, mimeType); + + // Generate avatars in each style + console.log(`Generating avatars for coach ${coachId} in ${AVATAR_STYLES.length} styles`); + const avatarResults = await Promise.allSettled( + AVATAR_STYLES.map(async (style) => { + try { + const prompt = AVATAR_PROMPTS[style]; + const model = AVATAR_MODELS.STYLE; + + // Generate avatar + const imageUrl = await generateAvatar(replicate, model, prompt, selfieResult.signedUrl, style); + + // Save to bucket + const filename = `${coachId}-${style.toLowerCase().replace(/\s+/g, '-')}.png`; + const publicUrl = await saveImageToBucket(imageUrl, filename); + + return { + style: style, + url: publicUrl, + filename: filename + }; + } catch (error) { + console.error(`Failed to generate ${style} avatar:`, error); + throw error; + } + }) + ); + + // Process results + const successfulAvatars = []; + const failedStyles = []; + + avatarResults.forEach((result, index) => { + if (result.status === 'fulfilled') { + successfulAvatars.push(result.value); + } else { + failedStyles.push(AVATAR_STYLES[index]); + console.error(`Failed to generate ${AVATAR_STYLES[index]} avatar:`, result.reason); + } + }); + + if (successfulAvatars.length === 0) { + throw new Error('Failed to generate any avatars'); + } + + console.log(`Successfully generated ${successfulAvatars.length} avatars for coach ${coachId}`); + + return { + avatars: successfulAvatars, + selfieUrl: selfieResult.storagePath, + failedStyles: failedStyles + }; + } catch (error) { + console.error("Error in generateCoachAvatars:", error); + throw error; + } +} + +module.exports = { + generateCoachAvatars, + AVATAR_STYLES, + storeSelfie, + saveImageToBucket +}; \ No newline at end of file diff --git a/functions/coach-avatar-generator/index.js b/functions/coach-avatar-generator/index.js new file mode 100644 index 0000000..05b928f --- /dev/null +++ b/functions/coach-avatar-generator/index.js @@ -0,0 +1,152 @@ +const { createClient } = require('@supabase/supabase-js'); +const { generateCoachAvatars } = require('./avatar-generation'); +const multer = require('multer'); +const { v4: uuidv4 } = require('uuid'); + +// Initialize Supabase +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY +); + +// Configure multer for memory storage +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + }, + fileFilter: (req, file, cb) => { + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed'), false); + } + } +}); + +// CORS headers +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '3600' +}; + +/** + * Main function to generate coach avatars + */ +exports.generateCoachAvatar = async (req, res) => { + // Set CORS headers + Object.keys(corsHeaders).forEach(key => { + res.set(key, corsHeaders[key]); + }); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.status(204).send(''); + return; + } + + // Handle file upload + upload.single('selfie')(req, res, async (err) => { + if (err) { + console.error('Upload error:', err); + return res.status(400).json({ + error: err.message || 'File upload failed' + }); + } + + try { + const { coachId } = req.body; + const file = req.file; + + if (!coachId) { + return res.status(400).json({ error: 'coachId is required' }); + } + + if (!file) { + return res.status(400).json({ error: 'Selfie image is required' }); + } + + console.log(`Processing avatar generation for coach ${coachId}`); + console.log(`File info: ${file.originalname}, ${file.mimetype}, ${file.size} bytes`); + + // Generate avatars + const result = await generateCoachAvatars(coachId, file.buffer, file.mimetype); + + res.status(200).json({ + success: true, + coachId: coachId, + avatars: result.avatars, + selfieStoragePath: result.selfieUrl, + failedStyles: result.failedStyles, + message: `Generated ${result.avatars.length} avatar options` + }); + + } catch (error) { + console.error('Avatar generation error:', error); + res.status(500).json({ + error: error.message || 'Failed to generate avatars', + details: process.env.NODE_ENV === 'development' ? error.stack : undefined + }); + } + }); +}; + +/** + * Function to save selected avatar to coach profile + */ +exports.saveSelectedAvatar = async (req, res) => { + // Set CORS headers + Object.keys(corsHeaders).forEach(key => { + res.set(key, corsHeaders[key]); + }); + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.status(204).send(''); + return; + } + + try { + const { coachId, selectedAvatarUrl, avatarStyle, originalSelfieUrl } = req.body; + + if (!coachId || !selectedAvatarUrl || !avatarStyle) { + return res.status(400).json({ + error: 'coachId, selectedAvatarUrl, and avatarStyle are required' + }); + } + + console.log(`Saving selected avatar for coach ${coachId}: ${avatarStyle}`); + + // Update coach profile with selected avatar + const { data, error } = await supabase + .from('coach_profiles') + .update({ + avatar_url: selectedAvatarUrl, + avatar_style: avatarStyle, + original_selfie_url: originalSelfieUrl, + updated_at: new Date().toISOString() + }) + .eq('id', coachId) + .select() + .single(); + + if (error) { + console.error('Database error:', error); + throw error; + } + + res.status(200).json({ + success: true, + coach: data, + message: 'Avatar saved successfully' + }); + + } catch (error) { + console.error('Save avatar error:', error); + res.status(500).json({ + error: error.message || 'Failed to save selected avatar' + }); + } +}; \ No newline at end of file diff --git a/functions/coach-avatar-generator/package.json b/functions/coach-avatar-generator/package.json new file mode 100644 index 0000000..b260c7b --- /dev/null +++ b/functions/coach-avatar-generator/package.json @@ -0,0 +1,19 @@ +{ + "name": "coach-avatar-generator", + "version": "1.0.0", + "description": "Generates professional avatars from coach selfies using AI", + "main": "index.js", + "scripts": { + "start": "functions-framework --target=generateCoachAvatar", + "deploy": "gcloud functions deploy generateCoachAvatar --runtime nodejs18 --trigger http" + }, + "dependencies": { + "@google-cloud/functions-framework": "4.0.0", + "@google-cloud/storage": "^7.0.0", + "@supabase/supabase-js": "^2.38.0", + "replicate": "1.0.1", + "uuid": "^9.0.0", + "multer": "2.0.0", + "sharp": "^0.32.0" + } +} \ No newline at end of file diff --git a/supabase/migrations/20250101000001_allow_public_coach_access.sql b/supabase/migrations/20250101000001_allow_public_coach_access.sql new file mode 100644 index 0000000..30dcd8a --- /dev/null +++ b/supabase/migrations/20250101000001_allow_public_coach_access.sql @@ -0,0 +1,23 @@ +/* + # Allow Public Access to Public Coaches + + This migration updates the RLS policies to allow unauthenticated users + to read public coaches from the coach_profiles table. + + This is needed for the HeroCoachPage to display public custom coaches + to all users, not just authenticated ones. +*/ + +-- Drop the existing "Users can view public coaches" policy +DROP POLICY IF EXISTS "Users can view public coaches" ON public.coach_profiles; + +-- Create a new policy that allows anyone (including unauthenticated users) to view public coaches +CREATE POLICY "Anyone can view public coaches" + ON public.coach_profiles FOR SELECT + USING (public = true AND active = true); + +-- Also grant SELECT permission to anonymous users for public coaches +GRANT SELECT ON public.coach_profiles TO anon; + +-- Add helpful comment +COMMENT ON POLICY "Anyone can view public coaches" ON public.coach_profiles IS 'Allows unauthenticated users to view public coaches for the HeroCoachPage'; \ No newline at end of file diff --git a/supabase/migrations/20250101000002_add_predefined_coaches.sql b/supabase/migrations/20250101000002_add_predefined_coaches.sql new file mode 100644 index 0000000..8127ac1 --- /dev/null +++ b/supabase/migrations/20250101000002_add_predefined_coaches.sql @@ -0,0 +1,268 @@ +/* + # Add Predefined Coaches to Database + + This migration adds the predefined coaches (zen_master, gym_bro, etc.) + as actual database entries so they can use the same LLM-powered + response system as custom coaches. +*/ + +-- This migration should run AFTER the sync migration +-- Get the synced user profile for phone number +12533800282 + +-- Insert Zen Master coach +INSERT INTO public.coach_profiles ( + id, + user_id, + user_email, + name, + handle, + description, + primary_response_style, + secondary_response_style, + emotional_response_map, + communication_traits, + voice_patterns, + catchphrases, + vocabulary_preferences, + content_processed, + total_content_pieces, + processing_status, + active, + max_daily_interactions, + public, + preview_sessions, + total_conversations, + created_at, + updated_at +) VALUES ( + '11111111-1111-1111-1111-111111111111', + (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'), + (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'), + 'Zen Master', + 'zen_master', + 'A peaceful, mindful coach focused on holistic wellness and inner strength', + 'wise_mentor', + 'empathetic_mirror', + '{"calm": 9, "motivating": 7, "philosophical": 9}', + '{"energy_level": 4, "directness": 3, "emotion_focus": 8, "formality": 6}', + '{"sentence_structure": "flowing_complex", "vocabulary_level": "mindful", "pace": "slow"}', + ARRAY['Namaste', 'Listen to your body', 'Every step counts', 'Breathe deeply', 'Trust the process'], + '{"spiritual_terms": "frequent", "nature_metaphors": "common", "breathing_references": "regular"}', + true, + 0, + 'complete', + true, + 1000, + true, + 0, + 0, + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Insert Gym Bro coach +INSERT INTO public.coach_profiles ( + id, + user_id, + user_email, + name, + handle, + description, + primary_response_style, + secondary_response_style, + emotional_response_map, + communication_traits, + voice_patterns, + catchphrases, + vocabulary_preferences, + content_processed, + total_content_pieces, + processing_status, + active, + max_daily_interactions, + public, + preview_sessions, + total_conversations, + created_at, + updated_at +) VALUES ( + '22222222-2222-2222-2222-222222222222', + (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'), + (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'), + 'Gym Bro', + 'gym_bro', + 'An enthusiastic, high-energy coach focused on gains and personal records', + 'cheerleader', + 'tough_love', + '{"excited": 10, "motivating": 10, "energetic": 9}', + '{"energy_level": 9, "directness": 7, "emotion_focus": 5, "formality": 2}', + '{"sentence_structure": "short_punchy", "vocabulary_level": "casual", "pace": "fast"}', + ARRAY['Let''s GO!', 'You got this fam!', 'GAINS!', 'Crushing it!', 'Beast mode!', 'Level UP!'], + '{"gym_terminology": "extensive", "caps_usage": "frequent", "emoji_usage": "heavy"}', + true, + 0, + 'complete', + true, + 1000, + true, + 0, + 0, + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Insert Dance Teacher coach +INSERT INTO public.coach_profiles ( + id, + user_id, + user_email, + name, + handle, + description, + primary_response_style, + secondary_response_style, + emotional_response_map, + communication_traits, + voice_patterns, + catchphrases, + vocabulary_preferences, + content_processed, + total_content_pieces, + processing_status, + active, + max_daily_interactions, + public, + preview_sessions, + total_conversations, + created_at, + updated_at +) VALUES ( + '33333333-3333-3333-3333-333333333333', + (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'), + (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'), + 'Dance Teacher', + 'dance_teacher', + 'A sassy, rhythmic coach focused on movement and expression', + 'cheerleader', + 'reframe_master', + '{"sassy": 9, "encouraging": 8, "expressive": 10}', + '{"energy_level": 8, "directness": 6, "emotion_focus": 7, "formality": 3}', + '{"sentence_structure": "rhythmic_varied", "vocabulary_level": "trendy", "pace": "medium"}', + ARRAY['Work it!', 'Slay!', 'You''re serving!', 'Gorgeous!', 'Main character energy!', 'Iconic!'], + '{"dance_terminology": "frequent", "slang_usage": "heavy", "confidence_boosting": "constant"}', + true, + 0, + 'complete', + true, + 1000, + true, + 0, + 0, + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Insert Drill Sergeant coach +INSERT INTO public.coach_profiles ( + id, + user_id, + user_email, + name, + handle, + description, + primary_response_style, + secondary_response_style, + emotional_response_map, + communication_traits, + voice_patterns, + catchphrases, + vocabulary_preferences, + content_processed, + total_content_pieces, + processing_status, + active, + max_daily_interactions, + public, + preview_sessions, + total_conversations, + created_at, + updated_at +) VALUES ( + '44444444-4444-4444-4444-444444444444', + (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'), + (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'), + 'Drill Sergeant', + 'drill_sergeant', + 'A disciplined, no-nonsense coach focused on structure and results', + 'tough_love', + 'data_driven', + '{"disciplined": 10, "commanding": 9, "results_focused": 10}', + '{"energy_level": 8, "directness": 10, "emotion_focus": 3, "formality": 8}', + '{"sentence_structure": "commanding_short", "vocabulary_level": "military", "pace": "fast"}', + ARRAY['No excuses!', 'Move it!', 'Mission accomplished!', 'Execute!', 'Discipline equals freedom!'], + '{"military_terminology": "extensive", "commands": "frequent", "results_focus": "constant"}', + true, + 0, + 'complete', + true, + 1000, + true, + 0, + 0, + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Insert Frat Bro coach +INSERT INTO public.coach_profiles ( + id, + user_id, + user_email, + name, + handle, + description, + primary_response_style, + secondary_response_style, + emotional_response_map, + communication_traits, + voice_patterns, + catchphrases, + vocabulary_preferences, + content_processed, + total_content_pieces, + processing_status, + active, + max_daily_interactions, + public, + preview_sessions, + total_conversations, + created_at, + updated_at +) VALUES ( + '55555555-5555-5555-5555-555555555555', + (SELECT id FROM public.user_profiles WHERE phone_number = '+12533800282'), + (SELECT email FROM public.user_profiles WHERE phone_number = '+12533800282'), + 'Frat Bro', + 'frat_bro', + 'An over-the-top, high-energy coach focused on extreme transformation', + 'cheerleader', + 'reframe_master', + '{"extreme": 10, "energetic": 10, "wild": 9}', + '{"energy_level": 10, "directness": 8, "emotion_focus": 6, "formality": 1}', + '{"sentence_structure": "chaotic_caps", "vocabulary_level": "extreme_casual", "pace": "hyperfast"}', + ARRAY['ABSOLUTELY BONKERS!', 'BUILT DIFFERENT!', 'LEGENDARY!', 'SWOLEPOCALYPSE!', 'UNHINGED!', 'PEAK PERFORMANCE!'], + '{"extreme_language": "constant", "made_up_words": "frequent", "caps_usage": "excessive"}', + true, + 0, + 'complete', + true, + 1000, + true, + 0, + 0, + NOW(), + NOW() +) ON CONFLICT (id) DO NOTHING; + +-- Add helpful comments +COMMENT ON COLUMN public.coach_profiles.id IS 'Predefined coaches use fixed UUIDs: zen_master=11111111-1111-1111-1111-111111111111, gym_bro=22222222-2222-2222-2222-222222222222, etc.'; \ No newline at end of file diff --git a/supabase/migrations/20250101000003_sync_auth_profiles.sql b/supabase/migrations/20250101000003_sync_auth_profiles.sql new file mode 100644 index 0000000..04b8eb1 --- /dev/null +++ b/supabase/migrations/20250101000003_sync_auth_profiles.sql @@ -0,0 +1,105 @@ +/* + # Synchronize auth.users and user_profiles + + This migration ensures that auth.users and user_profiles are properly linked + and creates functions to keep them in sync going forward. +*/ + +-- First, let's see what we're working with +-- Find mismatched records +DO $$ +DECLARE + auth_id UUID; + profile_id UUID; + profile_email TEXT; +BEGIN + -- Get your profile info + SELECT id, email INTO profile_id, profile_email + FROM public.user_profiles + WHERE phone_number = '+12533800282'; + + -- Get the auth user with this email + SELECT id INTO auth_id + FROM auth.users + WHERE email = profile_email; + + RAISE NOTICE 'Profile ID: %, Auth ID: %, Email: %', profile_id, auth_id, profile_email; + + -- If they don't match, fix it + IF auth_id IS NOT NULL AND auth_id != profile_id THEN + RAISE NOTICE 'Fixing ID mismatch - updating profile to use auth ID'; + + -- Update the user_profiles table to use the correct auth ID + UPDATE public.user_profiles + SET id = auth_id + WHERE phone_number = '+12533800282'; + + RAISE NOTICE 'Updated profile ID from % to %', profile_id, auth_id; + END IF; +END $$; + +-- Create a function to handle auth user creation +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + -- When a new auth user is created, create a corresponding user profile + INSERT INTO public.user_profiles (id, email, created_at, updated_at) + VALUES (NEW.id, NEW.email, NOW(), NOW()) + ON CONFLICT (id) DO UPDATE SET + email = NEW.email, + updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Create trigger to automatically sync new auth users +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- Create a function to get or create a unified user +CREATE OR REPLACE FUNCTION public.get_or_create_user( + user_email TEXT, + user_phone TEXT DEFAULT NULL, + user_full_name TEXT DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + user_id UUID; + auth_user_id UUID; +BEGIN + -- First try to find existing auth user by email + SELECT id INTO auth_user_id FROM auth.users WHERE email = user_email; + + IF auth_user_id IS NOT NULL THEN + -- Auth user exists, ensure profile exists and is synced + INSERT INTO public.user_profiles ( + id, email, phone_number, full_name, created_at, updated_at + ) VALUES ( + auth_user_id, user_email, user_phone, user_full_name, NOW(), NOW() + ) ON CONFLICT (id) DO UPDATE SET + email = user_email, + phone_number = COALESCE(user_phone, user_profiles.phone_number), + full_name = COALESCE(user_full_name, user_profiles.full_name), + updated_at = NOW(); + + RETURN auth_user_id; + ELSE + -- No auth user exists, this shouldn't happen in normal flow + -- But we can handle it by creating both + user_id := gen_random_uuid(); + + INSERT INTO auth.users (id, email, email_confirmed_at, created_at, updated_at) + VALUES (user_id, user_email, NOW(), NOW(), NOW()); + + INSERT INTO public.user_profiles ( + id, email, phone_number, full_name, created_at, updated_at + ) VALUES ( + user_id, user_email, user_phone, user_full_name, NOW(), NOW() + ); + + RETURN user_id; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; \ No newline at end of file diff --git a/supabase/migrations/20250127000000_add_coach_avatar_support.sql b/supabase/migrations/20250127000000_add_coach_avatar_support.sql new file mode 100644 index 0000000..65f199d --- /dev/null +++ b/supabase/migrations/20250127000000_add_coach_avatar_support.sql @@ -0,0 +1,27 @@ +/* + # Add Coach Avatar Support Migration + + Adds avatar functionality to the coach builder system: + 1. Add avatar_url column for generated professional avatar (public) + 2. Add original_selfie_url column for uploaded selfie (private, for regeneration) + 3. Add avatar_style column to track chosen style + 4. Add indexes for performance +*/ + +-- Add avatar columns to coach_profiles table +ALTER TABLE public.coach_profiles +ADD COLUMN avatar_url text, +ADD COLUMN original_selfie_url text, +ADD COLUMN avatar_style text DEFAULT 'Digital Art'; + +-- Add helpful comments +COMMENT ON COLUMN public.coach_profiles.avatar_url IS 'Generated avatar image URL for the coach (public, displayed in UI)'; +COMMENT ON COLUMN public.coach_profiles.original_selfie_url IS 'Original selfie uploaded by coach creator (private, for regeneration)'; +COMMENT ON COLUMN public.coach_profiles.avatar_style IS 'Style used for avatar generation (e.g., Digital Art, Comic book, Disney Character)'; + +-- Add indexes for performance +CREATE INDEX coach_profiles_avatar_url_idx ON public.coach_profiles(avatar_url); +CREATE INDEX coach_profiles_avatar_style_idx ON public.coach_profiles(avatar_style); + +-- Grant necessary permissions for service role (cloud functions) +GRANT UPDATE ON public.coach_profiles TO service_role; \ No newline at end of file diff --git a/webapp/Dockerfile b/webapp/Dockerfile index 6e4ccee..a49eb02 100644 --- a/webapp/Dockerfile +++ b/webapp/Dockerfile @@ -9,12 +9,14 @@ ARG VITE_STRIPE_PUBLIC_KEY ARG VITE_API_URL ARG VITE_SUPABASE_URL ARG VITE_SUPABASE_ANON_KEY +ARG VITE_GCP_FUNCTION_BASE_URL # Set environment variables ENV VITE_STRIPE_PUBLIC_KEY=$VITE_STRIPE_PUBLIC_KEY ENV VITE_API_URL=$VITE_API_URL ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY +ENV VITE_GCP_FUNCTION_BASE_URL=$VITE_GCP_FUNCTION_BASE_URL # Copy package files COPY package*.json ./ diff --git a/webapp/src/App.jsx b/webapp/src/App.jsx index 2a450eb..d5c8ae5 100644 --- a/webapp/src/App.jsx +++ b/webapp/src/App.jsx @@ -18,6 +18,7 @@ import { CoachBuilderProvider } from './contexts/CoachBuilderContext'; import CoachBuilderLanding from './components/CoachBuilder/CoachBuilderLanding'; import PersonalityQuestionnaire from './components/CoachBuilder/PersonalityQuestionnaire'; import ContentUpload from './components/CoachBuilder/ContentUpload'; +import AvatarUpload from './components/CoachBuilder/AvatarUpload'; import CoachPreview from './components/CoachBuilder/CoachPreview'; import CoachSavePrompt from './components/CoachBuilder/CoachSavePrompt'; import CoachDashboard from './components/MyCoaches/CoachDashboard'; @@ -507,6 +508,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/webapp/src/components/CoachBuilder/AvatarUpload.jsx b/webapp/src/components/CoachBuilder/AvatarUpload.jsx new file mode 100644 index 0000000..5d19c63 --- /dev/null +++ b/webapp/src/components/CoachBuilder/AvatarUpload.jsx @@ -0,0 +1,375 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useCoachBuilder } from '../../contexts/CoachBuilderContext'; +import ProgressStepper from './components/ProgressStepper'; + +const AvatarUpload = () => { + const navigate = useNavigate(); + const { coachData, updateAvatar, nextStep, prevStep } = useCoachBuilder(); + + const [dragOver, setDragOver] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [generatedAvatars, setGeneratedAvatars] = useState([]); + const [selectedAvatar, setSelectedAvatar] = useState(null); + const [generationError, setGenerationError] = useState(''); + + const handleDragOver = (e) => { + e.preventDefault(); + setDragOver(true); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + setDragOver(false); + }; + + const handleDrop = (e) => { + e.preventDefault(); + setDragOver(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + handleFileSelect(files[0]); + } + }; + + const handleFileInputChange = (e) => { + const file = e.target.files[0]; + if (file) { + handleFileSelect(file); + } + }; + + const handleFileSelect = (file) => { + // Validate file type + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + + // Validate file size (10MB limit) + if (file.size > 10 * 1024 * 1024) { + alert('File size must be less than 10MB'); + return; + } + + setSelectedFile(file); + + // Create preview URL + const url = URL.createObjectURL(file); + setPreviewUrl(url); + + // Clear previous generation results + setGeneratedAvatars([]); + setSelectedAvatar(null); + setGenerationError(''); + }; + + const handleGenerateAvatars = async () => { + if (!selectedFile) { + alert('Please select a selfie first'); + return; + } + + setIsGenerating(true); + setGenerationError(''); + + try { + // Create form data + const formData = new FormData(); + formData.append('selfie', selectedFile); + formData.append('coachId', coachData.tempCoachId || `temp-${Date.now()}`); + + // Call avatar generation function + const response = await fetch(`${import.meta.env.VITE_GCP_FUNCTION_BASE_URL}/coach-avatar-generator`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to generate avatars'); + } + + const result = await response.json(); + + if (result.avatars && result.avatars.length > 0) { + setGeneratedAvatars(result.avatars); + + // Auto-select the first avatar + setSelectedAvatar(result.avatars[0]); + + // Store generation info in coach data + updateAvatar({ + generatedAvatars: result.avatars, + selectedAvatar: result.avatars[0], + originalSelfieUrl: result.selfieStoragePath, + tempCoachId: formData.get('coachId') + }); + + if (result.failedStyles && result.failedStyles.length > 0) { + console.warn('Some avatar styles failed to generate:', result.failedStyles); + } + } else { + throw new Error('No avatars were generated'); + } + + } catch (error) { + console.error('Error generating avatars:', error); + setGenerationError(error.message); + + // If generation completely fails, use the selfie as avatar + if (previewUrl) { + const fallbackAvatar = { + style: 'Original Photo', + url: previewUrl, + filename: selectedFile.name + }; + setGeneratedAvatars([fallbackAvatar]); + setSelectedAvatar(fallbackAvatar); + + updateAvatar({ + generatedAvatars: [fallbackAvatar], + selectedAvatar: fallbackAvatar, + originalSelfieUrl: previewUrl, + tempCoachId: coachData.tempCoachId || `temp-${Date.now()}` + }); + } + } finally { + setIsGenerating(false); + } + }; + + const handleAvatarSelect = (avatar) => { + setSelectedAvatar(avatar); + updateAvatar({ + ...coachData.avatarData, + selectedAvatar: avatar + }); + }; + + const handleNext = () => { + if (!selectedAvatar) { + alert('Please select an avatar to continue'); + return; + } + + nextStep(); + navigate('/coach-builder/preview'); + }; + + const handlePrev = () => { + prevStep(); + navigate('/coach-builder/content'); + }; + + const handleSkip = () => { + // Update coach data to indicate no avatar + updateAvatar({ + generatedAvatars: [], + selectedAvatar: null, + originalSelfieUrl: null, + skipped: true + }); + + nextStep(); + navigate('/coach-builder/preview'); + }; + + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Content */} +
+
+ {/* Header */} +
+

+ Create Your Avatar +

+

+ Upload a selfie to generate professional avatar options for your AI coach +

+
+ + {/* File Upload Area */} + {!selectedFile && ( +
+
+
📸
+

+ Drop your selfie here or click to browse +

+

+ JPG, PNG up to 10MB +

+ + +
+
+ )} + + {/* Selected File Preview */} + {selectedFile && previewUrl && ( +
+

Your Selfie

+
+
+ Selected selfie +
+
+

{selectedFile.name}

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+
+ + +
+
+
+
+ )} + + {/* Generation Status */} + {isGenerating && ( +
+
+

+ Generating your professional avatars... +

+

+ This may take 1-2 minutes +

+
+ )} + + {/* Generation Error */} + {generationError && ( +
+

Avatar Generation Failed

+

{generationError}

+

+ Don't worry! We'll use your original photo as your avatar. +

+
+ )} + + {/* Generated Avatars */} + {generatedAvatars.length > 0 && ( +
+

+ Choose Your Avatar Style +

+
+ {generatedAvatars.map((avatar, index) => ( +
handleAvatarSelect(avatar)} + className={`p-4 border-2 rounded-lg cursor-pointer transition-all ${ + selectedAvatar?.style === avatar.style + ? 'border-blue-500 bg-blue-50' + : 'border-gray-200 hover:border-gray-300' + }`} + > + {`${avatar.style} +

+ {avatar.style} +

+ {selectedAvatar?.style === avatar.style && ( +
+ + ✓ Selected + +
+ )} +
+ ))} +
+
+ )} + + {/* Navigation */} +
+ + + + + +
+
+
+
+ ); +}; + +export default AvatarUpload; \ No newline at end of file diff --git a/webapp/src/components/CoachBuilder/CoachPreview.jsx b/webapp/src/components/CoachBuilder/CoachPreview.jsx index c8d4a39..3858997 100644 --- a/webapp/src/components/CoachBuilder/CoachPreview.jsx +++ b/webapp/src/components/CoachBuilder/CoachPreview.jsx @@ -72,7 +72,7 @@ const CoachPreview = () => { const handlePrev = () => { prevStep(); - navigate('/coach-builder/content'); + navigate('/coach-builder/avatar'); }; const formatTime = (timestamp) => { @@ -95,6 +95,20 @@ const CoachPreview = () => {

Your AI Coach

+ {/* Avatar Display */} + {coachData.avatarData?.selectedAvatar && ( +
+ Coach Avatar +

+ {coachData.avatarData.selectedAvatar.style} Style +

+
+ )} +

Name

@@ -132,6 +146,16 @@ const CoachPreview = () => {

{coachData.content?.length || 0} files

+
+

Avatar

+

+ {coachData.avatarData?.selectedAvatar ? + `${coachData.avatarData.selectedAvatar.style} style` : + coachData.avatarData?.skipped ? 'Skipped' : 'Not set' + } +

+
+ {/* Content Processing Status */} {coachData.content && coachData.content.length > 0 && (
diff --git a/webapp/src/components/CoachBuilder/ContentUpload.jsx b/webapp/src/components/CoachBuilder/ContentUpload.jsx index f54262d..7c03a39 100644 --- a/webapp/src/components/CoachBuilder/ContentUpload.jsx +++ b/webapp/src/components/CoachBuilder/ContentUpload.jsx @@ -95,7 +95,7 @@ const ContentUpload = () => { return; } nextStep(); - navigate('/coach-builder/preview'); + navigate('/coach-builder/avatar'); }; const handlePrev = () => { diff --git a/webapp/src/components/HeroCoachPage.jsx b/webapp/src/components/HeroCoachPage.jsx index 8cff328..7a483f5 100644 --- a/webapp/src/components/HeroCoachPage.jsx +++ b/webapp/src/components/HeroCoachPage.jsx @@ -237,9 +237,9 @@ export default function HeroCoachPage() { activities: isPredefined ? (COACH_PERSONAS[coach.handle]?.activities || ['Fitness coaching', 'Motivation', 'Wellness guidance']) : ['Custom coaching', 'Personalized motivation', 'AI-powered guidance'], - image: isPredefined && COACH_IMAGES[coach.handle] - ? COACH_IMAGES[coach.handle] - : COACH_IMAGES.custom_default, + // Use avatar_url if available, otherwise fallback to predefined or default image + image: coach.avatar_url || + (isPredefined && COACH_IMAGES[coach.handle] ? COACH_IMAGES[coach.handle] : COACH_IMAGES.custom_default), foods: isPredefined && COACH_FOODS[coach.handle] ? COACH_FOODS[coach.handle] : COACH_FOODS.custom_default, diff --git a/webapp/src/contexts/CoachBuilderContext.jsx b/webapp/src/contexts/CoachBuilderContext.jsx index 1f179bb..5dfd828 100644 --- a/webapp/src/contexts/CoachBuilderContext.jsx +++ b/webapp/src/contexts/CoachBuilderContext.jsx @@ -26,18 +26,27 @@ export const CoachBuilderProvider = ({ children }) => { catchphrases: [], vocabulary_preferences: {}, content: [], - public: false + public: false, + // Avatar data + avatarData: { + generatedAvatars: [], + selectedAvatar: null, + originalSelfieUrl: null, + tempCoachId: null, + skipped: false + } }); const [isProcessing, setIsProcessing] = useState(false); const [previewMode, setPreviewMode] = useState(false); const [uploadedFiles, setUploadedFiles] = useState([]); const [processingStatus, setProcessingStatus] = useState('idle'); // 'idle', 'uploading', 'processing', 'complete', 'error' - // Steps in the coach builder flow + // Steps in the coach builder flow (now includes avatar upload) const steps = [ { id: 'landing', name: 'Welcome', path: '/coach-builder' }, { id: 'personality', name: 'Personality', path: '/coach-builder/personality' }, { id: 'content', name: 'Content Upload', path: '/coach-builder/content' }, + { id: 'avatar', name: 'Avatar Upload', path: '/coach-builder/avatar' }, { id: 'preview', name: 'Preview & Test', path: '/coach-builder/preview' }, { id: 'save', name: 'Save Coach', path: '/coach-builder/save' } ]; @@ -50,6 +59,17 @@ export const CoachBuilderProvider = ({ children }) => { })); }; + // Update avatar data + const updateAvatar = (avatarData) => { + setCoachData(prev => ({ + ...prev, + avatarData: { + ...prev.avatarData, + ...avatarData + } + })); + }; + // Add content file to the coach const addContent = async (contentData) => { try { @@ -257,7 +277,7 @@ export const CoachBuilderProvider = ({ children }) => { return styleResponses[Math.floor(Math.random() * styleResponses.length)]; }; - // Save coach to database (requires authentication) + // Save coach to database (requires authentication) - Updated to include avatar const saveCoach = async (userEmail) => { try { setIsProcessing(true); @@ -314,7 +334,11 @@ export const CoachBuilderProvider = ({ children }) => { vocabulary_preferences: coachData.vocabulary_preferences, public: coachData.public, content_processed: false, - total_content_pieces: uploadedFiles.length + total_content_pieces: uploadedFiles.length, + // Avatar fields - only include if avatar was created + avatar_url: coachData.avatarData.selectedAvatar?.url || null, + avatar_style: coachData.avatarData.selectedAvatar?.style || null, + original_selfie_url: coachData.avatarData.originalSelfieUrl || null }; // Insert coach profile @@ -326,6 +350,27 @@ export const CoachBuilderProvider = ({ children }) => { if (coachError) throw coachError; + // If avatar was generated, save the selected avatar choice + if (coachData.avatarData.selectedAvatar && !coachData.avatarData.skipped) { + try { + await fetch(`${import.meta.env.VITE_GCP_FUNCTION_BASE_URL}/coach-avatar-generator/save-avatar`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + coachId: coach.id, + selectedAvatarUrl: coachData.avatarData.selectedAvatar.url, + avatarStyle: coachData.avatarData.selectedAvatar.style, + originalSelfieUrl: coachData.avatarData.originalSelfieUrl + }) + }); + } catch (avatarError) { + console.warn('Failed to save avatar data via cloud function:', avatarError); + // Continue with coach creation even if avatar save fails + } + } + // Process uploaded content files if (uploadedFiles.length > 0) { console.log('Processing uploaded content...'); @@ -363,7 +408,14 @@ export const CoachBuilderProvider = ({ children }) => { catchphrases: [], vocabulary_preferences: {}, content: [], - public: false + public: false, + avatarData: { + generatedAvatars: [], + selectedAvatar: null, + originalSelfieUrl: null, + tempCoachId: null, + skipped: false + } }); setUploadedFiles([]); setCurrentStep(0); @@ -448,6 +500,7 @@ export const CoachBuilderProvider = ({ children }) => { // Actions updatePersonality, + updateAvatar, addContent, removeContent, processContent,